Skip to main content

layover_core/cost/
window.rs

1//! Time windows to report cost over, and the difference between the two kinds.
2//!
3//! A dashboard asks for "the last 7 days" and "this month" in the same breath, which hides that
4//! they are not the same sort of question:
5//!
6//! - **Rolling** windows are pure instant arithmetic. "The last 7 days" means the same thing
7//!   everywhere on earth, and no time zone can make it wrong.
8//! - **Calendar** windows are not. "This month" begins at local midnight on the first, and which
9//!   instant that is depends on where the Tower is standing.
10//!
11//! Conflating them is not a theoretical hazard. A sibling project gated its daily spend on a UTC
12//! boundary while reporting the ledger in local time, so for the hours between local midnight and
13//! UTC midnight the budget it enforced and the number it displayed described different days. The
14//! bug is invisible until it matters, at which point the factory has spent a day's money twice.
15//!
16//! So [`Window`] makes the distinction part of the type, and every [`Span`] carries the zone it
17//! was reckoned in — `None` for rolling windows, because there was nothing to reckon.
18
19use std::fmt;
20
21use jiff::civil::Date;
22use jiff::tz::TimeZone;
23use jiff::{Timestamp, ToSpan, Zoned};
24
25/// How long history is kept before it is deleted.
26///
27/// Reporting cannot see past this, and a window that reaches further back says so rather than
28/// quietly returning a smaller number. See [`Span::truncated_by_retention`].
29pub const RETENTION_DAYS: i32 = 90;
30
31/// A period to total spend over.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum Window {
34    /// Since local midnight. Calendar.
35    Today,
36    /// The 24 hours ending now. Rolling.
37    Last24Hours,
38    /// The 7 days ending now. Rolling.
39    Last7Days,
40    /// The 30 days ending now. Rolling.
41    Last30Days,
42    /// The 90 days ending now, which is everything retention keeps. Rolling.
43    Last90Days,
44    /// Since local midnight on the first of the current month. Calendar.
45    MonthToDate,
46    /// Everything still on disk.
47    AllTime,
48}
49
50impl Window {
51    /// Every window, in the order a dashboard should offer them.
52    ///
53    /// Narrowest first: the question people ask most often is "what is it doing right now", and
54    /// the further down the list you read the more you are asking about a trend.
55    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    /// The identifier used in query strings and JSON.
66    #[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    /// A human label.
80    #[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    /// Returns `true` when the window's start depends on a time zone.
94    ///
95    /// Only calendar windows do. It is worth surfacing, because it decides whether two people
96    /// comparing dashboards in different places should expect the same number.
97    #[must_use]
98    pub fn is_calendar(self) -> bool {
99        matches!(self, Self::Today | Self::MonthToDate)
100    }
101
102    /// Parses a slug, as used in a query string.
103    #[must_use]
104    pub fn from_slug(slug: &str) -> Option<Self> {
105        Self::ALL.into_iter().find(|w| w.slug() == slug)
106    }
107
108    /// Resolves the window against a moment in a place.
109    ///
110    /// Calendar windows need the zone; rolling ones ignore it, and record that they did so.
111    #[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
142/// Steps `hours` back from an instant, saturating at the extreme rather than failing.
143///
144/// Hours, not days, and that is deliberate. `jiff` refuses to subtract days from a bare timestamp
145/// because a day is not always 24 hours — across a DST transition it is 23 or 25 — and without a
146/// zone there is no way to know which. Refusing is the right answer: a rolling window that
147/// silently changed length twice a year would be a calendar window wearing a disguise. "The last
148/// 7 days" here means 168 hours exactly, in every zone, which is what makes it comparable.
149fn rolling_back(from: Timestamp, hours: i32) -> Timestamp {
150    from.checked_sub(hours.hours()).unwrap_or(Timestamp::MIN)
151}
152
153/// Local midnight at the start of `now`'s day.
154fn start_of_day(now: &Zoned) -> Timestamp {
155    now.start_of_day()
156        .map_or_else(|_| now.timestamp(), |zoned| zoned.timestamp())
157}
158
159/// Local midnight on the first of `now`'s month.
160///
161/// Falls back to the start of the current day if the first does not exist as a local midnight,
162/// which real zones have done: Samoa skipped 30 December 2011 entirely when it changed side of
163/// the date line.
164fn 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/// A resolved [`Window`]: two instants, and an honest account of how they were arrived at.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct Span {
174    /// The window this came from.
175    pub window: Window,
176    /// When the period starts. `None` means "from the beginning of what is kept".
177    pub start: Option<Timestamp>,
178    /// When the period ends, which is the moment it was resolved.
179    pub end: Timestamp,
180    /// The zone the start was reckoned in, or `None` for a rolling window.
181    pub reckoned_in: Option<TimeZone>,
182    /// The oldest instant retention still keeps.
183    pub horizon: Timestamp,
184}
185
186impl Span {
187    /// Returns `true` when `at` falls inside the period.
188    ///
189    /// Half-open: the start is included and the end is not, so consecutive windows tile without
190    /// counting a run at the boundary twice.
191    #[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    /// Returns `true` when the period reaches further back than retention keeps.
197    ///
198    /// A total over such a window is a lower bound, not a total, and saying so is the same
199    /// principle that makes [`crate::cost::CostSource`] a field: a number whose provenance is
200    /// unstated will eventually be trusted more than it deserves.
201    #[must_use]
202    pub fn truncated_by_retention(&self) -> bool {
203        match self.window {
204            // Nobody reading "all time" believes it predates the install.
205            Window::AllTime => false,
206            _ => self.start.is_some_and(|start| start < self.horizon),
207        }
208    }
209
210    /// The name of the zone the start was reckoned in, when one was needed.
211    #[must_use]
212    pub fn zone_name(&self) -> Option<&str> {
213        self.reckoned_in.as_ref().and_then(TimeZone::iana_name)
214    }
215
216    /// How long the period is, in whole seconds, or `None` for an open start.
217    #[must_use]
218    pub fn duration_secs(&self) -> Option<i64> {
219        // Second arithmetic directly, rather than converting a `Span` through `f64`: a window can
220        // be ninety days, and a float is a strange way to count something already an integer.
221        Some(self.end.as_second() - self.start?.as_second())
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    /// A fixed moment, well inside a month, in a zone an hour ahead of UTC.
230    ///
231    /// Deliberately after local midnight but before UTC midnight is irrelevant here; the case
232    /// that matters is tested separately below.
233    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        // This is the whole reason the distinction is in the type. The same instant, asked "when
263        // did today begin?", gives two different answers, and a budget that gates on one while
264        // reporting the other lets a day's money be spent twice.
265        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        // The absence is the point: nobody should have to wonder which zone "last 7 days" used.
278        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        // Month to date is the one window whose length is not fixed, so it is the only one that
312        // can quietly become a lower bound. It cannot in practice — no month is 90 days — but the
313        // check exists so that changing RETENTION_DAYS cannot silently produce a wrong total.
314        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        // Europe/Tallinn puts its clocks back on the last Sunday of October, making that local
327        // day 25 hours long. A rolling week must not stretch with it: two operators comparing
328        // "last 7 days" a day apart would otherwise be totalling different amounts of time and
329        // calling the result by the same name.
330        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        // And this is the contrast that justifies two kinds of window. At the same reading on the
343        // same clock, more time has passed since midnight on the day the clocks went back — the
344        // hour between 03:00 and 04:00 happened twice. Forcing that to 24 would be answering a
345        // different question than the one asked.
346        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}