Skip to main content

chartml_core/format/
date.rs

1/// Date formatter wrapping chrono's strftime.
2use chrono::NaiveDateTime;
3
4#[derive(Debug, Clone)]
5pub struct DateFormatter {
6    format_str: String,
7}
8
9impl DateFormatter {
10    pub fn new(format_str: &str) -> Self {
11        Self {
12            format_str: format_str.to_string(),
13        }
14    }
15
16    /// Format a chrono NaiveDateTime.
17    pub fn format_datetime(&self, dt: &NaiveDateTime) -> String {
18        dt.format(&self.format_str).to_string()
19    }
20
21    /// Format a date string (parse then format).
22    /// Accepts ISO date strings like "2024-01-15" or "2024-01-15T10:30:00".
23    pub fn format_date_str(&self, date_str: &str) -> Option<String> {
24        // Try parsing as NaiveDateTime first, then as NaiveDate
25        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S") {
26            return Some(self.format_datetime(&dt));
27        }
28        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S%.f") {
29            return Some(self.format_datetime(&dt));
30        }
31        if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
32            let dt = date.and_hms_opt(0, 0, 0)?;
33            return Some(self.format_datetime(&dt));
34        }
35        None
36    }
37}
38
39/// Special format marker returned by `detect_date_format` when all dates fall
40/// on quarter boundaries (1st of Jan/Apr/Jul/Oct). Handled specially by
41/// `reformat_date_label`.
42pub(crate) const QUARTER_FORMAT: &str = "__QUARTER__";
43
44/// Detect if labels look like dates and return a compact display format.
45/// Checks a sample of labels for ISO date patterns (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
46/// Returns a chrono strftime format string for compact display, or the special
47/// `QUARTER_FORMAT` marker when all dates are quarter boundaries.
48pub fn detect_date_format(labels: &[String]) -> Option<String> {
49    if labels.is_empty() {
50        return None;
51    }
52
53    // Sample up to 5 labels to check
54    let sample_size = labels.len().min(5);
55    let mut date_count = 0;
56    let mut has_time = false;
57
58    for label in labels.iter().take(sample_size) {
59        let trimmed = label.trim();
60        if chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").is_ok() {
61            date_count += 1;
62        } else if chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S").is_ok()
63            || chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f").is_ok()
64        {
65            date_count += 1;
66            has_time = true;
67        }
68    }
69
70    // If at least 80% of sampled labels are dates, treat as date axis
71    if date_count * 5 >= sample_size * 4 {
72        // Check for quarterly pattern: all dates fall on the 1st of Jan/Apr/Jul/Oct
73        if !has_time && is_quarterly(labels) {
74            return Some(QUARTER_FORMAT.to_string());
75        }
76
77        // Check if the data spans multiple calendar years
78        let multi_year = spans_multiple_years(labels);
79
80        if has_time {
81            // If all timestamps share the same calendar date, use time-only format
82            if all_same_date(labels) {
83                Some("%H:%M".to_string())
84            } else if multi_year {
85                Some("%b %d '%y %H:%M".to_string())
86            } else {
87                Some("%b %d %H:%M".to_string())
88            }
89        } else if multi_year {
90            Some("%b '%y".to_string())
91        } else {
92            Some("%b %d".to_string())
93        }
94    } else {
95        None
96    }
97}
98
99/// Check whether ALL parseable date labels fall on quarter boundaries
100/// (day == 1 and month in {1, 4, 7, 10}).
101fn is_quarterly(labels: &[String]) -> bool {
102    use chrono::Datelike;
103
104    const QUARTER_MONTHS: [u32; 4] = [1, 4, 7, 10];
105
106    let mut found_any = false;
107    for label in labels {
108        let trimmed = label.trim();
109        if let Ok(d) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
110            if d.day() != 1 || !QUARTER_MONTHS.contains(&d.month()) {
111                return false;
112            }
113            found_any = true;
114        } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
115            let d = dt.date();
116            if d.day() != 1 || !QUARTER_MONTHS.contains(&d.month()) {
117                return false;
118            }
119            found_any = true;
120        }
121        // Non-date labels are ignored (the caller already verified date-ness)
122    }
123    found_any
124}
125
126/// Check whether ALL timestamps share the same calendar date (YYYY-MM-DD).
127/// When true, we can use a short time-only format like "%H:%M" instead of
128/// including the date in every label.
129fn all_same_date(labels: &[String]) -> bool {
130    fn extract_date_prefix(s: &str) -> Option<String> {
131        let t = s.trim();
132        // Extract the YYYY-MM-DD portion from datetime strings
133        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S") {
134            return Some(dt.date().to_string());
135        }
136        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S%.f") {
137            return Some(dt.date().to_string());
138        }
139        None
140    }
141
142    let mut first_date: Option<String> = None;
143    for label in labels {
144        if let Some(d) = extract_date_prefix(label) {
145            match &first_date {
146                None => first_date = Some(d),
147                Some(fd) => {
148                    if *fd != d {
149                        return false;
150                    }
151                }
152            }
153        }
154    }
155    // Only return true if we actually found at least one date
156    first_date.is_some()
157}
158
159/// Check whether the labels span multiple calendar years by comparing the
160/// year of the first parseable date with the year of the last parseable date.
161fn spans_multiple_years(labels: &[String]) -> bool {
162    fn extract_year(s: &str) -> Option<i32> {
163        use chrono::Datelike;
164        let t = s.trim();
165        if let Ok(d) = chrono::NaiveDate::parse_from_str(t, "%Y-%m-%d") {
166            return Some(d.year());
167        }
168        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S") {
169            return Some(dt.date().year());
170        }
171        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S%.f") {
172            return Some(dt.date().year());
173        }
174        None
175    }
176
177    let first_year = labels.iter().find_map(|l| extract_year(l));
178    let last_year = labels.iter().rev().find_map(|l| extract_year(l));
179
180    match (first_year, last_year) {
181        (Some(f), Some(l)) => f != l,
182        _ => false,
183    }
184}
185
186/// Reformat a date label using the given strftime format string.
187/// If `format_str` is the special `QUARTER_FORMAT` marker, formats as "Q1 '23" etc.
188/// If the label cannot be parsed as a date, returns it unchanged.
189pub fn reformat_date_label(label: &str, format_str: &str) -> String {
190    if format_str == QUARTER_FORMAT {
191        return format_as_quarter(label).unwrap_or_else(|| label.to_string());
192    }
193    let formatter = DateFormatter::new(format_str);
194    formatter.format_date_str(label).unwrap_or_else(|| label.to_string())
195}
196
197/// Format a date string as a quarter label like "Q1 '23".
198fn format_as_quarter(label: &str) -> Option<String> {
199    use chrono::Datelike;
200
201    let trimmed = label.trim();
202    let date = if let Ok(d) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
203        d
204    } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
205        dt.date()
206    } else {
207        return None;
208    };
209
210    let quarter = match date.month() {
211        1 => 1,
212        4 => 2,
213        7 => 3,
214        10 => 4,
215        _ => return None,
216    };
217
218    let year_short = date.year() % 100;
219    Some(format!("Q{} '{:02}", quarter, year_short))
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_format_date_identity() {
228        assert_eq!(
229            DateFormatter::new("%Y-%m-%d").format_date_str("2024-01-15"),
230            Some("2024-01-15".to_string())
231        );
232    }
233
234    #[test]
235    fn test_format_date_month_day_year() {
236        assert_eq!(
237            DateFormatter::new("%b %d, %Y").format_date_str("2024-01-15"),
238            Some("Jan 15, 2024".to_string())
239        );
240    }
241
242    #[test]
243    fn test_format_date_month_year() {
244        assert_eq!(
245            DateFormatter::new("%b %Y").format_date_str("2024-03-01"),
246            Some("Mar 2024".to_string())
247        );
248    }
249
250    #[test]
251    fn test_format_datetime() {
252        let dt = chrono::NaiveDate::from_ymd_opt(2024, 6, 15)
253            .unwrap()
254            .and_hms_opt(14, 30, 0)
255            .unwrap();
256        assert_eq!(
257            DateFormatter::new("%Y-%m-%d %H:%M").format_datetime(&dt),
258            "2024-06-15 14:30"
259        );
260    }
261
262    #[test]
263    fn test_format_date_str_with_time() {
264        assert_eq!(
265            DateFormatter::new("%Y-%m-%d %H:%M:%S").format_date_str("2024-01-15T10:30:00"),
266            Some("2024-01-15 10:30:00".to_string())
267        );
268    }
269
270    #[test]
271    fn test_format_date_str_invalid() {
272        assert_eq!(
273            DateFormatter::new("%Y-%m-%d").format_date_str("not-a-date"),
274            None
275        );
276    }
277
278    #[test]
279    fn test_detect_quarterly_format() {
280        let labels: Vec<String> = vec![
281            "2023-01-01".into(), "2023-04-01".into(),
282            "2023-07-01".into(), "2023-10-01".into(),
283        ];
284        let fmt = detect_date_format(&labels).unwrap();
285        assert_eq!(fmt, QUARTER_FORMAT);
286    }
287
288    #[test]
289    fn test_reformat_quarterly_labels() {
290        assert_eq!(reformat_date_label("2023-01-01", QUARTER_FORMAT), "Q1 '23");
291        assert_eq!(reformat_date_label("2023-04-01", QUARTER_FORMAT), "Q2 '23");
292        assert_eq!(reformat_date_label("2023-07-01", QUARTER_FORMAT), "Q3 '23");
293        assert_eq!(reformat_date_label("2023-10-01", QUARTER_FORMAT), "Q4 '23");
294        assert_eq!(reformat_date_label("2025-01-01", QUARTER_FORMAT), "Q1 '25");
295    }
296
297    #[test]
298    fn test_non_quarterly_dates_not_detected_as_quarterly() {
299        // Monthly dates should NOT trigger quarterly detection
300        let labels: Vec<String> = vec![
301            "2023-01-01".into(), "2023-02-01".into(),
302            "2023-03-01".into(), "2023-04-01".into(),
303        ];
304        let fmt = detect_date_format(&labels).unwrap();
305        assert_ne!(fmt, QUARTER_FORMAT);
306    }
307}