use std::collections::BTreeSet;
use std::str::FromStr;
use std::time::Duration;
use chrono::{
DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeDelta, TimeZone, Timelike,
Utc, Weekday,
};
use chrono_tz::Tz;
use super::SlaError;
const DAY_SECS: u32 = 86_400;
const SCAN_DAYS: usize = 36_525;
const WEEKDAYS: [Weekday; 5] = [
Weekday::Mon,
Weekday::Tue,
Weekday::Wed,
Weekday::Thu,
Weekday::Fri,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkingHours {
open: u32,
close: u32,
}
impl WorkingHours {
pub const ALL_DAY: Self = Self {
open: 0,
close: DAY_SECS,
};
pub fn new(open: NaiveTime, close: NaiveTime) -> Result<Self, SlaError> {
let close_secs = match close.num_seconds_from_midnight() {
0 => DAY_SECS,
secs => secs,
};
Self::from_secs(open.num_seconds_from_midnight(), close_secs)
.ok_or_else(|| SlaError::InvalidHours(format!("{open}-{close}")))
}
fn from_secs(open: u32, close: u32) -> Option<Self> {
(open < close && close <= DAY_SECS).then_some(Self { open, close })
}
#[must_use]
pub fn length(&self) -> Duration {
Duration::from_secs(u64::from(self.close.saturating_sub(self.open)))
}
}
impl FromStr for WorkingHours {
type Err = SlaError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let invalid = || SlaError::InvalidHours(text.to_owned());
let (open, close) = text.trim().split_once('-').ok_or_else(invalid)?;
let open = parse_clock(open).ok_or_else(invalid)?;
let close = parse_clock(close).ok_or_else(invalid)?;
Self::from_secs(open, close).ok_or_else(invalid)
}
}
fn parse_clock(text: &str) -> Option<u32> {
let (hours, minutes) = text.trim().split_once(':')?;
let digits = |text: &str| text.bytes().all(|b| b.is_ascii_digit());
if hours.is_empty()
|| hours.len() > 2
|| minutes.len() != 2
|| !digits(hours)
|| !digits(minutes)
{
return None;
}
let hours: u32 = hours.parse().ok()?;
let minutes: u32 = minutes.parse().ok()?;
if minutes >= 60 || hours > 24 || (hours == 24 && minutes > 0) {
return None;
}
hours
.checked_mul(3_600)?
.checked_add(minutes.checked_mul(60)?)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BusinessCalendar {
windows: [Vec<WorkingHours>; 7],
holidays: BTreeSet<NaiveDate>,
annual_holidays: BTreeSet<(u32, u32)>,
zone: Option<Tz>,
day_length: Option<Duration>,
}
impl BusinessCalendar {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn weekdays(hours: WorkingHours) -> Self {
WEEKDAYS
.into_iter()
.fold(Self::new(), |calendar, day| calendar.hours(day, hours))
}
#[must_use]
pub fn hours(mut self, day: Weekday, hours: WorkingHours) -> Self {
if let Some(windows) = self.windows.get_mut(day_index(day)) {
windows.push(hours);
*windows = merge(std::mem::take(windows));
}
self
}
#[must_use]
pub fn holiday(mut self, date: NaiveDate) -> Self {
self.holidays.insert(date);
self
}
#[must_use]
pub fn annual_holiday(mut self, month: u32, day: u32) -> Self {
self.annual_holidays.insert((month, day));
self
}
#[must_use]
pub const fn zone(mut self, zone: Tz) -> Self {
self.zone = Some(zone);
self
}
#[must_use]
pub const fn business_day(mut self, length: Duration) -> Self {
self.day_length = Some(length);
self
}
#[must_use]
pub const fn home_zone(&self) -> Option<Tz> {
self.zone
}
#[must_use]
pub fn day_length(&self) -> Duration {
self.day_length.unwrap_or_else(|| {
self.windows
.iter()
.map(|day| {
day.iter()
.fold(Duration::ZERO, |sum, w| sum.saturating_add(w.length()))
})
.max()
.unwrap_or(Duration::ZERO)
})
}
#[must_use]
pub fn is_holiday(&self, date: NaiveDate) -> bool {
self.holidays.contains(&date) || self.annual_holidays.contains(&(date.month(), date.day()))
}
#[must_use]
pub fn is_working(&self, at: DateTime<Utc>, zone: Tz) -> bool {
self.intervals(local_date(at, zone), zone)
.iter()
.any(|(start, end)| *start <= at && at < *end)
}
#[must_use]
pub fn next_working_instant(&self, at: DateTime<Utc>, zone: Tz) -> Option<DateTime<Utc>> {
self.scan(at, zone)
.find(|(_, end)| *end > at)
.map(|(start, _)| start.max(at))
}
#[must_use]
pub fn working_time(&self, from: DateTime<Utc>, to: DateTime<Utc>, zone: Tz) -> Duration {
if to <= from || !self.has_working_time() {
return Duration::ZERO;
}
let last = local_date(to, zone);
let mut total = TimeDelta::zero();
for date in local_date(from, zone)
.iter_days()
.take(SCAN_DAYS)
.take_while(|d| *d <= last)
{
for (start, end) in self.intervals(date, zone) {
let (start, end) = (start.max(from), end.min(to));
if end > start {
total = total
.checked_add(&end.signed_duration_since(start))
.unwrap_or(TimeDelta::MAX);
}
}
}
total.to_std().unwrap_or(Duration::ZERO)
}
#[must_use]
pub fn deadline(
&self,
from: DateTime<Utc>,
budget: Duration,
zone: Tz,
) -> Option<DateTime<Utc>> {
let mut left = TimeDelta::from_std(budget).ok()?;
if left.is_zero() {
return self.next_working_instant(from, zone);
}
for (start, end) in self.scan(from, zone) {
let start = start.max(from);
if end <= start {
continue;
}
let available = end.signed_duration_since(start);
if available >= left {
return start.checked_add_signed(left);
}
left = left.checked_sub(&available)?;
}
None
}
fn has_working_time(&self) -> bool {
self.windows.iter().any(|day| !day.is_empty())
}
fn scan(
&self,
from: DateTime<Utc>,
zone: Tz,
) -> impl Iterator<Item = (DateTime<Utc>, DateTime<Utc>)> + '_ {
let days = if self.has_working_time() {
SCAN_DAYS
} else {
0
};
local_date(from, zone)
.iter_days()
.take(days)
.flat_map(move |date| self.intervals(date, zone))
}
fn intervals(&self, date: NaiveDate, zone: Tz) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
if self.is_holiday(date) {
return Vec::new();
}
self.windows
.get(day_index(date.weekday()))
.map(|windows| {
let day_end = local_instant(date, DAY_SECS, zone);
let resolved: Vec<_> = windows
.iter()
.filter_map(|w| {
let start = local_instant(date, w.open, zone)?;
let end = local_instant(date, w.close, zone)?;
let end = day_end.map_or(end, |day_end| end.min(day_end));
(end > start).then_some((start, end))
})
.collect();
merge_instants(resolved)
})
.unwrap_or_default()
}
}
fn merge_instants(
mut intervals: Vec<(DateTime<Utc>, DateTime<Utc>)>,
) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
intervals.sort_unstable();
let mut merged: Vec<(DateTime<Utc>, DateTime<Utc>)> = Vec::with_capacity(intervals.len());
for (start, end) in intervals {
match merged.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => merged.push((start, end)),
}
}
merged
}
const fn day_index(day: Weekday) -> usize {
day.num_days_from_monday() as usize
}
fn merge(mut windows: Vec<WorkingHours>) -> Vec<WorkingHours> {
windows.sort_unstable();
let mut merged: Vec<WorkingHours> = Vec::with_capacity(windows.len());
for window in windows {
match merged.last_mut() {
Some(last) if window.open <= last.close => last.close = last.close.max(window.close),
_ => merged.push(window),
}
}
merged
}
fn local_date(at: DateTime<Utc>, zone: Tz) -> NaiveDate {
let offset = zone.offset_from_utc_datetime(&at.naive_utc()).fix();
at.naive_utc()
.checked_add_offset(offset)
.map_or_else(|| at.date_naive(), |local| local.date())
}
fn local_instant(date: NaiveDate, secs: u32, zone: Tz) -> Option<DateTime<Utc>> {
let naive: NaiveDateTime = date
.and_time(NaiveTime::MIN)
.checked_add_signed(TimeDelta::try_seconds(i64::from(secs))?)?;
if let Some(local) = zone.from_local_datetime(&naive).earliest() {
return Some(local.with_timezone(&Utc));
}
let before = naive.checked_sub_signed(TimeDelta::try_days(2)?)?;
let offset = zone.from_local_datetime(&before).earliest()?.offset().fix();
Some(naive.checked_sub_offset(offset)?.and_utc())
}