1use chrono::{DateTime, Datelike, Local, TimeDelta};
9
10use crate::types::{DocDiffTimelinePoint, DocDiffTimelinePointKind};
11
12pub fn ymd(y: i32, m: u32, d: u32) -> String {
14 format!("{y:04}-{m:02}-{d:02}")
15}
16
17pub 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#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct TimelineBucket {
36 pub label: String,
37 pub points: Vec<DocDiffTimelinePoint>,
38}
39
40pub 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
59pub 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 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}