Skip to main content

blockworx/tools/
timeline.rs

1// A history to browse needs a container to keep it in, which on the web waits
2// on OPFS (todo.md P5). Building this for wasm regardless is what keeps it from
3// quietly acquiring a native-only dependency in the meantime.
4#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
5
6//! Browsing a container's history.
7//!
8//! The rows come from the sidecars alone — `history/*.json` — so opening the
9//! timeline reads a few hundred bytes per entry rather than a snapshot each.
10//! That is what the sidecars are for; a snapshot is only decompressed when a
11//! row is actually restored.
12
13use crate::storage::history::{Record, Session};
14use crate::tools::tool::Action;
15
16/// One line in the timeline, oldest first.
17#[derive(Clone, PartialEq, Eq, Debug)]
18pub enum Row {
19    /// The document was opened. Everything below it, until the next one, is
20    /// that session's work.
21    Session { ts: u64 },
22    /// One settled edit.
23    Entry {
24        seq: u64,
25        ts: u64,
26        command: Option<String>,
27        changed: Vec<String>,
28    },
29}
30
31/// Interleave the entries with the session markers, oldest first.
32///
33/// A session records the entry number it began at, so its marker belongs
34/// immediately before that entry — including when a session made no edits at
35/// all, which is exactly when the marker is the only evidence it happened.
36pub fn rows(records: &[Record], sessions: &[Session]) -> Vec<Row> {
37    let mut rows = Vec::with_capacity(records.len() + sessions.len());
38    let mut sessions = sessions.iter().peekable();
39    for record in records {
40        while let Some(session) = sessions.peek() {
41            if session.seq > record.seq {
42                break;
43            }
44            rows.push(Row::Session { ts: session.ts });
45            sessions.next();
46        }
47        let meta = record.meta.clone().unwrap_or_default();
48        rows.push(Row::Entry {
49            seq: record.seq,
50            ts: meta.ts,
51            command: meta.command,
52            changed: meta.changed,
53        });
54    }
55    // Sessions numbered past the last entry: opened, nothing edited yet.
56    rows.extend(sessions.map(|s| Row::Session { ts: s.ts }));
57    rows
58}
59
60/// What a row says it did. An entry that went through a named command says so;
61/// one from dragging on the canvas has only the ids it touched, which is why
62/// those are the part that always gets recorded.
63pub fn summary(command: Option<&str>, changed: &[String]) -> String {
64    let what = match changed.len() {
65        0 => "no visible change".to_string(),
66        1..=3 => changed.join(", "),
67        n => format!("{}, and {} more", changed[..2].join(", "), n - 2),
68    };
69    match command {
70        Some(command) => format!("{command} — {what}"),
71        None => what,
72    }
73}
74
75/// `ts` as a clock time relative to `now`, both in milliseconds since the epoch.
76///
77/// Deliberately coarse and relative: what a person wants from a history row is
78/// how long ago, and an absolute date would need a calendar the app has no
79/// other use for.
80pub fn ago(ts: u64, now: u64) -> String {
81    let seconds = now.saturating_sub(ts) / 1000;
82    match seconds {
83        0..=4 => "just now".to_string(),
84        5..=59 => format!("{seconds}s ago"),
85        60..=3599 => format!("{}m ago", seconds / 60),
86        3600..=86_399 => format!("{}h ago", seconds / 3600),
87        _ => format!("{}d ago", seconds / 86_400),
88    }
89}
90
91/// The timeline window. Returns an action when a row is restored, and clears
92/// `open` when the window is closed.
93pub fn timeline(ctx: &egui::Context, open: &mut bool, rows: &[Row], now: u64) -> Option<Action> {
94    let mut action = None;
95    let mut showing = true;
96    egui::Window::new("Timeline")
97        .id(egui::Id::new("timeline"))
98        .open(&mut showing)
99        .resizable(true)
100        .default_width(360.0)
101        .show(ctx, |ui| {
102            if rows.is_empty() {
103                ui.label("No history yet — edits are recorded as you make them.");
104                return;
105            }
106            egui::ScrollArea::vertical().show(ui, |ui| {
107                // Newest first: the recent past is what anyone is looking for.
108                for row in rows.iter().rev() {
109                    match row {
110                        Row::Session { ts } => {
111                            ui.add_space(4.0);
112                            ui.label(
113                                egui::RichText::new(format!("opened {}", ago(*ts, now))).weak(),
114                            );
115                            ui.separator();
116                        }
117                        Row::Entry {
118                            seq,
119                            ts,
120                            command,
121                            changed,
122                        } => {
123                            ui.horizontal(|ui| {
124                                ui.label(egui::RichText::new(ago(*ts, now)).weak());
125                                if ui
126                                    .button(summary(command.as_deref(), changed))
127                                    .on_hover_text("Restore this state as a new edit")
128                                    .clicked()
129                                {
130                                    action = Some(Action::RestoreHistory(*seq));
131                                }
132                            });
133                        }
134                    }
135                }
136            });
137        });
138    if !showing {
139        *open = false;
140    }
141    action
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::storage::history::Entry;
148
149    fn record(seq: u64, changed: &[&str]) -> Record {
150        Record {
151            seq,
152            meta: Some(Entry {
153                ts: 1000 * seq,
154                command: None,
155                changed: changed.iter().map(|s| (*s).to_string()).collect(),
156            }),
157        }
158    }
159
160    #[test]
161    fn a_session_marker_precedes_the_work_it_started() {
162        let records = vec![record(0, &["b1"]), record(1, &["b2"]), record(2, &["b3"])];
163        let sessions = vec![Session { ts: 1, seq: 0 }, Session { ts: 2, seq: 2 }];
164
165        assert_eq!(
166            rows(&records, &sessions),
167            vec![
168                Row::Session { ts: 1 },
169                Row::Entry {
170                    seq: 0,
171                    ts: 0,
172                    command: None,
173                    changed: vec!["b1".to_string()],
174                },
175                Row::Entry {
176                    seq: 1,
177                    ts: 1000,
178                    command: None,
179                    changed: vec!["b2".to_string()],
180                },
181                Row::Session { ts: 2 },
182                Row::Entry {
183                    seq: 2,
184                    ts: 2000,
185                    command: None,
186                    changed: vec!["b3".to_string()],
187                },
188            ]
189        );
190    }
191
192    /// A session that edited nothing is exactly when its marker is the only
193    /// record that it happened at all.
194    #[test]
195    fn a_session_that_made_no_edits_still_appears() {
196        let rows = rows(
197            &[record(0, &["b1"])],
198            &[Session { ts: 1, seq: 0 }, Session { ts: 2, seq: 1 }],
199        );
200        assert_eq!(rows.last(), Some(&Row::Session { ts: 2 }));
201        assert_eq!(rows.len(), 3);
202    }
203
204    /// An entry whose sidecar never landed is still browsable — unlabelled, but
205    /// present and restorable.
206    #[test]
207    fn an_unlabelled_entry_still_gets_a_row() {
208        let rows = rows(&[Record { seq: 4, meta: None }], &[]);
209        assert_eq!(
210            rows,
211            vec![Row::Entry {
212                seq: 4,
213                ts: 0,
214                command: None,
215                changed: Vec::new(),
216            }]
217        );
218    }
219
220    #[test]
221    fn a_summary_names_the_command_and_what_it_touched() {
222        assert_eq!(summary(Some("delete"), &["b3".to_string()]), "delete — b3");
223        assert_eq!(summary(None, &["b3".to_string()]), "b3");
224        assert_eq!(summary(None, &[]), "no visible change");
225    }
226
227    /// A drag can touch a lot; a row must stay one line.
228    #[test]
229    fn a_long_change_list_is_abbreviated() {
230        let many: Vec<String> = (0..9).map(|n| format!("b{n}")).collect();
231        assert_eq!(summary(None, &many), "b0, b1, and 7 more");
232    }
233
234    #[test]
235    fn elapsed_time_reads_in_the_largest_useful_unit() {
236        let now = 100 * 86_400 * 1000;
237        assert_eq!(ago(now, now), "just now");
238        assert_eq!(ago(now - 30_000, now), "30s ago");
239        assert_eq!(ago(now - 5 * 60_000, now), "5m ago");
240        assert_eq!(ago(now - 3 * 3_600_000, now), "3h ago");
241        assert_eq!(ago(now - 2 * 86_400_000, now), "2d ago");
242        // A clock that went backwards must not underflow into "eons ago".
243        assert_eq!(ago(now + 5000, now), "just now");
244    }
245}