use serde::{Deserialize, Serialize};
use jiff::civil::{Date, Time, Weekday};
use jiff::tz::TimeZone;
use jiff::{Timestamp, ToSpan};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Window {
pub hours: String,
pub tz: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub days: Option<String>,
#[serde(default)]
pub grace_before_secs: u64,
#[serde(default)]
pub grace_after_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<String>,
}
pub enum State {
Open { closes: i64 },
Closed { next_open: Option<i64> },
}
const HORIZON_DAYS: i64 = 400;
impl Window {
pub fn validate(&self) -> Result<(), String> {
let (open, close) = self.parse_hours()?;
if open >= close {
return Err(format!(
"window hours '{}': open must be before close (overnight windows are not supported)",
self.hours
));
}
self.parse_days()?;
self.zone()?;
if let Some(u) = &self.until {
parse_date(u)?;
}
Ok(())
}
pub fn state(&self, now_unix: u64) -> Result<State, String> {
let (open, close) = self.parse_hours()?;
let days = self.parse_days()?;
let tz = self.zone()?;
let until = self.until.as_deref().map(parse_date).transpose()?;
let now_ts = Timestamp::from_second(now_unix as i64)
.map_err(|e| format!("timestamp out of range: {e}"))?;
let today = now_ts.to_zoned(tz.clone()).date();
let mut date = today.yesterday().map_err(|e| format!("date range: {e}"))?;
for _ in 0..HORIZON_DAYS {
let in_days = days.is_empty() || days.contains(&date.weekday());
let in_until = until.is_none_or(|u| date <= u);
if in_days && in_until {
let start = zoned_second(&tz, date, open)?
- i64::try_from(self.grace_before_secs).unwrap_or(0);
let end = zoned_second(&tz, date, close)?
+ i64::try_from(self.grace_after_secs).unwrap_or(0);
let now = now_unix as i64;
if now < start {
return Ok(State::Closed { next_open: Some(start) });
}
if now < end {
return Ok(State::Open { closes: end });
}
}
date = date.tomorrow().map_err(|e| format!("date range: {e}"))?;
}
Ok(State::Closed { next_open: None })
}
pub fn end_of_authorisation(&self) -> Result<Option<i64>, String> {
let Some(u) = &self.until else { return Ok(None) };
let (_, close) = self.parse_hours()?;
let tz = self.zone()?;
let end = zoned_second(&tz, parse_date(u)?, close)?
+ i64::try_from(self.grace_after_secs).unwrap_or(0);
Ok(Some(end))
}
pub fn describe(&self) -> String {
let mut s = String::new();
if let Some(d) = &self.days {
s.push_str(d);
s.push(' ');
}
s.push_str(&self.hours);
s.push(' ');
s.push_str(&self.tz);
if self.grace_before_secs > 0 || self.grace_after_secs > 0 {
if self.grace_before_secs == self.grace_after_secs {
s.push_str(&format!(" ±{}", crate::lease::human_secs(self.grace_before_secs)));
} else {
s.push_str(&format!(
" (-{}/+{})",
crate::lease::human_secs(self.grace_before_secs),
crate::lease::human_secs(self.grace_after_secs)
));
}
}
if let Some(u) = &self.until {
s.push_str(&format!(" until {u}"));
}
s
}
fn parse_hours(&self) -> Result<(Time, Time), String> {
let (a, b) = self
.hours
.split_once('-')
.ok_or_else(|| format!("window hours '{}': expected 'HH:MM-HH:MM'", self.hours))?;
Ok((parse_hm(a.trim())?, parse_hm(b.trim())?))
}
fn parse_days(&self) -> Result<Vec<Weekday>, String> {
let Some(spec) = &self.days else { return Ok(Vec::new()) };
let mut out = Vec::new();
for token in spec.split(',') {
let token = token.trim();
if let Some((a, b)) = token.split_once('-') {
let (a, b) = (parse_day(a.trim())?, parse_day(b.trim())?);
let (mut n, last) = (a.to_monday_one_offset(), b.to_monday_one_offset());
if n > last {
return Err(format!(
"window days '{spec}': range '{token}' runs backwards (no wrap-around)"
));
}
while n <= last {
out.push(Weekday::from_monday_one_offset(n).map_err(|e| e.to_string())?);
n += 1;
}
} else {
out.push(parse_day(token)?);
}
}
Ok(out)
}
fn zone(&self) -> Result<TimeZone, String> {
TimeZone::get(&self.tz).map_err(|_| {
format!("window tz '{}': not an IANA zone name (e.g. America/New_York)", self.tz)
})
}
}
fn zoned_second(tz: &TimeZone, date: Date, time: Time) -> Result<i64, String> {
let dt = date.at(time.hour(), time.minute(), 0, 0);
Ok(tz
.to_ambiguous_zoned(dt)
.compatible()
.map_err(|e| format!("resolving {dt} in zone: {e}"))?
.timestamp()
.as_second())
}
fn parse_hm(s: &str) -> Result<Time, String> {
let (h, m) = s
.split_once(':')
.ok_or_else(|| format!("time '{s}': expected HH:MM"))?;
let h: i8 = h.parse().map_err(|_| format!("time '{s}': bad hour"))?;
let m: i8 = m.parse().map_err(|_| format!("time '{s}': bad minute"))?;
Time::new(h, m, 0, 0).map_err(|_| format!("time '{s}': out of range"))
}
fn parse_day(s: &str) -> Result<Weekday, String> {
match s.to_ascii_lowercase().as_str() {
"mon" | "monday" => Ok(Weekday::Monday),
"tue" | "tues" | "tuesday" => Ok(Weekday::Tuesday),
"wed" | "wednesday" => Ok(Weekday::Wednesday),
"thu" | "thur" | "thurs" | "thursday" => Ok(Weekday::Thursday),
"fri" | "friday" => Ok(Weekday::Friday),
"sat" | "saturday" => Ok(Weekday::Saturday),
"sun" | "sunday" => Ok(Weekday::Sunday),
other => Err(format!("window days: unknown day '{other}'")),
}
}
fn parse_date(s: &str) -> Result<Date, String> {
s.parse::<Date>()
.map_err(|_| format!("window until '{s}': expected YYYY-MM-DD"))
}
#[allow(unused_imports)]
use ToSpan as _;
#[cfg(test)]
mod tests {
use super::*;
fn market() -> Window {
Window {
hours: "09:30-16:00".into(),
tz: "America/New_York".into(),
days: Some("mon-fri".into()),
grace_before_secs: 1800,
grace_after_secs: 1800,
until: None,
}
}
fn is_open(w: &Window, t: u64) -> bool {
matches!(w.state(t).unwrap(), State::Open { .. })
}
#[test]
fn validates() {
market().validate().unwrap();
let mut w = market();
w.hours = "16:00-09:30".into();
assert!(w.validate().is_err(), "overnight must be rejected");
w = market();
w.tz = "Mars/Olympus".into();
assert!(w.validate().is_err());
w = market();
w.days = Some("fri-mon".into());
assert!(w.validate().is_err(), "backwards range must be rejected");
w = market();
w.until = Some("someday".into());
assert!(w.validate().is_err());
}
#[test]
fn grace_edges_est() {
let w = market();
assert!(is_open(&w, 1768485600));
assert!(!is_open(&w, 1768485540));
assert!(!is_open(&w, 1784147460));
}
#[test]
fn weekday_gate() {
let w = market();
assert!(!is_open(&w, 1784390400));
assert!(is_open(&w, 1784124000));
assert!(!is_open(&w, 1784098800));
}
#[test]
fn dst_transitions_evaluate_in_zone() {
let w = market();
assert!(is_open(&w, 1773064800));
assert!(is_open(&w, 1793631600));
let summer_open = match w.state(1784098800).unwrap() {
State::Closed { next_open: Some(t) } => t,
_ => panic!("3am should be closed with a next open"),
};
assert_eq!(summer_open % 86400, 13 * 3600);
}
#[test]
fn until_bound_ends_authorisation() {
let mut w = market();
w.until = Some("2026-09-04".into());
assert!(is_open(&w, 1788548400));
match w.state(1788796800).unwrap() {
State::Closed { next_open: None } => {}
_ => panic!("past `until` must report no next open"),
}
let end = w.end_of_authorisation().unwrap().unwrap();
assert_eq!(end % 86400, 20 * 3600 + 1800);
}
#[test]
fn every_day_when_days_absent() {
let mut w = market();
w.days = None;
assert!(is_open(&w, 1784390400));
}
#[test]
fn meta_roundtrip_and_absent_field_compat() {
let w = market();
let j = serde_json::to_string(&w).unwrap();
assert_eq!(serde_json::from_str::<Window>(&j).unwrap(), w);
let old: Option<Window> = serde_json::from_str("null").unwrap();
assert!(old.is_none());
}
}