Skip to main content

dpm_model/calendar/
working.rs

1//! Working-time arithmetic on a calendar compiled into instants relative to an origin.
2//!
3//! Compiling reads the weekly periods and exceptions of every local date in a window, converts
4//! each period to UTC in the calendar's time zone, and keeps the merged spans with the working
5//! time before each. Spans, working totals and every comparison are integer milliseconds, so a
6//! span boundary compares equal to itself however the origin falls, and results do not depend on
7//! the window compiled. The interface speaks hours after the origin, the unit of every schedule
8//! projection. A query outside the compiled window reports [`BeyondCalendar`] rather than
9//! guessing, and the caller compiles a wider window up to [`MAX_WINDOW_HOURS`].
10
11use super::{CalendarDefinition, CalendarRules, WorkingPeriod, standard_definition};
12use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveDateTime, TimeZone, Utc};
13use chrono_tz::Tz;
14use index::Ranked;
15
16mod arithmetic;
17mod index;
18
19/// A time or amount of working time outside the compiled window.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
21#[error("time lies outside the compiled calendar window")]
22pub struct BeyondCalendar;
23
24/// Longest window a calendar is compiled over: a century, of which schedule projections reach at
25/// least three quarters ahead of their clock reading. Work that needs more calendar than this is
26/// out of range rather than an unbounded allocation; validation refuses calendars closed for
27/// longer than five years, so only extreme durations or lags reach it.
28pub const MAX_WINDOW_HOURS: f64 = 24.0 * 366.0 * 100.0;
29
30const MILLIS_PER_HOUR: f64 = 3_600_000.0;
31
32/// Working spans of one calendar within a window, or every hour for the `always` calendar.
33#[derive(Debug, Clone, PartialEq)]
34pub struct WorkingTime {
35    /// First and last covered millisecond after the origin.
36    window: (i64, i64),
37    /// Ordered, disjoint, non-touching working spans in milliseconds after the origin.
38    spans: Vec<(i64, i64)>,
39    /// Working milliseconds before each span, within the window.
40    before: Vec<i64>,
41    /// Span starts, ranked.
42    starts: Ranked,
43    /// Span ends, ranked.
44    ends: Ranked,
45    /// Working milliseconds up to the end of each span, ranked.
46    through: Ranked,
47    always: bool,
48}
49
50/// Milliseconds for hours after the origin; `None` when not representable.
51fn millis(hours: f64) -> Option<i64> {
52    let value = (hours * MILLIS_PER_HOUR).round();
53    // chrono's range is far below 2^53 ms, so this bound also keeps the cast exact.
54    (value.is_finite() && value.abs() <= 9.0e15).then_some(value as i64)
55}
56
57fn hours(millis: i64) -> f64 {
58    millis as f64 / MILLIS_PER_HOUR
59}
60
61/// The UTC instant of a local wall-clock time; a time skipped by a daylight-saving change moves
62/// forward to the first valid local minute, and a repeated time takes its earlier occurrence.
63fn local_instant(zone: Tz, local: NaiveDateTime) -> Option<DateTime<Utc>> {
64    let mut candidate = local;
65    // A skipped local hour is never longer than a few hours in the tz database.
66    for _ in 0..=(6 * 4) {
67        if let Some(at) = zone.from_local_datetime(&candidate).earliest() {
68            return Some(at.with_timezone(&Utc));
69        }
70        candidate = candidate.checked_add_signed(chrono::TimeDelta::minutes(15))?;
71    }
72    None
73}
74
75fn periods_of(definition: &CalendarDefinition, date: NaiveDate) -> &[WorkingPeriod] {
76    match definition.exceptions.iter().find(|e| e.covers(date)) {
77        Some(exception) => &exception.hours,
78        None => definition.week.day(date.weekday()),
79    }
80}
81
82/// Milliseconds from the origin to a local time of day on a date.
83fn local_offset(zone: Tz, origin: DateTime<Utc>, date: NaiveDate, minutes: u16) -> Option<i64> {
84    let local = date
85        .and_hms_opt(0, 0, 0)?
86        .checked_add_signed(chrono::TimeDelta::minutes(i64::from(minutes)))?;
87    Some((local_instant(zone, local)? - origin).num_milliseconds())
88}
89
90impl WorkingTime {
91    /// A calendar in which every hour is working time.
92    #[must_use]
93    pub fn always() -> Self {
94        Self {
95            window: (i64::MIN, i64::MAX),
96            spans: Vec::new(),
97            before: Vec::new(),
98            starts: Ranked::new(Vec::new(), 0, 0),
99            ends: Ranked::new(Vec::new(), 0, 0),
100            through: Ranked::new(Vec::new(), 0, 0),
101            always: true,
102        }
103    }
104
105    /// Whether every hour is working time.
106    #[must_use]
107    pub fn is_always(&self) -> bool {
108        self.always
109    }
110
111    /// Compile the local dates covering hours `from..=until` after `origin`; `None` when the
112    /// window is longer than [`MAX_WINDOW_HOURS`] or outside chrono's range.
113    #[must_use]
114    pub fn compile(
115        rules: CalendarRules<'_>,
116        zone: Tz,
117        origin: DateTime<Utc>,
118        from: f64,
119        until: f64,
120    ) -> Option<Self> {
121        let definition = match rules {
122            CalendarRules::Always => return Some(Self::always()),
123            CalendarRules::Standard => &standard_definition(),
124            CalendarRules::Weekly(definition) => definition,
125        };
126        let span = until - from;
127        if span.is_nan() || span > MAX_WINDOW_HOURS {
128            return None;
129        }
130        let date_at = |hours: f64| {
131            crate::events::hours_after(origin, hours).map(|at| at.with_timezone(&zone).date_naive())
132        };
133        let first = date_at(from)?.checked_sub_days(Days::new(1))?;
134        let last = date_at(until)?.checked_add_days(Days::new(1))?;
135        let mut spans = Vec::new();
136        let mut date = first;
137        while date <= last {
138            for period in periods_of(definition, date) {
139                let start = local_offset(zone, origin, date, period.start().minutes())?;
140                let end = local_offset(zone, origin, date, period.end().minutes())?;
141                if end > start {
142                    spans.push((start, end));
143                }
144            }
145            date = date.succ_opt()?;
146        }
147        let window = (
148            local_offset(zone, origin, first, 0)?,
149            local_offset(zone, origin, last.succ_opt()?, 0)?,
150        );
151        Some(Self::from_spans(window, spans))
152    }
153
154    fn from_spans(window: (i64, i64), mut spans: Vec<(i64, i64)>) -> Self {
155        spans.sort_unstable();
156        let mut merged: Vec<(i64, i64)> = Vec::with_capacity(spans.len());
157        for (start, end) in spans {
158            match merged.last_mut() {
159                Some(last) if start <= last.1 => last.1 = last.1.max(end),
160                _ => merged.push((start, end)),
161            }
162        }
163        let mut before = Vec::with_capacity(merged.len());
164        let mut through = Vec::with_capacity(merged.len());
165        let mut total: i64 = 0;
166        for (start, end) in &merged {
167            before.push(total);
168            total = total.saturating_add(end - start);
169            through.push(total);
170        }
171        let starts = merged.iter().map(|span| span.0).collect();
172        let ends = merged.iter().map(|span| span.1).collect();
173        Self {
174            starts: Ranked::new(starts, window.0, window.1),
175            ends: Ranked::new(ends, window.0, window.1),
176            through: Ranked::new(through, 0, total),
177            window,
178            spans: merged,
179            before,
180            always: false,
181        }
182    }
183
184    /// Hours after `from` at which `hours` of working time have passed, compiling wider windows
185    /// until the answer is inside one. `None` when the calendar has no working time left within
186    /// [`MAX_WINDOW_HOURS`].
187    #[must_use]
188    pub fn offset_after(
189        rules: CalendarRules<'_>,
190        zone: Tz,
191        from: DateTime<Utc>,
192        hours: f64,
193    ) -> Option<f64> {
194        let mut until = (hours.max(0.0) * 2.0 + 24.0 * 14.0).min(MAX_WINDOW_HOURS);
195        loop {
196            let calendar = Self::compile(rules, zone, from, 0.0, until)?;
197            if let Ok(end) = calendar.add(0.0, hours) {
198                return Some(end);
199            }
200            if until >= MAX_WINDOW_HOURS {
201                return None;
202            }
203            until = (until * 2.0).min(MAX_WINDOW_HOURS);
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests;