Skip to main content

docgen_diff/
timeline_groups.rs

1//! Timeline date bucketing — a faithful port of `timeline-groups.ts`.
2//!
3//! Adapts the JS `Date`-based logic to `chrono::Local`. `ymd` takes 1-based
4//! year/month/day ints (the JS `ymd(new Date(...))` used 0-based months; our
5//! API is 1-based and documented). `format_date` parses an RFC3339 string to
6//! local time and formats it as `YYYY-MM-DD`, returning `""` on null/invalid.
7
8use chrono::{DateTime, Datelike, Local, TimeDelta};
9
10use crate::types::{DocDiffTimelinePoint, DocDiffTimelinePointKind};
11
12/// Zero-padded `YYYY-MM-DD` from 1-based year/month/day.
13pub fn ymd(y: i32, m: u32, d: u32) -> String {
14    format!("{y:04}-{m:02}-{d:02}")
15}
16
17/// Parse an RFC3339 string, convert to local time, format as `YYYY-MM-DD`.
18/// Returns `""` for `None` or an unparseable value (parity with the JS
19/// `new Date(value)` + `ymd`, which is local-timezone).
20pub fn format_date(value: Option<&str>) -> String {
21    let Some(s) = value else {
22        return String::new();
23    };
24    match DateTime::parse_from_rfc3339(s) {
25        Ok(dt) => {
26            let local = dt.with_timezone(&Local);
27            ymd(local.year(), local.month(), local.day())
28        }
29        Err(_) => String::new(),
30    }
31}
32
33/// A timeline bucket: a label plus the points that fall under it.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct TimelineBucket {
36    pub label: String,
37    pub points: Vec<DocDiffTimelinePoint>,
38}
39
40/// Bucket label for a single point relative to `now`: `"Working tree"` for
41/// worktree points, else `"Today"` / `"Yesterday"` / `"Earlier"`.
42pub fn bucket_label(point: &DocDiffTimelinePoint, now: DateTime<Local>) -> String {
43    if point.kind == DocDiffTimelinePointKind::Worktree {
44        return "Working tree".into();
45    }
46    let today = ymd(now.year(), now.month(), now.day());
47    let prev = now - TimeDelta::days(1);
48    let yesterday = ymd(prev.year(), prev.month(), prev.day());
49    let day = format_date(point.date.as_deref());
50    if day == today {
51        "Today".into()
52    } else if day == yesterday {
53        "Yesterday".into()
54    } else {
55        "Earlier".into()
56    }
57}
58
59/// Group points into ordered buckets by label, preserving first-seen order.
60pub fn group_timeline(
61    points: Vec<DocDiffTimelinePoint>,
62    now: DateTime<Local>,
63) -> Vec<TimelineBucket> {
64    let mut buckets: Vec<TimelineBucket> = Vec::new();
65    for point in points {
66        let label = bucket_label(&point, now);
67        if let Some(existing) = buckets.iter_mut().find(|b| b.label == label) {
68            existing.points.push(point);
69        } else {
70            buckets.push(TimelineBucket {
71                label,
72                points: vec![point],
73            });
74        }
75    }
76    buckets
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use chrono::TimeZone;
83
84    fn point_dated(date: &str) -> DocDiffTimelinePoint {
85        point(
86            DocDiffTimelinePointKind::Commit,
87            Some(date.to_string()),
88            "p",
89        )
90    }
91
92    fn point(
93        kind: DocDiffTimelinePointKind,
94        date: Option<String>,
95        id: &str,
96    ) -> DocDiffTimelinePoint {
97        DocDiffTimelinePoint {
98            id: id.into(),
99            kind,
100            hash: Some("abc".into()),
101            short_hash: "abc".into(),
102            subject: "s".into(),
103            author: None,
104            date,
105            base_ref: String::new(),
106            head_ref: String::new(),
107            files: vec![],
108            file_tree: vec![],
109            total_added_lines: 0,
110            total_removed_lines: 0,
111            warnings: vec![],
112        }
113    }
114
115    #[test]
116    fn ymd_formats_year_month_day_with_zero_padding() {
117        assert_eq!(ymd(2026, 1, 5), "2026-01-05");
118        assert_eq!(ymd(2026, 12, 31), "2026-12-31");
119    }
120
121    #[test]
122    fn format_date_returns_empty_for_null_or_invalid() {
123        assert_eq!(format_date(None), "");
124        assert_eq!(format_date(Some("not-a-date")), "");
125    }
126
127    #[test]
128    fn format_date_parses_iso_strings() {
129        let result = format_date(Some("2026-03-15T12:00:00Z"));
130        // Timezone-dependent but YYYY-MM-DD format, day is 14 or 15.
131        assert_eq!(result.len(), 10);
132        assert!(result.starts_with("2026-03-1"));
133        assert!(result == "2026-03-14" || result == "2026-03-15");
134    }
135
136    #[test]
137    fn bucket_label_returns_working_tree_for_worktree_kind() {
138        let now = Local.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap();
139        assert_eq!(
140            bucket_label(&point(DocDiffTimelinePointKind::Worktree, None, "wt"), now),
141            "Working tree"
142        );
143    }
144
145    #[test]
146    fn buckets_today_yesterday_earlier() {
147        let now = Local.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap();
148        let today = Local
149            .with_ymd_and_hms(2026, 5, 15, 8, 0, 0)
150            .unwrap()
151            .to_rfc3339();
152        let yesterday = Local
153            .with_ymd_and_hms(2026, 5, 14, 8, 0, 0)
154            .unwrap()
155            .to_rfc3339();
156        let earlier = Local
157            .with_ymd_and_hms(2026, 5, 1, 8, 0, 0)
158            .unwrap()
159            .to_rfc3339();
160        assert_eq!(bucket_label(&point_dated(&today), now), "Today");
161        assert_eq!(bucket_label(&point_dated(&yesterday), now), "Yesterday");
162        assert_eq!(bucket_label(&point_dated(&earlier), now), "Earlier");
163    }
164
165    #[test]
166    fn group_timeline_preserves_order_and_groups_by_label() {
167        let now = Local.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap();
168        let today = Local
169            .with_ymd_and_hms(2026, 5, 15, 8, 0, 0)
170            .unwrap()
171            .to_rfc3339();
172        let earlier = Local
173            .with_ymd_and_hms(2026, 5, 1, 8, 0, 0)
174            .unwrap()
175            .to_rfc3339();
176        let buckets = group_timeline(
177            vec![
178                point(DocDiffTimelinePointKind::Worktree, None, "wt"),
179                point(DocDiffTimelinePointKind::Commit, Some(today.clone()), "a"),
180                point(DocDiffTimelinePointKind::Commit, Some(today), "b"),
181                point(DocDiffTimelinePointKind::Commit, Some(earlier), "c"),
182            ],
183            now,
184        );
185        let proj: Vec<(String, Vec<String>)> = buckets
186            .iter()
187            .map(|b| {
188                (
189                    b.label.clone(),
190                    b.points.iter().map(|p| p.id.clone()).collect(),
191                )
192            })
193            .collect();
194        assert_eq!(
195            proj,
196            vec![
197                ("Working tree".to_string(), vec!["wt".to_string()]),
198                ("Today".to_string(), vec!["a".to_string(), "b".to_string()]),
199                ("Earlier".to_string(), vec!["c".to_string()]),
200            ]
201        );
202    }
203}