use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveWindow {
pub start_hour: u8,
pub end_hour: u8,
pub activation: f32,
}
impl ActiveWindow {
#[must_use]
pub fn new(start_hour: u8, end_hour: u8, activation: f32) -> Self {
Self {
start_hour: start_hour.min(23),
end_hour: end_hour.min(23),
activation: activation.clamp(0.0, 1.0),
}
}
#[must_use]
#[inline]
pub fn contains_hour(&self, hour: u8) -> bool {
if self.start_hour <= self.end_hour {
hour >= self.start_hour && hour < self.end_hour
} else {
hour >= self.start_hour || hour < self.end_hour
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveHoursSchedule {
pub windows: Vec<ActiveWindow>,
pub timezone_offset_secs: i32,
pub default_activation: f32,
}
impl Default for ActiveHoursSchedule {
fn default() -> Self {
Self {
windows: Vec::new(),
timezone_offset_secs: 0,
default_activation: 0.0,
}
}
}
impl ActiveHoursSchedule {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_window(&mut self, window: ActiveWindow) {
self.windows.push(window);
}
#[must_use]
fn local_hour(&self, now: DateTime<Utc>) -> u8 {
let utc_secs = now.timestamp();
let local_secs = utc_secs + self.timezone_offset_secs as i64;
let hour = ((local_secs % 86400 + 86400) % 86400) / 3600;
hour as u8
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
#[inline]
pub fn activation_at(&self, now: DateTime<Utc>) -> f32 {
let hour = self.local_hour(now);
self.windows
.iter()
.filter(|w| w.contains_hour(hour))
.map(|w| w.activation)
.fold(self.default_activation, f32::max)
}
#[must_use]
pub fn is_active(&self, now: DateTime<Utc>) -> bool {
self.activation_at(now) > 0.5
}
#[must_use]
pub fn is_dormant(&self, now: DateTime<Utc>) -> bool {
self.activation_at(now) < 0.1
}
#[must_use]
pub fn window_count(&self) -> usize {
self.windows.len()
}
}
impl fmt::Display for ActiveHoursSchedule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.windows.is_empty() {
return f.write_str("no schedule (always default)");
}
for (i, w) in self.windows.iter().enumerate() {
if i > 0 {
f.write_str(", ")?;
}
write!(
f,
"{:02}:00–{:02}:00 ({:.0}%)",
w.start_hour,
w.end_hour,
w.activation * 100.0
)?;
}
Ok(())
}
}
#[must_use]
pub fn default_schedule() -> ActiveHoursSchedule {
ActiveHoursSchedule {
windows: vec![ActiveWindow::new(9, 17, 1.0)],
timezone_offset_secs: 0,
default_activation: 0.0,
}
}
#[must_use]
pub fn night_owl_schedule() -> ActiveHoursSchedule {
ActiveHoursSchedule {
windows: vec![ActiveWindow::new(14, 2, 1.0)],
timezone_offset_secs: 0,
default_activation: 0.0,
}
}
#[must_use]
pub fn early_bird_schedule() -> ActiveHoursSchedule {
ActiveHoursSchedule {
windows: vec![ActiveWindow::new(5, 14, 1.0)],
timezone_offset_secs: 0,
default_activation: 0.0,
}
}
#[must_use]
pub fn always_on() -> ActiveHoursSchedule {
ActiveHoursSchedule {
windows: Vec::new(),
timezone_offset_secs: 0,
default_activation: 1.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn utc_at_hour(hour: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 15, hour, 30, 0).unwrap()
}
#[test]
fn test_window_normal_range() {
let w = ActiveWindow::new(9, 17, 1.0);
assert!(w.contains_hour(9));
assert!(w.contains_hour(12));
assert!(w.contains_hour(16));
assert!(!w.contains_hour(17));
assert!(!w.contains_hour(8));
}
#[test]
fn test_window_wraps_midnight() {
let w = ActiveWindow::new(22, 6, 1.0);
assert!(w.contains_hour(22));
assert!(w.contains_hour(23));
assert!(w.contains_hour(0));
assert!(w.contains_hour(5));
assert!(!w.contains_hour(6));
assert!(!w.contains_hour(12));
}
#[test]
fn test_window_clamps() {
let w = ActiveWindow::new(25, 30, 2.0);
assert_eq!(w.start_hour, 23);
assert_eq!(w.end_hour, 23);
assert!((w.activation - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_window_same_start_end() {
let w = ActiveWindow::new(12, 12, 1.0);
for h in 0..24 {
assert!(
!w.contains_hour(h),
"hour {h} should not match zero-width window"
);
}
}
#[test]
fn test_default_schedule() {
let s = default_schedule();
assert!(s.is_active(utc_at_hour(12)));
assert!(!s.is_active(utc_at_hour(3)));
}
#[test]
fn test_night_owl() {
let s = night_owl_schedule();
assert!(s.is_active(utc_at_hour(20)));
assert!(s.is_active(utc_at_hour(0)));
assert!(!s.is_active(utc_at_hour(8)));
}
#[test]
fn test_early_bird() {
let s = early_bird_schedule();
assert!(s.is_active(utc_at_hour(6)));
assert!(!s.is_active(utc_at_hour(20)));
}
#[test]
fn test_always_on() {
let s = always_on();
for hour in 0..24 {
assert!(
s.is_active(utc_at_hour(hour)),
"should be active at hour {hour}"
);
}
}
#[test]
fn test_empty_schedule_dormant() {
let s = ActiveHoursSchedule::new();
assert!(s.is_dormant(utc_at_hour(12)));
}
#[test]
fn test_timezone_offset() {
let mut s = default_schedule(); s.timezone_offset_secs = -5 * 3600; assert!(s.is_active(utc_at_hour(14)));
assert!(!s.is_active(utc_at_hour(12)));
}
#[test]
fn test_multiple_windows_max() {
let mut s = ActiveHoursSchedule::new();
s.add_window(ActiveWindow::new(9, 17, 0.5));
s.add_window(ActiveWindow::new(12, 14, 1.0));
let a = s.activation_at(utc_at_hour(13));
assert!((a - 1.0).abs() < f32::EPSILON);
let a2 = s.activation_at(utc_at_hour(10));
assert!((a2 - 0.5).abs() < f32::EPSILON);
}
#[test]
fn test_display() {
let s = default_schedule();
let text = s.to_string();
assert!(text.contains("09:00"), "display: {text}");
assert!(text.contains("17:00"), "display: {text}");
}
#[test]
fn test_display_empty() {
let s = ActiveHoursSchedule::new();
let text = s.to_string();
assert!(text.contains("no schedule"));
}
#[test]
fn test_window_count() {
let mut s = ActiveHoursSchedule::new();
assert_eq!(s.window_count(), 0);
s.add_window(ActiveWindow::new(9, 17, 1.0));
assert_eq!(s.window_count(), 1);
}
#[test]
fn test_serde_schedule() {
let s = default_schedule();
let json = serde_json::to_string(&s).unwrap();
let s2: ActiveHoursSchedule = serde_json::from_str(&json).unwrap();
assert_eq!(s2.window_count(), s.window_count());
}
#[test]
fn test_serde_window() {
let w = ActiveWindow::new(9, 17, 0.8);
let json = serde_json::to_string(&w).unwrap();
let w2: ActiveWindow = serde_json::from_str(&json).unwrap();
assert_eq!(w2.start_hour, w.start_hour);
}
}