Skip to main content

coding_agent_search/analytics/
bucketing.rs

1//! Time-bucket conversions for analytics.
2//!
3//! Converts between integer day_id / hour_id keys (as stored in SQLite rollup
4//! tables) and human-readable ISO date strings used in JSON output.
5
6use crate::storage::sqlite::FrankenStorage;
7
8use super::types::AnalyticsFilter;
9
10/// Format a `day_id` as an ISO date string (`YYYY-MM-DD`).
11pub fn day_id_to_iso(day_id: i64) -> String {
12    use chrono::{TimeZone, Utc};
13    let ms = FrankenStorage::millis_from_day_id(day_id);
14    Utc.timestamp_millis_opt(ms)
15        .single()
16        .map(|dt| dt.format("%Y-%m-%d").to_string())
17        .unwrap_or_else(|| format!("day:{day_id}"))
18}
19
20/// Format an `hour_id` as an ISO datetime string (`YYYY-MM-DDTHH:00Z`).
21pub fn hour_id_to_iso(hour_id: i64) -> String {
22    use chrono::{TimeZone, Utc};
23    let ms = FrankenStorage::millis_from_hour_id(hour_id);
24    Utc.timestamp_millis_opt(ms)
25        .single()
26        .map(|dt| dt.format("%Y-%m-%dT%H:00Z").to_string())
27        .unwrap_or_else(|| format!("hour:{hour_id}"))
28}
29
30/// Compute the ISO week key (`YYYY-Www`) from a `day_id`.
31pub fn day_id_to_iso_week(day_id: i64) -> String {
32    use chrono::{Datelike, TimeZone, Utc};
33    let ms = FrankenStorage::millis_from_day_id(day_id);
34    Utc.timestamp_millis_opt(ms)
35        .single()
36        .map(|dt| {
37            let iso = dt.iso_week();
38            format!("{}-W{:02}", iso.year(), iso.week())
39        })
40        .unwrap_or_else(|| format!("day:{day_id}"))
41}
42
43/// Compute the month key (`YYYY-MM`) from a `day_id`.
44pub fn day_id_to_month(day_id: i64) -> String {
45    use chrono::{TimeZone, Utc};
46    let ms = FrankenStorage::millis_from_day_id(day_id);
47    Utc.timestamp_millis_opt(ms)
48        .single()
49        .map(|dt| dt.format("%Y-%m").to_string())
50        .unwrap_or_else(|| format!("day:{day_id}"))
51}
52
53/// Resolve the time-range from [`AnalyticsFilter`] into an inclusive
54/// `(min_day_id, max_day_id)` range.  Returns `(None, None)` when no time
55/// filter is active.
56pub fn resolve_day_range(filter: &AnalyticsFilter) -> (Option<i64>, Option<i64>) {
57    let min_day = filter.since_ms.map(FrankenStorage::day_id_from_millis);
58    let max_day = filter.until_ms.map(FrankenStorage::day_id_from_millis);
59    (min_day, max_day)
60}
61
62/// Resolve the time-range from [`AnalyticsFilter`] into an inclusive
63/// `(min_hour_id, max_hour_id)` range.
64pub fn resolve_hour_range(filter: &AnalyticsFilter) -> (Option<i64>, Option<i64>) {
65    let min_hour = filter.since_ms.map(FrankenStorage::hour_id_from_millis);
66    let max_hour = filter.until_ms.map(FrankenStorage::hour_id_from_millis);
67    (min_hour, max_hour)
68}
69
70// ---------------------------------------------------------------------------
71// Tests
72// ---------------------------------------------------------------------------
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    // Known epoch: 2025-01-15 00:00:00 UTC → day_id = 1736899200000 / 86400000 = 20102
79    // Actually let's use the FrankenStorage functions to get the right day_id.
80
81    #[test]
82    fn day_id_roundtrip() {
83        // 2025-06-15 00:00:00 UTC = 1749945600000 ms
84        let ms = 1_749_945_600_000_i64;
85        let day_id = FrankenStorage::day_id_from_millis(ms);
86        let iso = day_id_to_iso(day_id);
87        assert_eq!(iso, "2025-06-15");
88    }
89
90    #[test]
91    fn hour_id_roundtrip() {
92        // 2025-06-15 14:00:00 UTC = 1749996000000 ms
93        let ms = 1_749_996_000_000_i64;
94        let hour_id = FrankenStorage::hour_id_from_millis(ms);
95        let iso = hour_id_to_iso(hour_id);
96        assert_eq!(iso, "2025-06-15T14:00Z");
97    }
98
99    #[test]
100    fn day_id_to_week() {
101        // 2025-01-06 (Monday) and 2025-01-12 (Sunday) are in ISO week 2025-W02
102        let mon_ms = 1_736_121_600_000_i64; // 2025-01-06 00:00 UTC
103        let sun_ms = 1_736_640_000_000_i64; // 2025-01-12 00:00 UTC
104        let mon_id = FrankenStorage::day_id_from_millis(mon_ms);
105        let sun_id = FrankenStorage::day_id_from_millis(sun_ms);
106        assert_eq!(day_id_to_iso_week(mon_id), day_id_to_iso_week(sun_id));
107        assert!(day_id_to_iso_week(mon_id).contains("W02"));
108    }
109
110    #[test]
111    fn day_id_to_month_format() {
112        let ms = 1_750_032_000_000_i64; // 2025-06-15
113        let day_id = FrankenStorage::day_id_from_millis(ms);
114        assert_eq!(day_id_to_month(day_id), "2025-06");
115    }
116
117    #[test]
118    fn resolve_empty_filter_gives_none() {
119        let f = AnalyticsFilter::default();
120        let (min, max) = resolve_day_range(&f);
121        assert!(min.is_none());
122        assert!(max.is_none());
123        let (hmin, hmax) = resolve_hour_range(&f);
124        assert!(hmin.is_none());
125        assert!(hmax.is_none());
126    }
127
128    #[test]
129    fn resolve_day_range_with_since() {
130        let f = AnalyticsFilter {
131            since_ms: Some(1_750_032_000_000),
132            ..Default::default()
133        };
134        let (min, max) = resolve_day_range(&f);
135        assert!(min.is_some());
136        assert!(max.is_none());
137    }
138}