1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
21#[error("time lies outside the compiled calendar window")]
22pub struct BeyondCalendar;
23
24pub const MAX_WINDOW_HOURS: f64 = 24.0 * 366.0 * 100.0;
29
30const MILLIS_PER_HOUR: f64 = 3_600_000.0;
31
32#[derive(Debug, Clone, PartialEq)]
34pub struct WorkingTime {
35 window: (i64, i64),
37 spans: Vec<(i64, i64)>,
39 before: Vec<i64>,
41 starts: Ranked,
43 ends: Ranked,
45 through: Ranked,
47 always: bool,
48}
49
50fn millis(hours: f64) -> Option<i64> {
52 let value = (hours * MILLIS_PER_HOUR).round();
53 (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
61fn local_instant(zone: Tz, local: NaiveDateTime) -> Option<DateTime<Utc>> {
64 let mut candidate = local;
65 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
82fn 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 #[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 #[must_use]
107 pub fn is_always(&self) -> bool {
108 self.always
109 }
110
111 #[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 #[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;