1use std::collections::HashMap;
7
8use chrono::Datelike;
9
10use crate::config::TimelineGroupBy;
11use crate::spec::{Spec, SpecStatus};
12
13#[derive(Debug, Clone, serde::Serialize)]
15pub struct TimelineGroup {
16 pub date: String,
18 pub ascii_tree: String,
20}
21
22pub fn build_timeline_groups(
24 specs: &[&Spec],
25 group_by: TimelineGroupBy,
26 include_pending: bool,
27) -> Vec<TimelineGroup> {
28 let filtered_specs: Vec<_> = specs
30 .iter()
31 .filter(|s| {
32 if !include_pending && s.frontmatter.status == SpecStatus::Pending {
33 return false;
34 }
35 true
36 })
37 .collect();
38
39 let mut groups: HashMap<String, Vec<&&Spec>> = HashMap::new();
41
42 for spec in &filtered_specs {
43 let date_key = extract_date_key(spec, group_by);
44 groups.entry(date_key).or_default().push(spec);
45 }
46
47 let mut sorted_keys: Vec<_> = groups.keys().cloned().collect();
49 sorted_keys.sort_by(|a, b| b.cmp(a));
50
51 sorted_keys
53 .into_iter()
54 .map(|date| {
55 let specs_in_group = groups.get(&date).unwrap();
56 let ascii_tree = render_tree(specs_in_group);
57 let display_date = format_date_display(&date, group_by);
58 TimelineGroup {
59 date: display_date,
60 ascii_tree,
61 }
62 })
63 .collect()
64}
65
66fn extract_date_key(spec: &Spec, group_by: TimelineGroupBy) -> String {
68 let date_str = spec
70 .frontmatter
71 .completed_at
72 .as_ref()
73 .and_then(|s| s.split('T').next())
74 .map(|s| s.to_string())
75 .unwrap_or_else(|| {
76 let parts: Vec<_> = spec.id.split('-').collect();
78 if parts.len() >= 3 {
79 format!("{}-{}-{}", parts[0], parts[1], parts[2])
80 } else {
81 "unknown".to_string()
82 }
83 });
84
85 match group_by {
86 TimelineGroupBy::Day => date_str,
87 TimelineGroupBy::Week => {
88 if let Some(date) = parse_date(&date_str) {
90 let weekday = date.weekday();
91 let days_since_monday = weekday.num_days_from_monday();
92 let monday = date - chrono::Duration::days(days_since_monday as i64);
93 monday.format("%Y-%m-%d").to_string()
94 } else {
95 date_str
96 }
97 }
98 TimelineGroupBy::Month => {
99 let parts: Vec<_> = date_str.split('-').collect();
101 if parts.len() >= 2 {
102 format!("{}-{}", parts[0], parts[1])
103 } else {
104 date_str
105 }
106 }
107 }
108}
109
110fn parse_date(s: &str) -> Option<chrono::NaiveDate> {
112 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
113}
114
115fn format_date_display(date_key: &str, group_by: TimelineGroupBy) -> String {
117 match group_by {
118 TimelineGroupBy::Day => date_key.to_string(),
119 TimelineGroupBy::Week => format!("Week of {}", date_key),
120 TimelineGroupBy::Month => {
121 if let Some(date) = parse_month(date_key) {
122 date.format("%B %Y").to_string()
123 } else {
124 date_key.to_string()
125 }
126 }
127 }
128}
129
130fn parse_month(s: &str) -> Option<chrono::NaiveDate> {
132 let full = format!("{}-01", s);
133 chrono::NaiveDate::parse_from_str(&full, "%Y-%m-%d").ok()
134}
135
136fn render_tree(specs: &[&&Spec]) -> String {
138 if specs.is_empty() {
139 return String::new();
140 }
141
142 let mut lines = Vec::new();
143
144 for (i, spec) in specs.iter().enumerate() {
145 let is_last = i == specs.len() - 1;
146 let prefix = if is_last { "└── " } else { "├── " };
147
148 let status_icon = match spec.frontmatter.status {
149 SpecStatus::Completed => "✓",
150 SpecStatus::InProgress => "◐",
151 SpecStatus::Pending => "○",
152 SpecStatus::Failed => "✗",
153 SpecStatus::NeedsAttention => "⚠",
154 _ => "•",
155 };
156
157 let short_id = spec.id.split('-').skip(3).collect::<Vec<_>>().join("-");
158
159 let short_id = if short_id.is_empty() {
160 &spec.id
161 } else {
162 &short_id
163 };
164
165 let title = spec
166 .title
167 .as_ref()
168 .map(|t| truncate(t, 30))
169 .unwrap_or_else(|| "Untitled".to_string());
170
171 let status_display = match spec.frontmatter.status {
172 SpecStatus::Completed => "completed",
173 SpecStatus::InProgress => "in_progress",
174 SpecStatus::Pending => "pending",
175 SpecStatus::Failed => "failed",
176 _ => "other",
177 };
178
179 lines.push(format!(
180 "{}{} {} {} ({})",
181 prefix, status_icon, short_id, title, status_display
182 ));
183 }
184
185 lines.join("\n")
186}
187
188fn truncate(s: &str, max_len: usize) -> String {
190 if s.chars().count() <= max_len {
191 s.to_string()
192 } else {
193 let truncated: String = s.chars().take(max_len - 1).collect();
194 format!("{}…", truncated)
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::spec::SpecFrontmatter;
202
203 fn make_spec(id: &str, status: SpecStatus, completed_at: Option<&str>) -> Spec {
204 Spec {
205 id: id.to_string(),
206 title: Some("Test Spec".to_string()),
207 body: String::new(),
208 frontmatter: SpecFrontmatter {
209 status,
210 completed_at: completed_at.map(|s| s.to_string()),
211 ..Default::default()
212 },
213 }
214 }
215
216 #[test]
217 fn test_build_timeline_groups_empty() {
218 let specs: Vec<&Spec> = vec![];
219 let groups = build_timeline_groups(&specs, TimelineGroupBy::Day, false);
220 assert!(groups.is_empty());
221 }
222
223 #[test]
224 fn test_extract_date_key_from_id() {
225 let spec = make_spec("2026-01-30-00a-xyz", SpecStatus::Pending, None);
226 let key = extract_date_key(&spec, TimelineGroupBy::Day);
227 assert_eq!(key, "2026-01-30");
228 }
229
230 #[test]
231 fn test_extract_date_key_from_completed_at() {
232 let spec = make_spec(
233 "2026-01-30-00a-xyz",
234 SpecStatus::Completed,
235 Some("2026-01-29T12:00:00Z"),
236 );
237 let key = extract_date_key(&spec, TimelineGroupBy::Day);
238 assert_eq!(key, "2026-01-29");
239 }
240
241 #[test]
242 fn test_render_tree() {
243 let spec1 = make_spec("2026-01-30-00a-xyz", SpecStatus::Completed, None);
244 let spec2 = make_spec("2026-01-30-00b-abc", SpecStatus::InProgress, None);
245 let specs: Vec<&Spec> = vec![&spec1, &spec2];
246 let refs: Vec<&&Spec> = specs.iter().collect();
247 let tree = render_tree(&refs);
248
249 assert!(tree.contains("✓"));
250 assert!(tree.contains("◐"));
251 assert!(tree.contains("├──"));
252 assert!(tree.contains("└──"));
253 }
254
255 #[test]
256 fn test_truncate() {
257 assert_eq!(truncate("short", 10), "short");
258 assert_eq!(truncate("a very long title here", 10), "a very lo…");
259 }
260
261 #[test]
262 fn test_format_date_display() {
263 assert_eq!(
264 format_date_display("2026-01-30", TimelineGroupBy::Day),
265 "2026-01-30"
266 );
267 assert_eq!(
268 format_date_display("2026-01-27", TimelineGroupBy::Week),
269 "Week of 2026-01-27"
270 );
271 assert_eq!(
272 format_date_display("2026-01", TimelineGroupBy::Month),
273 "January 2026"
274 );
275 }
276}