Skip to main content

chant/site/
timeline.rs

1//! ASCII timeline generation for specs.
2//!
3//! This module generates ASCII art timelines showing spec activity
4//! grouped by date, week, or month.
5
6use std::collections::HashMap;
7
8use chrono::Datelike;
9
10use crate::config::TimelineGroupBy;
11use crate::spec::{Spec, SpecStatus};
12
13/// A group of specs for a timeline entry
14#[derive(Debug, Clone, serde::Serialize)]
15pub struct TimelineGroup {
16    /// The date/period label for this group
17    pub date: String,
18    /// ASCII tree representation of specs in this group
19    pub ascii_tree: String,
20}
21
22/// Build timeline groups from specs
23pub fn build_timeline_groups(
24    specs: &[&Spec],
25    group_by: TimelineGroupBy,
26    include_pending: bool,
27) -> Vec<TimelineGroup> {
28    // Filter specs based on include_pending
29    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    // Group specs by date
40    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    // Sort groups by date descending
48    let mut sorted_keys: Vec<_> = groups.keys().cloned().collect();
49    sorted_keys.sort_by(|a, b| b.cmp(a));
50
51    // Build timeline groups
52    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
66/// Extract the date key for grouping
67fn extract_date_key(spec: &Spec, group_by: TimelineGroupBy) -> String {
68    // Try to get date from completed_at or from spec ID
69    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            // Extract date from ID (e.g., 2026-01-30-00a-xyz -> 2026-01-30)
77            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            // Get the week start (Monday)
89            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            // Get YYYY-MM
100            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
110/// Parse a date string to a chrono NaiveDate
111fn parse_date(s: &str) -> Option<chrono::NaiveDate> {
112    chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
113}
114
115/// Format the date for display
116fn 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
130/// Parse a month string (YYYY-MM) to a date
131fn 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
136/// Render specs as an ASCII tree
137fn 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
188/// Truncate a string to max length
189fn 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}