use jiff::civil::Time;
use jiff::tz::TimeZone;
use jiff::Timestamp;
use crate::engine::{each_date, local_dates, resolve, Block};
use crate::Weekdays;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct Hours {
pub(crate) start: Time,
pub(crate) end: Time,
pub(crate) days: Weekdays,
pub(crate) zone: Option<String>,
}
impl Hours {
pub(crate) fn resolve(&self, tz: TimeZone) -> Shifts {
Shifts {
start: self.start,
end: self.end,
days: self.days,
tz,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct WorkDay(pub(crate) Hours);
impl WorkDay {
#[must_use]
pub fn new(start: Time, end: Time, days: Weekdays) -> Self {
Self(Hours {
start,
end,
days,
zone: None,
})
}
#[must_use]
pub fn time_zone(mut self, name: impl Into<String>) -> Self {
self.0.zone = Some(name.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Break(pub(crate) Hours);
impl Break {
#[must_use]
pub fn new(start: Time, end: Time, days: Weekdays) -> Self {
Self(Hours {
start,
end,
days,
zone: None,
})
}
#[must_use]
pub fn time_zone(mut self, name: impl Into<String>) -> Self {
self.0.zone = Some(name.into());
self
}
}
#[derive(Clone, Debug)]
pub(crate) struct Shifts {
start: Time,
end: Time,
days: Weekdays,
tz: TimeZone,
}
impl Shifts {
pub(crate) fn steady(&self, lo: Timestamp, hi: Timestamp) -> bool {
self.tz
.following(lo)
.next()
.is_none_or(|transition| transition.timestamp() > hi)
}
pub(crate) fn blocks(&self, lo: Timestamp, hi: Timestamp, out: &mut Vec<Block>) {
let overnight = self.end <= self.start;
for date in each_date(local_dates(lo, hi, &self.tz)) {
if !self.days.contains(date.weekday()) {
continue;
}
let start = resolve(date, self.start, &self.tz);
let end = match (overnight, date.tomorrow()) {
(false, _) => resolve(date, self.end, &self.tz),
(true, Ok(next)) => resolve(next, self.end, &self.tz),
(true, Err(_)) => Timestamp::MAX,
};
out.extend(Block::clipped(start, end, lo, hi));
}
}
}
#[cfg(test)]
mod tests {
use jiff::civil::{time, Time};
use jiff::tz::TimeZone;
use super::Hours;
use crate::Weekdays;
fn blocks(
start: Time,
end: Time,
days: Weekdays,
zone: &str,
lo: &str,
hi: &str,
) -> Vec<String> {
let hours = Hours {
start,
end,
days,
zone: None,
};
let mut out = Vec::new();
hours.resolve(TimeZone::get(zone).unwrap()).blocks(
lo.parse().unwrap(),
hi.parse().unwrap(),
&mut out,
);
out.into_iter()
.map(|b| format!("{}/{}", b.start, b.end))
.collect()
}
#[test]
fn day_shifts_on_working_days_only() {
let got = blocks(
time(9, 0, 0, 0),
time(17, 0, 0, 0),
Weekdays::MON_TO_FRI,
"UTC",
"2026-01-09T00:00:00Z",
"2026-01-13T00:00:00Z",
);
assert_eq!(
got,
[
"2026-01-09T09:00:00Z/2026-01-09T17:00:00Z",
"2026-01-12T09:00:00Z/2026-01-12T17:00:00Z",
]
);
}
#[test]
fn overnight_shift_belongs_to_its_start_day() {
let got = blocks(
time(22, 0, 0, 0),
time(6, 0, 0, 0),
Weekdays::MON_TO_FRI,
"UTC",
"2026-01-09T00:00:00Z",
"2026-01-12T12:00:00Z",
);
assert_eq!(
got,
[
"2026-01-09T00:00:00Z/2026-01-09T06:00:00Z",
"2026-01-09T22:00:00Z/2026-01-10T06:00:00Z",
]
);
}
#[test]
fn shift_in_progress_is_clipped_to_the_window() {
let got = blocks(
time(22, 0, 0, 0),
time(6, 0, 0, 0),
Weekdays::MON_TO_FRI,
"UTC",
"2026-01-06T02:00:00Z",
"2026-01-06T04:00:00Z",
);
assert_eq!(got, ["2026-01-06T02:00:00Z/2026-01-06T04:00:00Z"]);
}
#[test]
fn equal_start_and_end_is_a_24_hour_shift() {
let got = blocks(
time(9, 0, 0, 0),
time(9, 0, 0, 0),
Weekdays::MON_TO_FRI,
"UTC",
"2026-01-05T00:00:00Z",
"2026-01-07T00:00:00Z",
);
assert_eq!(
got,
[
"2026-01-05T09:00:00Z/2026-01-06T09:00:00Z",
"2026-01-06T09:00:00Z/2026-01-07T00:00:00Z",
]
);
}
#[test]
fn gap_moves_times_forward() {
let sunday = |start: Time, end: Time| {
blocks(
start,
end,
Weekdays::ALL,
"America/New_York",
"2026-03-08T00:00:00Z",
"2026-03-09T00:00:00Z",
)
};
assert_eq!(
sunday(time(2, 30, 0, 0), time(4, 0, 0, 0)),
["2026-03-08T07:30:00Z/2026-03-08T08:00:00Z"]
);
assert_eq!(
sunday(time(2, 30, 0, 0), time(3, 0, 0, 0)),
Vec::<String>::new()
);
assert_eq!(
sunday(time(2, 0, 0, 0), time(2, 30, 0, 0)),
["2026-03-08T07:00:00Z/2026-03-08T07:30:00Z"]
);
}
#[test]
fn overlap_takes_the_earlier_occurrence() {
let got = blocks(
time(1, 30, 0, 0),
time(3, 0, 0, 0),
Weekdays::ALL,
"America/New_York",
"2026-11-01T00:00:00Z",
"2026-11-02T00:00:00Z",
);
assert_eq!(got, ["2026-11-01T05:30:00Z/2026-11-01T08:00:00Z"]);
}
#[test]
fn shifts_past_the_edges_of_jiff_range_are_clipped_to_it() {
let all_day = |lo: &str, hi: &str| {
blocks(
Time::midnight(),
Time::midnight(),
Weekdays::ALL,
"UTC",
lo,
hi,
)
};
assert_eq!(
all_day("-009999-01-02T01:59:59Z", "-009999-01-04T00:00:00Z"),
[
"-009999-01-02T01:59:59Z/-009999-01-03T00:00:00Z",
"-009999-01-03T00:00:00Z/-009999-01-04T00:00:00Z",
]
);
assert_eq!(
all_day("9999-12-29T00:00:00Z", "9999-12-30T22:00:00Z"),
[
"9999-12-29T00:00:00Z/9999-12-30T00:00:00Z",
"9999-12-30T00:00:00Z/9999-12-30T22:00:00Z",
]
);
}
}