claude-dashboard 0.2.2

A terminal TUI dashboard that renders Claude Code usage reports with token stats, project breakdowns, and language distribution.
Documentation
/// Parse timestamp strings from Claude Code data files.
/// Handles ISO 8601 (RFC 3339), ISO-like variants, and localized formats
/// like "MM/DD/YYYY HH:MM:SS" observed in Windows stats-cache.json.
pub fn parse_iso_timestamp(s: &str) -> Option<chrono::NaiveDateTime> {
    // Try full ISO format with timezone
    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
        return Some(dt.naive_utc());
    }
    // Try ISO without timezone (with fractional seconds)
    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.fZ") {
        return Some(dt);
    }
    // Try ISO without timezone (no fractional seconds)
    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ") {
        return Some(dt);
    }
    // Try localized format: "MM/DD/YYYY HH:MM:SS" (observed in Windows Claude Code)
    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%m/%d/%Y %H:%M:%S") {
        return Some(dt);
    }
    // Try localized format with AM/PM
    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%m/%d/%Y %I:%M:%S %p") {
        return Some(dt);
    }
    // Try just date
    if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
        return Some(d.and_hms_opt(0, 0, 0).unwrap());
    }
    None
}

/// Get the first and last day of a month.
pub fn month_bounds(year: i32, month: u32) -> (chrono::NaiveDate, chrono::NaiveDate) {
    let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
    let end = if month == 12 {
        chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
            .unwrap()
            .pred_opt()
            .unwrap()
    } else {
        chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
            .unwrap()
            .pred_opt()
            .unwrap()
    };
    (start, end)
}

/// Get the first and last day of a year.
pub fn year_bounds(year: i32) -> (chrono::NaiveDate, chrono::NaiveDate) {
    let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
    let end = chrono::NaiveDate::from_ymd_opt(year, 12, 31).unwrap();
    (start, end)
}

/// Check if a date string (YYYY-MM-DD) falls within [start, end] inclusive.
pub fn date_str_in_range(date_str: &str, start: chrono::NaiveDate, end: chrono::NaiveDate) -> bool {
    if let Ok(d) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
        d >= start && d <= end
    } else {
        false
    }
}

/// Fast timestamp range check on a raw JSONL line.
/// Looks for "timestamp":"YYYY-MM-DD or "timestamp": "YYYY-MM-DD pattern
/// without full deserialization.
pub fn timestamp_in_range_fast(line: &str, start: chrono::NaiveDate, end: chrono::NaiveDate) -> bool {
    // Try exact "timestamp":" (no space)
    if let Some(idx) = line.find("\"timestamp\":\"") {
        let after = &line[idx + 13..];
        if after.len() >= 10 {
            let date_str = &after[..10];
            return date_str_in_range(date_str, start, end);
        }
    }
    // Try "timestamp": " (with space)
    if let Some(idx) = line.find("\"timestamp\": \"") {
        let after = &line[idx + 14..];
        if after.len() >= 10 {
            let date_str = &after[..10];
            return date_str_in_range(date_str, start, end);
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Timelike;

    #[test]
    fn test_parse_iso_timestamp_full() {
        let dt = parse_iso_timestamp("2025-11-04T08:34:16.259Z");
        assert!(dt.is_some());
        let dt = dt.unwrap();
        assert_eq!(dt.date().to_string(), "2025-11-04");
        assert_eq!(dt.time().hour(), 8);
    }

    #[test]
    fn test_parse_iso_timestamp_date_only() {
        let dt = parse_iso_timestamp("2024-03-15");
        assert!(dt.is_some());
        let dt = dt.unwrap();
        assert_eq!(dt.date().to_string(), "2024-03-15");
    }

    #[test]
    fn test_parse_iso_timestamp_invalid() {
        assert!(parse_iso_timestamp("not-a-date").is_none());
        assert!(parse_iso_timestamp("").is_none());
    }

    #[test]
    fn test_parse_iso_timestamp_without_ms() {
        // Timestamps without milliseconds (common in session JSONL)
        let dt = parse_iso_timestamp("2026-04-15T10:30:00Z");
        assert!(dt.is_some());
        let dt = dt.unwrap();
        assert_eq!(dt.date().to_string(), "2026-04-15");
        assert_eq!(dt.time().hour(), 10);
    }

    #[test]
    fn test_parse_iso_timestamp_with_timezone_offset() {
        let dt = parse_iso_timestamp("2026-04-15T10:30:00+08:00");
        assert!(dt.is_some());
        let dt = dt.unwrap();
        assert_eq!(dt.date().to_string(), "2026-04-15");
    }

    #[test]
    fn test_parse_localized_timestamp() {
        // Format observed in Windows Claude Code stats-cache.json firstSessionDate
        let dt = parse_iso_timestamp("03/09/2026 14:00:30");
        assert!(dt.is_some());
        let dt = dt.unwrap();
        assert_eq!(dt.date().to_string(), "2026-03-09");
        assert_eq!(dt.time().hour(), 14);
        assert_eq!(dt.time().minute(), 0);
        assert_eq!(dt.time().second(), 30);
    }

    #[test]
    fn test_parse_localized_timestamp_invalid() {
        assert!(parse_iso_timestamp("13/32/2026 25:00:00").is_none());
    }

    #[test]
    fn test_month_bounds() {
        let (start, end) = month_bounds(2026, 1);
        assert_eq!(start.to_string(), "2026-01-01");
        assert_eq!(end.to_string(), "2026-01-31");

        let (start, end) = month_bounds(2026, 2);
        assert_eq!(start.to_string(), "2026-02-01");
        assert_eq!(end.to_string(), "2026-02-28");

        let (_start, end) = month_bounds(2024, 2); // leap year
        assert_eq!(end.to_string(), "2024-02-29");
    }

    #[test]
    fn test_month_bounds_december() {
        let (start, end) = month_bounds(2026, 12);
        assert_eq!(start.to_string(), "2026-12-01");
        assert_eq!(end.to_string(), "2026-12-31");
    }

    #[test]
    fn test_year_bounds() {
        let (start, end) = year_bounds(2026);
        assert_eq!(start.to_string(), "2026-01-01");
        assert_eq!(end.to_string(), "2026-12-31");
    }

    #[test]
    fn test_year_bounds_leap_year() {
        let (start, end) = year_bounds(2024);
        assert_eq!(start.to_string(), "2024-01-01");
        assert_eq!(end.to_string(), "2024-12-31");
    }

    #[test]
    fn test_date_str_in_range() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();

        assert!(date_str_in_range("2026-04-15", start, end));
        assert!(date_str_in_range("2026-04-01", start, end));
        assert!(date_str_in_range("2026-04-30", start, end));
        assert!(!date_str_in_range("2026-03-31", start, end));
        assert!(!date_str_in_range("2026-05-01", start, end));
        assert!(!date_str_in_range("bad-date", start, end));
    }

    #[test]
    fn test_date_str_in_range_boundary() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();

        assert!(date_str_in_range("2026-01-01", start, end));
        assert!(date_str_in_range("2026-01-31", start, end));
        assert!(!date_str_in_range("2025-12-31", start, end));
        assert!(!date_str_in_range("2026-02-01", start, end));
    }

    #[test]
    fn test_timestamp_in_range_fast() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();

        let line = r#"{"type":"assistant","timestamp":"2026-04-15T10:30:00Z","message":{}}"#;
        assert!(timestamp_in_range_fast(line, start, end));

        let line = r#"{"type":"assistant","timestamp":"2026-03-01T10:30:00Z","message":{}}"#;
        assert!(!timestamp_in_range_fast(line, start, end));

        let line = r#"{"type":"assistant","no_timestamp":"here"}"#;
        assert!(!timestamp_in_range_fast(line, start, end));
    }

    #[test]
    fn test_timestamp_in_range_fast_boundary() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();

        // First day of range
        let line = r#"{"timestamp":"2026-04-01T00:00:00Z","type":"assistant"}"#;
        assert!(timestamp_in_range_fast(line, start, end));

        // Last day of range
        let line = r#"{"timestamp":"2026-04-30T23:59:59Z","type":"assistant"}"#;
        assert!(timestamp_in_range_fast(line, start, end));

        // Day before range
        let line = r#"{"timestamp":"2026-03-31T23:59:59Z","type":"assistant"}"#;
        assert!(!timestamp_in_range_fast(line, start, end));
    }

    #[test]
    fn test_timestamp_in_range_fast_with_space_after_colon() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();

        // JSON with space after colon — now correctly handled
        let line = r#"{"timestamp": "2026-04-15T10:30:00Z", "type": "assistant"}"#;
        assert!(timestamp_in_range_fast(line, start, end));
    }

    #[test]
    fn test_timestamp_in_range_fast_line_without_date() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();

        // Line that doesn't have 10 chars after the timestamp prefix
        let line = r#"{"timestamp":"bad"}"#;
        assert!(!timestamp_in_range_fast(line, start, end));
    }

    #[test]
    fn test_timestamp_in_range_fast_empty_line() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
        assert!(!timestamp_in_range_fast("", start, end));
    }

    #[test]
    fn test_timestamp_in_range_fast_short_timestamp() {
        let start = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
        let end = chrono::NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
        // Timestamp field exists but value is too short (< 10 chars)
        let line = r#"{"timestamp":"2026-0"}"#;
        assert!(!timestamp_in_range_fast(line, start, end));
    }
}