use super::{CalendarDefinition, CalendarRules, WorkingPeriod, standard_definition};
use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveDateTime, TimeZone, Utc};
use chrono_tz::Tz;
use index::Ranked;
mod arithmetic;
mod index;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("time lies outside the compiled calendar window")]
pub struct BeyondCalendar;
pub const MAX_WINDOW_HOURS: f64 = 24.0 * 366.0 * 100.0;
const MILLIS_PER_HOUR: f64 = 3_600_000.0;
#[derive(Debug, Clone, PartialEq)]
pub struct WorkingTime {
window: (i64, i64),
spans: Vec<(i64, i64)>,
before: Vec<i64>,
starts: Ranked,
ends: Ranked,
through: Ranked,
always: bool,
}
fn millis(hours: f64) -> Option<i64> {
let value = (hours * MILLIS_PER_HOUR).round();
(value.is_finite() && value.abs() <= 9.0e15).then_some(value as i64)
}
fn hours(millis: i64) -> f64 {
millis as f64 / MILLIS_PER_HOUR
}
fn local_instant(zone: Tz, local: NaiveDateTime) -> Option<DateTime<Utc>> {
let mut candidate = local;
for _ in 0..=(6 * 4) {
if let Some(at) = zone.from_local_datetime(&candidate).earliest() {
return Some(at.with_timezone(&Utc));
}
candidate = candidate.checked_add_signed(chrono::TimeDelta::minutes(15))?;
}
None
}
fn periods_of(definition: &CalendarDefinition, date: NaiveDate) -> &[WorkingPeriod] {
match definition.exceptions.iter().find(|e| e.covers(date)) {
Some(exception) => &exception.hours,
None => definition.week.day(date.weekday()),
}
}
fn local_offset(zone: Tz, origin: DateTime<Utc>, date: NaiveDate, minutes: u16) -> Option<i64> {
let local = date
.and_hms_opt(0, 0, 0)?
.checked_add_signed(chrono::TimeDelta::minutes(i64::from(minutes)))?;
Some((local_instant(zone, local)? - origin).num_milliseconds())
}
impl WorkingTime {
#[must_use]
pub fn always() -> Self {
Self {
window: (i64::MIN, i64::MAX),
spans: Vec::new(),
before: Vec::new(),
starts: Ranked::new(Vec::new(), 0, 0),
ends: Ranked::new(Vec::new(), 0, 0),
through: Ranked::new(Vec::new(), 0, 0),
always: true,
}
}
#[must_use]
pub fn is_always(&self) -> bool {
self.always
}
#[must_use]
pub fn compile(
rules: CalendarRules<'_>,
zone: Tz,
origin: DateTime<Utc>,
from: f64,
until: f64,
) -> Option<Self> {
let definition = match rules {
CalendarRules::Always => return Some(Self::always()),
CalendarRules::Standard => &standard_definition(),
CalendarRules::Weekly(definition) => definition,
};
let span = until - from;
if span.is_nan() || span > MAX_WINDOW_HOURS {
return None;
}
let date_at = |hours: f64| {
crate::events::hours_after(origin, hours).map(|at| at.with_timezone(&zone).date_naive())
};
let first = date_at(from)?.checked_sub_days(Days::new(1))?;
let last = date_at(until)?.checked_add_days(Days::new(1))?;
let mut spans = Vec::new();
let mut date = first;
while date <= last {
for period in periods_of(definition, date) {
let start = local_offset(zone, origin, date, period.start().minutes())?;
let end = local_offset(zone, origin, date, period.end().minutes())?;
if end > start {
spans.push((start, end));
}
}
date = date.succ_opt()?;
}
let window = (
local_offset(zone, origin, first, 0)?,
local_offset(zone, origin, last.succ_opt()?, 0)?,
);
Some(Self::from_spans(window, spans))
}
fn from_spans(window: (i64, i64), mut spans: Vec<(i64, i64)>) -> Self {
spans.sort_unstable();
let mut merged: Vec<(i64, i64)> = Vec::with_capacity(spans.len());
for (start, end) in spans {
match merged.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => merged.push((start, end)),
}
}
let mut before = Vec::with_capacity(merged.len());
let mut through = Vec::with_capacity(merged.len());
let mut total: i64 = 0;
for (start, end) in &merged {
before.push(total);
total = total.saturating_add(end - start);
through.push(total);
}
let starts = merged.iter().map(|span| span.0).collect();
let ends = merged.iter().map(|span| span.1).collect();
Self {
starts: Ranked::new(starts, window.0, window.1),
ends: Ranked::new(ends, window.0, window.1),
through: Ranked::new(through, 0, total),
window,
spans: merged,
before,
always: false,
}
}
#[must_use]
pub fn offset_after(
rules: CalendarRules<'_>,
zone: Tz,
from: DateTime<Utc>,
hours: f64,
) -> Option<f64> {
let mut until = (hours.max(0.0) * 2.0 + 24.0 * 14.0).min(MAX_WINDOW_HOURS);
loop {
let calendar = Self::compile(rules, zone, from, 0.0, until)?;
if let Ok(end) = calendar.add(0.0, hours) {
return Some(end);
}
if until >= MAX_WINDOW_HOURS {
return None;
}
until = (until * 2.0).min(MAX_WINDOW_HOURS);
}
}
}
#[cfg(test)]
mod tests;