Skip to main content

kitchen_ui/
timeline.rs

1//! An episodic memory of the work: what the person and the agent did, over time, as a story one
2//! can read back (after Scott Jenson's "Episodic Memory" prototype). It shows attention, not
3//! content: which kind of work, when, for how long and how busily.
4//!
5//! - **Input**: [`Activity`]: one thing that happened (a command run, time in a workspace, a job),
6//!   its lane (who or what: you, the agent, jobs), its kind (the colour), what it was, where it
7//!   came from (its provenance: the workspace, how it was started) and what it produced (assets).
8//! - **Synthesis**: [`episodes`] turns many activities into a few episodes: neighbours of the same
9//!   kind merge, then the shortest episodes fold into their neighbours until a handful is left
10//!   (50 browser visits become "Research").
11//! - **View**: [`Timeline`]: one row per lane, a bar per episode (its width the time, its height
12//!   how busy it was, its colour its kind), the hours under it, a card on hover, and, for the
13//!   episode clicked, context cards below: each thing done in it, where it came from and what it
14//!   produced. Summary shows the episodes, All every activity.
15
16use std::collections::HashMap;
17
18use gpui_kit::component::{h_flex, v_flex};
19use gpui_kit::prelude::FluentBuilder as _;
20use gpui_kit::{
21    Context, EventEmitter, FontWeight, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _,
22    TestSupportExt as _, Window, div, px, relative,
23};
24
25use crate::theme::{self, color};
26use crate::widgets::MenuSelect;
27
28/// One thing that happened.
29#[derive(Clone, Debug, Default, PartialEq)]
30pub struct Activity {
31    /// When it started (seconds since 1970, UTC).
32    pub start: f64,
33    /// When it ended (the same as `start` for an instant: it then lasts until the next one).
34    pub end: f64,
35    /// Who or what did it: its row ("You", "Agent", "Jobs", "Focus").
36    pub lane: SharedString,
37    /// Its kind: the colour and the episode it joins ("Analyze", "Pipeline", "histology").
38    pub kind: SharedString,
39    /// What it was ("Find cells").
40    pub title: SharedString,
41    /// A short line about it (its arguments, its result).
42    pub detail: SharedString,
43    /// Where it came from: its provenance ("Explore · menu", "agent · lumen MCP").
44    pub source: SharedString,
45    /// What it produced or used (files, ids).
46    pub assets: Vec<SharedString>,
47}
48
49/// Several activities read as one: a stretch of one kind of work.
50#[derive(Clone, Debug, PartialEq)]
51pub struct Episode {
52    /// When it started (seconds since 1970).
53    pub start: f64,
54    /// When it ended.
55    pub end: f64,
56    /// Its row.
57    pub lane: SharedString,
58    /// Its kind (the colour).
59    pub kind: SharedString,
60    /// What it was mostly ("Find cells ×3").
61    pub title: SharedString,
62    /// The activities in it, in time order.
63    pub items: Vec<Activity>,
64}
65
66impl Episode {
67    /// How long it lasted (s).
68    pub fn duration(&self) -> f64 {
69        (self.end - self.start).max(0.0)
70    }
71
72    /// How busy it was: activities per minute.
73    pub fn intensity(&self) -> f64 {
74        self.items.len() as f64 / (self.duration() / 60.0).max(1.0)
75    }
76
77    fn of(items: Vec<Activity>) -> Episode {
78        let start = items.iter().map(|a| a.start).fold(f64::INFINITY, f64::min);
79        let end = items.iter().map(|a| a.end).fold(f64::NEG_INFINITY, f64::max);
80        // The kind and the title that cover the most time
81        let mut by_kind: HashMap<SharedString, f64> = HashMap::new();
82        let mut by_title: HashMap<SharedString, (usize, f64)> = HashMap::new();
83        for a in &items {
84            *by_kind.entry(a.kind.clone()).or_default() += (a.end - a.start).max(1.0);
85            let t = by_title.entry(a.title.clone()).or_default();
86            t.0 += 1;
87            t.1 += (a.end - a.start).max(1.0);
88        }
89        let kind = by_kind.into_iter().max_by(|a, b| a.1.total_cmp(&b.1)).map(|k| k.0).unwrap_or_default();
90        let (title, (n, _)) = by_title.into_iter().max_by(|a, b| (a.1.0, a.1.1).partial_cmp(&(b.1.0, b.1.1)).unwrap_or(std::cmp::Ordering::Equal)).unwrap_or_default();
91        let title = if n > 1 { format!("{title} ×{n}").into() } else { title };
92        Episode { start, end, lane: items.first().map(|a| a.lane.clone()).unwrap_or_default(), kind, title, items }
93    }
94}
95
96/// The activities with their ends filled in: an instant lasts until the next activity in its lane,
97/// at most `idle` seconds (then the person was away), and at least one second.
98pub fn fill_ends(items: &[Activity], idle: f64, now: f64) -> Vec<Activity> {
99    let mut out: Vec<Activity> = items.to_vec();
100    out.sort_by(|a, b| a.start.total_cmp(&b.start));
101    let mut next_in_lane: HashMap<SharedString, f64> = HashMap::new();
102    for a in out.iter_mut().rev() {
103        if a.end <= a.start {
104            let next = next_in_lane.get(&a.lane).copied().unwrap_or(now);
105            a.end = (a.start + idle).min(next).max(a.start + 1.0);
106        }
107        next_in_lane.insert(a.lane.clone(), a.start);
108    }
109    out
110}
111
112/// Every activity as an episode of its own (the "All" view).
113pub fn each(items: &[Activity]) -> Vec<Episode> {
114    let mut out: Vec<Episode> = items.iter().map(|a| Episode::of(vec![a.clone()])).collect();
115    out.sort_by(|a, b| a.start.total_cmp(&b.start));
116    out
117}
118
119/// The story in a few episodes: per lane, neighbours of the same kind less than `gap` seconds
120/// apart merge; then, while there are more than `target` episodes, the shortest one folds into
121/// the neighbour in its lane it is closer to (in time), until `target` are left or nothing can merge.
122pub fn episodes(items: &[Activity], target: usize, gap: f64) -> Vec<Episode> {
123    let mut lanes: Vec<SharedString> = Vec::new();
124    for a in items {
125        if !lanes.contains(&a.lane) {
126            lanes.push(a.lane.clone());
127        }
128    }
129    let mut per_lane: Vec<Vec<Vec<Activity>>> = lanes
130        .iter()
131        .map(|lane| {
132            let mut mine: Vec<Activity> = items.iter().filter(|a| &a.lane == lane).cloned().collect();
133            mine.sort_by(|a, b| a.start.total_cmp(&b.start));
134            let mut groups: Vec<Vec<Activity>> = Vec::new();
135            for a in mine {
136                match groups.last_mut() {
137                    Some(g) if g.last().is_some_and(|l| l.kind == a.kind && a.start - l.end <= gap) => g.push(a),
138                    _ => groups.push(vec![a]),
139                }
140            }
141            groups
142        })
143        .collect();
144    let span = |g: &[Activity]| g.last().map_or(0.0, |l| l.end) - g.first().map_or(0.0, |f| f.start);
145    loop {
146        let count: usize = per_lane.iter().map(Vec::len).sum();
147        if count <= target.max(1) {
148            break;
149        }
150        // The shortest episode that has a neighbour in its lane
151        let mut best: Option<(usize, usize, f64)> = None;
152        for (l, groups) in per_lane.iter().enumerate() {
153            if groups.len() < 2 {
154                continue;
155            }
156            for (i, g) in groups.iter().enumerate() {
157                let s = span(g);
158                if best.is_none_or(|b| s < b.2) {
159                    best = Some((l, i, s));
160                }
161            }
162        }
163        let Some((l, i, _)) = best else { break };
164        let groups = &mut per_lane[l];
165        // Into the neighbour closer in time
166        let gap_before = (i > 0).then(|| groups[i][0].start - groups[i - 1].last().map_or(0.0, |a| a.end));
167        let gap_after = (i + 1 < groups.len()).then(|| groups[i + 1][0].start - groups[i].last().map_or(0.0, |a| a.end));
168        let into = match (gap_before, gap_after) {
169            (Some(b), Some(a)) => if b <= a { i - 1 } else { i + 1 },
170            (Some(_), None) => i - 1,
171            _ => i + 1,
172        };
173        let g = groups.remove(i);
174        let into = if into > i { into - 1 } else { into };
175        groups[into].extend(g);
176        groups[into].sort_by(|a, b| a.start.total_cmp(&b.start));
177    }
178    let mut out: Vec<Episode> = per_lane.into_iter().flatten().map(Episode::of).collect();
179    out.sort_by(|a, b| a.start.total_cmp(&b.start));
180    out
181}
182
183/// The colours of the kinds: a set of hues readable on both themes, given out in order of first
184/// appearance so a kind keeps its colour while the view lives.
185const KIND_HUES: [u32; 10] = [0x5b8def, 0xf2b134, 0x2fb67c, 0xd9479b, 0x1fb5c9, 0x8b6cf0, 0xef6f4a, 0x8a96a8, 0x9bc53d, 0xc98bdb];
186
187/// The time of day of `t` (seconds since 1970) in the local time zone: "09:19".
188pub fn clock(t: f64) -> String {
189    jiff::Timestamp::from_millisecond((t * 1000.0) as i64)
190        .map(|ts| ts.to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M").to_string())
191        .unwrap_or_default()
192}
193
194/// A duration as people say it: "45 s", "26 min", "1 h 05".
195pub fn duration(s: f64) -> String {
196    let s = s.max(0.0).round() as u64;
197    match s {
198        0..60 => format!("{s} s"),
199        60..3600 => format!("{} min", s / 60),
200        _ => format!("{} h {:02}", s / 3600, (s % 3600) / 60),
201    }
202}
203
204/// A tick step for a span of `span` seconds: about six ticks, on round times.
205fn tick_step(span: f64) -> f64 {
206    const STEPS: [f64; 12] = [10.0, 30.0, 60.0, 120.0, 300.0, 600.0, 900.0, 1800.0, 3600.0, 7200.0, 10800.0, 21600.0];
207    STEPS.into_iter().find(|s| span / s <= 7.0).unwrap_or(43200.0)
208}
209
210/// What the timeline tells the app.
211#[derive(Clone, Debug, PartialEq)]
212pub enum TimelineEvent {
213    /// An episode was clicked (its index in [`Timeline::shown`]), or the selection cleared.
214    Selected(Option<usize>),
215}
216
217/// The view.
218pub struct Timeline {
219    title: SharedString,
220    activities: Vec<Activity>,
221    shown: Vec<Episode>,
222    summary: bool,
223    target: usize,
224    gap: f64,
225    idle: f64,
226    hovered: Option<usize>,
227    selected: Option<usize>,
228    colors: HashMap<SharedString, Hsla>,
229}
230
231impl EventEmitter<TimelineEvent> for Timeline {}
232
233impl Timeline {
234    /// An empty timeline titled `title`, summarising to about eight episodes.
235    pub fn new(title: impl Into<SharedString>) -> Self {
236        Self { title: title.into(), activities: Vec::new(), shown: Vec::new(), summary: true, target: 8, gap: 120.0, idle: 300.0, hovered: None, selected: None, colors: HashMap::new() }
237    }
238
239    /// Sets what happened (instants last until the next activity in their lane, at most five
240    /// minutes) and draws it again; the selection is kept when its episode still starts the same.
241    pub fn set_activities(&mut self, items: Vec<Activity>, now: f64, cx: &mut Context<Self>) {
242        let keep = self.selected.and_then(|i| self.shown.get(i)).map(|e| (e.lane.clone(), e.start));
243        self.activities = fill_ends(&items, self.idle, now);
244        self.rebuild();
245        self.selected = keep.and_then(|(lane, start)| self.shown.iter().position(|e| e.lane == lane && e.start == start));
246        cx.notify();
247    }
248
249    /// Shows the episodes (`true`) or every activity.
250    pub fn set_summary(&mut self, summary: bool, cx: &mut Context<Self>) {
251        self.summary = summary;
252        self.selected = None;
253        self.hovered = None;
254        self.rebuild();
255        cx.notify();
256    }
257
258    /// How many episodes the summary aims at.
259    pub fn set_target(&mut self, target: usize, cx: &mut Context<Self>) {
260        self.target = target.max(1);
261        self.rebuild();
262        cx.notify();
263    }
264
265    /// The episodes shown.
266    pub fn shown(&self) -> &[Episode] {
267        &self.shown
268    }
269
270    /// Every activity.
271    pub fn activities(&self) -> &[Activity] {
272        &self.activities
273    }
274
275    /// The episode clicked.
276    pub fn selected(&self) -> Option<&Episode> {
277        self.selected.and_then(|i| self.shown.get(i))
278    }
279
280    /// Selects the episode `index` (`None`: none), as a click does.
281    pub fn select(&mut self, index: Option<usize>, cx: &mut Context<Self>) {
282        self.selected = index.filter(|i| *i < self.shown.len());
283        cx.emit(TimelineEvent::Selected(self.selected));
284        cx.notify();
285    }
286
287    fn rebuild(&mut self) {
288        self.shown = if self.summary { episodes(&self.activities, self.target, self.gap) } else { each(&self.activities) };
289        for a in &self.activities {
290            if !self.colors.contains_key(&a.kind) {
291                let hue = KIND_HUES[self.colors.len() % KIND_HUES.len()];
292                self.colors.insert(a.kind.clone(), theme::rgb_hsla(hue));
293            }
294        }
295    }
296
297    fn color(&self, kind: &SharedString) -> Hsla {
298        self.colors.get(kind).copied().unwrap_or_else(color::text_muted)
299    }
300
301    fn lanes(&self) -> Vec<SharedString> {
302        let mut out: Vec<SharedString> = Vec::new();
303        for a in &self.activities {
304            if !out.contains(&a.lane) {
305                out.push(a.lane.clone());
306            }
307        }
308        out
309    }
310
311    fn range(&self) -> (f64, f64) {
312        let start = self.activities.iter().map(|a| a.start).fold(f64::INFINITY, f64::min);
313        let end = self.activities.iter().map(|a| a.end).fold(f64::NEG_INFINITY, f64::max);
314        if start.is_finite() && end > start { (start, end) } else { (0.0, 1.0) }
315    }
316
317    fn context_cards(&self, e: &Episode) -> impl IntoElement {
318        let dot = self.color(&e.kind);
319        let head = h_flex()
320            .gap(px(theme::SM))
321            .items_center()
322            .child(div().size(px(10.)).rounded_full().bg(dot))
323            .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(e.title.clone()))
324            .child(div().text_xs().text_color(color::text_muted()).child(format!("{} · {} · {} – {} · {}", e.lane, e.kind, clock(e.start), clock(e.end), duration(e.duration()))));
325        let cards = e.items.iter().enumerate().map(|(i, a)| {
326            v_flex()
327                .id(SharedString::from(format!("timeline-card-{i}")))
328                .w(px(260.))
329                .flex_none()
330                .gap(px(theme::XS))
331                .p(px(theme::SM))
332                .rounded(px(theme::RADIUS))
333                .border_1()
334                .border_color(color::border())
335                .bg(color::raised())
336                .child(h_flex().gap(px(theme::XS)).items_center().child(div().size(px(8.)).rounded_full().bg(self.color(&a.kind))).child(div().flex_1().min_w_0().truncate().text_sm().font_weight(FontWeight::SEMIBOLD).child(a.title.clone())).child(div().text_xs().text_color(color::text_muted()).child(clock(a.start))))
337                .when(!a.source.is_empty(), |d| d.child(div().text_xs().text_color(color::text_muted()).child(format!("From {}", a.source))))
338                .when(!a.detail.is_empty(), |d| d.child(div().text_xs().font_family("monospace").text_color(color::text_muted()).line_clamp(3).child(a.detail.clone())))
339                .when(!a.assets.is_empty(), |d| {
340                    d.child(h_flex().flex_wrap().gap(px(theme::XS)).children(a.assets.iter().map(|s| div().px(px(theme::XS)).rounded(px(theme::RADIUS_SM)).bg(color::control()).text_xs().truncate().max_w(px(240.)).child(s.clone()))))
341                })
342        });
343        v_flex().gap(px(theme::SM)).child(head).child(h_flex().id("timeline-cards").gap(px(theme::SM)).overflow_x_scroll().pb(px(theme::XS)).children(cards))
344    }
345}
346
347/// Height of a lane (px): the tallest bar and its label.
348const LANE_H: f32 = 64.;
349/// Width of the lane names (px).
350const LANE_LABEL_W: f32 = 72.;
351
352impl Render for Timeline {
353    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
354        let (t0, t1) = self.range();
355        let span = (t1 - t0).max(1.0);
356        let x = move |t: f64| ((t - t0) / span).clamp(0.0, 1.0) as f32;
357        let busiest = self.shown.iter().map(Episode::intensity).fold(0.0f64, f64::max).max(1e-9);
358        let header = h_flex()
359            .gap(px(theme::MD))
360            .items_center()
361            .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(self.title.clone()))
362            .child(div().flex_1().text_xs().text_color(color::text_muted()).child(if self.activities.is_empty() {
363                "Nothing yet: what you and the agent do shows here".to_string()
364            } else {
365                format!("{} episodes from {} actions · {} – {}", self.shown.len(), self.activities.len(), clock(t0), clock(t1))
366            }))
367            .child(div().w(px(150.)).child({
368                let me = cx.entity().downgrade();
369                MenuSelect::new("timeline-detail", vec!["Summary".into(), "Every action".into()], Some(if self.summary { 0 } else { 1 }), move |i, _, cx| {
370                    let _ = me.update(cx, |t, cx| t.set_summary(i == 0, cx));
371                })
372            }));
373        let lanes = self.lanes().into_iter().map(|lane| {
374            let bars = self.shown.iter().enumerate().filter(|(_, e)| e.lane == lane).map(|(i, e)| {
375                let (left, width) = (x(e.start), (x(e.end) - x(e.start)).max(0.002));
376                // Height: how busy it was (at least a third, so a quiet stretch still reads)
377                let h = 0.35 + 0.65 * (e.intensity() / busiest).sqrt() as f32;
378                let (hovered, selected) = (self.hovered == Some(i), self.selected == Some(i));
379                let fill = self.color(&e.kind);
380                let label = (width > 0.07).then(|| {
381                    div().absolute().left(relative(left)).w(relative(width)).top_0().px(px(2.)).truncate().text_xs().text_color(fill).child(e.title.clone())
382                });
383                let bar = div()
384                    .id(SharedString::from(format!("timeline-bar-{i}")))
385                    .absolute()
386                    .left(relative(left))
387                    .w(relative(width))
388                    .bottom_0()
389                    .h(px((LANE_H - 18.) * h))
390                    .rounded(px(2.))
391                    .bg(if hovered || selected { fill } else { fill.opacity(0.85) })
392                    .when(selected, |d| d.border_2().border_color(color::text()))
393                    .cursor_pointer()
394                    .on_hover(cx.listener(move |t, on: &bool, _, cx| {
395                        t.hovered = if *on { Some(i) } else if t.hovered == Some(i) { None } else { t.hovered };
396                        cx.notify();
397                    }))
398                    .on_click(cx.listener(move |t, _, _, cx| {
399                        let next = if t.selected == Some(i) { None } else { Some(i) };
400                        t.select(next, cx);
401                    }))
402                    .test_support();
403                [label.map(|l| l.into_any_element()), Some(bar.into_any_element())]
404            });
405            h_flex()
406                .h(px(LANE_H))
407                .items_end()
408                .child(div().w(px(LANE_LABEL_W)).flex_none().pb(px(theme::XS)).text_xs().text_color(color::text_muted()).child(lane.clone()))
409                .child(div().relative().flex_1().h_full().border_b_1().border_color(color::border()).children(bars.flatten().flatten()))
410        });
411        // The hours (or minutes) under the bars
412        let step = tick_step(span);
413        let first = (t0 / step).ceil() * step;
414        let ticks = (0..12).map(|k| first + k as f64 * step).take_while(|t| *t <= t1).map(|t| {
415            div().absolute().left(relative(x(t))).top_0().pl(px(2.)).border_l_1().border_color(color::border()).text_xs().text_color(color::text_muted()).child(clock(t))
416        });
417        let axis = h_flex().h(px(18.)).child(div().w(px(LANE_LABEL_W)).flex_none()).child(div().relative().flex_1().h_full().children(ticks));
418        // The card of the episode under the pointer
419        let hover = self.hovered.and_then(|i| self.shown.get(i)).map(|e| {
420            let left = x(e.start).min(0.75);
421            h_flex().h(px(0.)).child(div().w(px(LANE_LABEL_W)).flex_none()).child(
422                div().relative().flex_1().child(
423                    v_flex()
424                        .absolute()
425                        .left(relative(left))
426                        .top(px(theme::XS))
427                        .w(px(260.))
428                        .gap(px(2.))
429                        .p(px(theme::SM))
430                        .rounded(px(theme::RADIUS))
431                        .border_1()
432                        .border_color(color::border())
433                        .bg(color::raised())
434                        .shadow_lg()
435                        .child(h_flex().gap(px(theme::XS)).items_center().child(div().size(px(8.)).rounded_full().bg(self.color(&e.kind))).child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(e.kind.clone())).child(div().text_xs().text_color(color::text_muted()).child(format!("{} · {}", clock(e.start), duration(e.duration())))))
436                        .child(div().text_sm().truncate().child(e.title.clone()))
437                        .child(div().text_xs().text_color(color::text_muted()).child(format!("{} action{} · {}", e.items.len(), if e.items.len() == 1 { "" } else { "s" }, e.lane)))
438                        .children(e.items.first().filter(|a| !a.detail.is_empty()).map(|a| div().text_xs().font_family("monospace").text_color(color::text_muted()).line_clamp(2).child(a.detail.clone()))),
439                ),
440            )
441        });
442        let context = self.selected.and_then(|i| self.shown.get(i)).map(|e| self.context_cards(e));
443        v_flex()
444            .id("timeline")
445            .size_full()
446            .gap(px(theme::SM))
447            .p(px(theme::MD))
448            .overflow_y_scroll()
449            .child(header)
450            .child(v_flex().children(lanes).child(axis).children(hover))
451            .children(context)
452            .when(self.selected.is_none() && !self.shown.is_empty(), |d| d.child(div().pt(px(theme::LG)).text_xs().text_color(color::text_muted()).child("Click a bar to see what was done in it, where it came from and what it produced.")))
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    fn act(start: f64, lane: &str, kind: &str, title: &str) -> Activity {
461        Activity { start, end: start, lane: lane.into(), kind: kind.into(), title: title.into(), ..Default::default() }
462    }
463
464    /// Instants last until the next activity in their lane, and never past the idle limit.
465    #[test]
466    fn instants_last_until_the_next_one() {
467        let filled = fill_ends(&[act(0.0, "You", "A", "a"), act(60.0, "You", "B", "b"), act(10.0, "Agent", "C", "c")], 300.0, 1000.0);
468        let end = |t: &str| filled.iter().find(|a| a.title == t).unwrap().end;
469        assert_eq!(end("a"), 60.0);
470        assert_eq!(end("b"), 360.0, "the idle limit");
471        assert_eq!(end("c"), 310.0, "lanes are apart");
472    }
473
474    /// Fifty actions of a few kinds become the few episodes asked for, each keeping its actions.
475    #[test]
476    fn many_actions_become_a_few_episodes() {
477        let mut items = Vec::new();
478        let kinds = ["Research", "Draft", "Research", "Chat", "Draft"];
479        for k in 0..50 {
480            let kind = kinds[(k / 10) % kinds.len()];
481            // A stray action of another kind now and then
482            let kind = if k % 7 == 3 { "System" } else { kind };
483            items.push(act(k as f64 * 30.0, "You", kind, &format!("step {}", k % 4)));
484        }
485        let items = fill_ends(&items, 300.0, 1500.0);
486        let raw = each(&items);
487        assert_eq!(raw.len(), 50);
488        let eps = episodes(&items, 7, 120.0);
489        assert!(eps.len() <= 7, "{} episodes", eps.len());
490        assert_eq!(eps.iter().map(|e| e.items.len()).sum::<usize>(), 50, "nothing lost");
491        assert!(eps.windows(2).all(|w| w[0].start <= w[1].start));
492        assert!(eps.iter().any(|e| e.kind == "Research") && eps.iter().any(|e| e.kind == "Draft"));
493    }
494
495    #[test]
496    fn durations_and_ticks_read_well() {
497        assert_eq!(duration(45.0), "45 s");
498        assert_eq!(duration(26.0 * 60.0), "26 min");
499        assert_eq!(duration(3900.0), "1 h 05");
500        assert_eq!(tick_step(3.0 * 3600.0), 1800.0);
501        assert_eq!(tick_step(600.0), 120.0);
502    }
503}