Skip to main content

caldir_core/utils/
date_range.rs

1//! Date range for filtering events.
2
3use chrono::{DateTime, Duration, Local, NaiveDate, TimeZone, Utc};
4
5const UNBOUNDED_PAST: &str = "1970-01-01T00:00:00+00:00";
6const UNBOUNDED_FUTURE: &str = "2100-01-01T00:00:00+00:00";
7const DEFAULT_SYNC_PAST_DAYS: i64 = 365;
8const DEFAULT_SYNC_FUTURE_DAYS: i64 = 365;
9
10#[derive(Debug, Clone, Default)]
11pub struct DateRange {
12    pub from: Option<DateTime<Utc>>,
13    pub to: Option<DateTime<Utc>>,
14}
15
16impl DateRange {
17    // +/- 1 year
18    pub fn default_sync_window() -> Self {
19        Self::sync_window_at(Local::now().date_naive(), Local)
20    }
21
22    pub fn sync_window_at<Tz: TimeZone>(today: NaiveDate, tz: Tz) -> Self {
23        Self {
24            from: Some(start_of_day_utc(
25                &tz,
26                today - Duration::days(DEFAULT_SYNC_PAST_DAYS),
27            )),
28            to: Some(end_of_day_utc(
29                &tz,
30                today + Duration::days(DEFAULT_SYNC_FUTURE_DAYS),
31            )),
32        }
33    }
34
35    /// RFC3339 `(from, to)`, substituting sentinel deep-past/deep-future timestamps when unbounded.
36    pub fn to_rfc3339(&self) -> (String, String) {
37        let (from, to) = self.query_bounds();
38        (from.to_rfc3339(), to.to_rfc3339())
39    }
40
41    /// Concrete bounds shared by provider queries and deletion detection.
42    pub(crate) fn query_bounds(&self) -> (DateTime<Utc>, DateTime<Utc>) {
43        let from = self
44            .from
45            .unwrap_or_else(|| UNBOUNDED_PAST.parse().expect("valid past sentinel"));
46        let to = self
47            .to
48            .unwrap_or_else(|| UNBOUNDED_FUTURE.parse().expect("valid future sentinel"));
49        (from, to)
50    }
51}
52
53fn start_of_day_utc<Tz: TimeZone>(tz: &Tz, date: NaiveDate) -> DateTime<Utc> {
54    let local = date
55        .and_hms_opt(0, 0, 0)
56        .expect("midnight should be a valid NaiveDateTime");
57
58    tz.from_local_datetime(&local)
59        .earliest()
60        .map(|dt| dt.with_timezone(&Utc))
61        .expect("midnight should resolve in supported timezones")
62}
63
64fn end_of_day_utc<Tz: TimeZone>(tz: &Tz, date: NaiveDate) -> DateTime<Utc> {
65    let local = date
66        .and_hms_opt(23, 59, 59)
67        .expect("end of day should be a valid NaiveDateTime");
68
69    tz.from_local_datetime(&local)
70        .latest()
71        .map(|dt| dt.with_timezone(&Utc))
72        .expect("end of day should resolve in supported timezones")
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use chrono::{NaiveDate, NaiveTime, TimeZone};
79    use chrono_tz::Europe::Stockholm;
80
81    fn utc(y: i32, m: u32, d: u32) -> DateTime<Utc> {
82        Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).unwrap()
83    }
84
85    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
86        NaiveDate::from_ymd_opt(y, m, d).unwrap()
87    }
88
89    fn range_in(
90        tz: chrono_tz::Tz,
91        range: &DateRange,
92    ) -> (Option<(NaiveDate, NaiveTime)>, (NaiveDate, NaiveTime)) {
93        let from = range.from.map(|dt| {
94            let local = dt.with_timezone(&tz);
95            (local.date_naive(), local.time())
96        });
97        let to_local = range.to.unwrap().with_timezone(&tz);
98        (from, (to_local.date_naive(), to_local.time()))
99    }
100
101    #[test]
102    fn rfc3339_uses_bounded_dates_when_set() {
103        let range = DateRange {
104            from: Some(utc(2026, 3, 20)),
105            to: Some(utc(2026, 3, 25)),
106        };
107
108        assert_eq!(
109            range.to_rfc3339(),
110            (
111                "2026-03-20T00:00:00+00:00".to_string(),
112                "2026-03-25T00:00:00+00:00".to_string(),
113            )
114        );
115    }
116
117    #[test]
118    fn rfc3339_uses_sentinels_when_unbounded() {
119        let range = DateRange {
120            from: None,
121            to: None,
122        };
123
124        assert_eq!(
125            range.to_rfc3339(),
126            (
127                "1970-01-01T00:00:00+00:00".to_string(),
128                "2100-01-01T00:00:00+00:00".to_string(),
129            )
130        );
131    }
132
133    #[test]
134    fn default_sync_window_is_one_year_back_and_forward_in_local_days() {
135        let range = DateRange::sync_window_at(date(2026, 5, 14), Stockholm);
136
137        let (from, to) = range_in(Stockholm, &range);
138        assert_eq!(
139            from,
140            Some((date(2025, 5, 14), NaiveTime::from_hms_opt(0, 0, 0).unwrap())),
141        );
142        assert_eq!(
143            to,
144            (
145                date(2027, 5, 14),
146                NaiveTime::from_hms_opt(23, 59, 59).unwrap()
147            ),
148        );
149    }
150}