1use std::fmt;
20
21use jiff::civil::Date;
22use jiff::tz::TimeZone;
23use jiff::{Timestamp, ToSpan, Zoned};
24
25pub const RETENTION_DAYS: i32 = 90;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Window {
34 Today,
36 Last24Hours,
38 Last7Days,
40 Last30Days,
42 Last90Days,
44 MonthToDate,
46 AllTime,
48}
49
50impl Window {
51 pub const ALL: [Self; 7] = [
56 Self::Today,
57 Self::Last24Hours,
58 Self::Last7Days,
59 Self::Last30Days,
60 Self::MonthToDate,
61 Self::Last90Days,
62 Self::AllTime,
63 ];
64
65 #[must_use]
67 pub fn slug(self) -> &'static str {
68 match self {
69 Self::Today => "today",
70 Self::Last24Hours => "last_24h",
71 Self::Last7Days => "last_7d",
72 Self::Last30Days => "last_30d",
73 Self::Last90Days => "last_90d",
74 Self::MonthToDate => "month_to_date",
75 Self::AllTime => "all_time",
76 }
77 }
78
79 #[must_use]
81 pub fn label(self) -> &'static str {
82 match self {
83 Self::Today => "Today",
84 Self::Last24Hours => "Last 24 hours",
85 Self::Last7Days => "Last 7 days",
86 Self::Last30Days => "Last 30 days",
87 Self::Last90Days => "Last 90 days",
88 Self::MonthToDate => "Month to date",
89 Self::AllTime => "All time",
90 }
91 }
92
93 #[must_use]
98 pub fn is_calendar(self) -> bool {
99 matches!(self, Self::Today | Self::MonthToDate)
100 }
101
102 #[must_use]
104 pub fn from_slug(slug: &str) -> Option<Self> {
105 Self::ALL.into_iter().find(|w| w.slug() == slug)
106 }
107
108 #[must_use]
112 pub fn resolve(self, now: &Zoned) -> Span {
113 let end = now.timestamp();
114 let zone = now.time_zone().clone();
115
116 let (start, reckoned_in) = match self {
117 Self::Today => (Some(start_of_day(now)), Some(zone)),
118 Self::MonthToDate => (Some(start_of_month(now)), Some(zone)),
119 Self::Last24Hours => (Some(rolling_back(end, 24)), None),
120 Self::Last7Days => (Some(rolling_back(end, 7 * 24)), None),
121 Self::Last30Days => (Some(rolling_back(end, 30 * 24)), None),
122 Self::Last90Days => (Some(rolling_back(end, 90 * 24)), None),
123 Self::AllTime => (None, None),
124 };
125
126 Span {
127 window: self,
128 start,
129 end,
130 reckoned_in,
131 horizon: rolling_back(end, RETENTION_DAYS * 24),
132 }
133 }
134}
135
136impl fmt::Display for Window {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.write_str(self.label())
139 }
140}
141
142fn rolling_back(from: Timestamp, hours: i32) -> Timestamp {
150 from.checked_sub(hours.hours()).unwrap_or(Timestamp::MIN)
151}
152
153fn start_of_day(now: &Zoned) -> Timestamp {
155 now.start_of_day()
156 .map_or_else(|_| now.timestamp(), |zoned| zoned.timestamp())
157}
158
159fn start_of_month(now: &Zoned) -> Timestamp {
165 let first = Date::new(now.year(), now.month(), 1).unwrap_or_else(|_| now.date());
166 first
167 .to_zoned(now.time_zone().clone())
168 .map_or_else(|_| start_of_day(now), |zoned| zoned.timestamp())
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Span {
174 pub window: Window,
176 pub start: Option<Timestamp>,
178 pub end: Timestamp,
180 pub reckoned_in: Option<TimeZone>,
182 pub horizon: Timestamp,
184}
185
186impl Span {
187 #[must_use]
192 pub fn contains(&self, at: Timestamp) -> bool {
193 self.start.is_none_or(|start| at >= start) && at < self.end
194 }
195
196 #[must_use]
202 pub fn truncated_by_retention(&self) -> bool {
203 match self.window {
204 Window::AllTime => false,
206 _ => self.start.is_some_and(|start| start < self.horizon),
207 }
208 }
209
210 #[must_use]
212 pub fn zone_name(&self) -> Option<&str> {
213 self.reckoned_in.as_ref().and_then(TimeZone::iana_name)
214 }
215
216 #[must_use]
218 pub fn duration_secs(&self) -> Option<i64> {
219 Some(self.end.as_second() - self.start?.as_second())
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn now() -> Zoned {
234 "2026-09-16T13:10:00+03:00[Europe/Tallinn]"
235 .parse()
236 .expect("valid zoned timestamp")
237 }
238
239 #[test]
240 fn rolling_windows_go_back_exactly_that_far() {
241 let span = Window::Last7Days.resolve(&now());
242
243 assert_eq!(span.duration_secs(), Some(7 * 24 * 3_600));
244 assert!(!span.window.is_calendar());
245 }
246
247 #[test]
248 fn a_calendar_month_starts_at_local_midnight_on_the_first() {
249 let span = Window::MonthToDate.resolve(&now());
250 let start = span.start.expect("month to date has a start");
251
252 assert_eq!(
253 start.to_string(),
254 "2026-08-31T21:00:00Z",
255 "local midnight on 1 September in UTC+3 is 21:00 the previous day in UTC"
256 );
257 assert_eq!(span.zone_name(), Some("Europe/Tallinn"));
258 }
259
260 #[test]
261 fn a_calendar_window_reckoned_in_two_zones_covers_different_instants() {
262 let tallinn = Window::Today.resolve(&now());
266 let honolulu: Zoned = "2026-09-16T00:10:00-10:00[Pacific/Honolulu]"
267 .parse()
268 .expect("valid zoned timestamp");
269 let pacific = Window::Today.resolve(&honolulu);
270
271 assert_ne!(tallinn.start, pacific.start);
272 assert_eq!(pacific.zone_name(), Some("Pacific/Honolulu"));
273 }
274
275 #[test]
276 fn a_rolling_window_records_that_no_zone_was_involved() {
277 let span = Window::Last7Days.resolve(&now());
279
280 assert!(span.reckoned_in.is_none());
281 assert!(span.zone_name().is_none());
282 }
283
284 #[test]
285 fn windows_are_half_open_so_consecutive_periods_do_not_double_count() {
286 let span = Window::Last7Days.resolve(&now());
287 let start = span.start.expect("rolling window has a start");
288
289 assert!(span.contains(start), "the start is inside");
290 assert!(!span.contains(span.end), "the end is not");
291 assert!(!span.contains(start - 1.second()));
292 }
293
294 #[test]
295 fn nothing_reaches_past_retention_except_the_window_that_says_so() {
296 let at = now();
297
298 assert!(!Window::Last30Days.resolve(&at).truncated_by_retention());
299 assert!(
300 !Window::Last90Days.resolve(&at).truncated_by_retention(),
301 "ninety days is exactly what is kept, so it is complete"
302 );
303 assert!(
304 !Window::AllTime.resolve(&at).truncated_by_retention(),
305 "all time means all that is kept, and claiming otherwise would warn on every page load"
306 );
307 }
308
309 #[test]
310 fn a_month_to_date_window_is_truncated_only_once_it_outruns_retention() {
311 let span = Window::MonthToDate.resolve(&now());
315 assert!(!span.truncated_by_retention());
316
317 let stretched = Span {
318 start: Some(span.horizon - 1.second()),
319 ..span
320 };
321 assert!(stretched.truncated_by_retention());
322 }
323
324 #[test]
325 fn a_rolling_week_stays_168_hours_across_a_daylight_saving_change() {
326 let across_dst: Zoned = "2026-10-26T12:00:00+02:00[Europe/Tallinn]"
331 .parse()
332 .expect("valid zoned timestamp");
333
334 assert_eq!(
335 Window::Last7Days.resolve(&across_dst).duration_secs(),
336 Some(7 * 24 * 3_600)
337 );
338 }
339
340 #[test]
341 fn a_calendar_day_does_stretch_across_a_daylight_saving_change() {
342 let ordinary: Zoned = "2026-10-20T23:00:00+03:00[Europe/Tallinn]"
347 .parse()
348 .expect("valid zoned timestamp");
349 let transition: Zoned = "2026-10-25T23:00:00+02:00[Europe/Tallinn]"
350 .parse()
351 .expect("valid zoned timestamp");
352
353 assert_eq!(
354 Window::Today.resolve(&ordinary).duration_secs(),
355 Some(23 * 3_600)
356 );
357 assert_eq!(
358 Window::Today.resolve(&transition).duration_secs(),
359 Some(24 * 3_600),
360 "the clocks went back, so an extra hour has passed by the same reading"
361 );
362 }
363
364 #[test]
365 fn slugs_round_trip_and_are_unique() {
366 let mut seen = Vec::new();
367 for window in Window::ALL {
368 assert_eq!(Window::from_slug(window.slug()), Some(window));
369 assert!(
370 !seen.contains(&window.slug()),
371 "duplicate {}",
372 window.slug()
373 );
374 seen.push(window.slug());
375 }
376
377 assert_eq!(Window::from_slug("fortnight"), None);
378 }
379
380 #[test]
381 fn only_calendar_windows_need_a_zone() {
382 for window in Window::ALL {
383 let span = window.resolve(&now());
384 assert_eq!(
385 span.reckoned_in.is_some(),
386 window.is_calendar(),
387 "{window} disagrees about whether it used a zone"
388 );
389 }
390 }
391
392 #[test]
393 fn an_open_window_contains_everything_up_to_now() {
394 let span = Window::AllTime.resolve(&now());
395
396 assert!(span.start.is_none());
397 assert!(span.duration_secs().is_none());
398 assert!(span.contains(Timestamp::MIN));
399 }
400}