Skip to main content

u_schedule/models/
calendar.rs

1//! Calendar and time window models.
2//!
3//! Defines resource availability patterns: working hours, shifts,
4//! and blocked periods (maintenance, holidays).
5//!
6//! # Time Model
7//! All times are in milliseconds relative to a scheduling epoch.
8//! The consumer defines what epoch means.
9//!
10//! # Precedence
11//! Blocked periods override time windows. A timestamp is available iff:
12//! - It falls within at least one `time_windows` entry, AND
13//! - It does NOT fall within any `blocked_periods` entry.
14
15use serde::{Deserialize, Serialize};
16
17/// A time interval [start, end).
18///
19/// Half-open interval: includes start, excludes end.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct TimeWindow {
22    /// Interval start (ms, inclusive).
23    pub start_ms: i64,
24    /// Interval end (ms, exclusive).
25    pub end_ms: i64,
26}
27
28impl TimeWindow {
29    /// Creates a new time window.
30    pub fn new(start_ms: i64, end_ms: i64) -> Self {
31        Self { start_ms, end_ms }
32    }
33
34    /// Duration of this window (ms).
35    #[inline]
36    pub fn duration_ms(&self) -> i64 {
37        self.end_ms - self.start_ms
38    }
39
40    /// Whether a timestamp falls within this window.
41    #[inline]
42    pub fn contains(&self, time_ms: i64) -> bool {
43        time_ms >= self.start_ms && time_ms < self.end_ms
44    }
45
46    /// Whether two windows overlap.
47    pub fn overlaps(&self, other: &Self) -> bool {
48        self.start_ms < other.end_ms && other.start_ms < self.end_ms
49    }
50}
51
52/// Resource availability calendar.
53///
54/// Combines positive availability windows with negative blocked periods.
55/// If no time_windows are defined, the resource is always available
56/// (subject to blocked periods).
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Calendar {
59    /// Calendar identifier.
60    pub id: String,
61    /// Periods when the resource is available.
62    /// Empty = always available.
63    pub time_windows: Vec<TimeWindow>,
64    /// Periods when the resource is unavailable (overrides time_windows).
65    pub blocked_periods: Vec<TimeWindow>,
66}
67
68impl Calendar {
69    /// Creates an empty calendar (no constraints = always available).
70    pub fn new(id: impl Into<String>) -> Self {
71        Self {
72            id: id.into(),
73            time_windows: Vec::new(),
74            blocked_periods: Vec::new(),
75        }
76    }
77
78    /// Creates a calendar that is always available.
79    pub fn always_available(id: impl Into<String>) -> Self {
80        Self::new(id)
81    }
82
83    /// Adds an availability window.
84    pub fn with_window(mut self, start_ms: i64, end_ms: i64) -> Self {
85        self.time_windows.push(TimeWindow::new(start_ms, end_ms));
86        self
87    }
88
89    /// Adds a blocked period.
90    pub fn with_blocked(mut self, start_ms: i64, end_ms: i64) -> Self {
91        self.blocked_periods.push(TimeWindow::new(start_ms, end_ms));
92        self
93    }
94
95    /// Whether a timestamp is within working time.
96    ///
97    /// Returns `true` if the timestamp is in an availability window
98    /// (or no windows are defined) AND not in any blocked period.
99    pub fn is_working_time(&self, time_ms: i64) -> bool {
100        // Check blocked periods first (they override)
101        if self.blocked_periods.iter().any(|w| w.contains(time_ms)) {
102            return false;
103        }
104
105        // If no windows defined, always available
106        if self.time_windows.is_empty() {
107            return true;
108        }
109
110        // Must be in at least one window
111        self.time_windows.iter().any(|w| w.contains(time_ms))
112    }
113
114    /// Finds the next available time at or after `from_ms`.
115    ///
116    /// Returns `from_ms` if already available, or the start of the
117    /// next availability window that isn't blocked.
118    ///
119    /// Returns `None` if no future availability exists.
120    pub fn next_available_time(&self, from_ms: i64) -> Option<i64> {
121        if self.is_working_time(from_ms) {
122            return Some(from_ms);
123        }
124
125        // If no windows, we must be in a blocked period
126        if self.time_windows.is_empty() {
127            // Find end of current blocked period
128            for bp in &self.blocked_periods {
129                if bp.contains(from_ms) {
130                    let candidate = bp.end_ms;
131                    if self.is_working_time(candidate) {
132                        return Some(candidate);
133                    }
134                }
135            }
136            return None;
137        }
138
139        // Search windows sorted by start time
140        let mut candidates: Vec<i64> = self
141            .time_windows
142            .iter()
143            .filter(|w| w.end_ms > from_ms)
144            .map(|w| w.start_ms.max(from_ms))
145            .collect();
146        candidates.sort();
147
148        for candidate in candidates {
149            if self.is_working_time(candidate) {
150                return Some(candidate);
151            }
152            // If candidate is blocked, try end of the blocking period
153            for bp in &self.blocked_periods {
154                if bp.contains(candidate) && bp.end_ms < i64::MAX && self.is_working_time(bp.end_ms)
155                {
156                    return Some(bp.end_ms);
157                }
158            }
159        }
160
161        None
162    }
163
164    /// Computes total available time within a range [start, end).
165    pub fn available_time_in_range(&self, start_ms: i64, end_ms: i64) -> i64 {
166        if end_ms <= start_ms {
167            return 0;
168        }
169
170        let range = TimeWindow::new(start_ms, end_ms);
171
172        // If no windows, total = range - blocked
173        if self.time_windows.is_empty() {
174            let blocked: i64 = self
175                .blocked_periods
176                .iter()
177                .filter_map(|bp| overlap_duration(&range, bp))
178                .sum();
179            return range.duration_ms() - blocked;
180        }
181
182        // Sum window intersections with range, minus blocked intersections
183        let mut available: i64 = 0;
184        for w in &self.time_windows {
185            if let Some(dur) = overlap_duration(&range, w) {
186                available += dur;
187            }
188        }
189
190        // Subtract blocked intersections
191        let blocked: i64 = self
192            .blocked_periods
193            .iter()
194            .filter_map(|bp| overlap_duration(&range, bp))
195            .sum();
196
197        (available - blocked).max(0)
198    }
199}
200
201/// Computes overlap duration between two time windows.
202fn overlap_duration(a: &TimeWindow, b: &TimeWindow) -> Option<i64> {
203    let start = a.start_ms.max(b.start_ms);
204    let end = a.end_ms.min(b.end_ms);
205    if end > start {
206        Some(end - start)
207    } else {
208        None
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_time_window() {
218        let w = TimeWindow::new(100, 200);
219        assert_eq!(w.duration_ms(), 100);
220        assert!(w.contains(100));
221        assert!(w.contains(199));
222        assert!(!w.contains(200)); // exclusive end
223        assert!(!w.contains(50));
224    }
225
226    #[test]
227    fn test_time_window_overlap() {
228        let a = TimeWindow::new(0, 100);
229        let b = TimeWindow::new(50, 150);
230        assert!(a.overlaps(&b));
231        assert!(b.overlaps(&a));
232
233        let c = TimeWindow::new(100, 200); // touching but not overlapping
234        assert!(!a.overlaps(&c));
235    }
236
237    #[test]
238    fn test_calendar_always_available() {
239        let cal = Calendar::always_available("cal1");
240        assert!(cal.is_working_time(0));
241        assert!(cal.is_working_time(1_000_000));
242    }
243
244    #[test]
245    fn test_calendar_with_windows() {
246        let cal = Calendar::new("shifts")
247            .with_window(0, 8_000) // 0-8s: day shift
248            .with_window(16_000, 24_000); // 16-24s: night shift
249
250        assert!(cal.is_working_time(4_000)); // During day shift
251        assert!(!cal.is_working_time(10_000)); // Between shifts
252        assert!(cal.is_working_time(20_000)); // During night shift
253    }
254
255    #[test]
256    fn test_calendar_blocked_overrides() {
257        let cal = Calendar::new("cal")
258            .with_window(0, 100_000)
259            .with_blocked(50_000, 60_000); // Maintenance window
260
261        assert!(cal.is_working_time(40_000)); // Before maintenance
262        assert!(!cal.is_working_time(55_000)); // During maintenance
263        assert!(cal.is_working_time(70_000)); // After maintenance
264    }
265
266    #[test]
267    fn test_next_available_time() {
268        let cal = Calendar::new("shifts")
269            .with_window(0, 8_000)
270            .with_window(16_000, 24_000);
271
272        assert_eq!(cal.next_available_time(4_000), Some(4_000)); // Already available
273        assert_eq!(cal.next_available_time(10_000), Some(16_000)); // Wait for next shift
274    }
275
276    #[test]
277    fn test_next_available_blocked() {
278        let cal = Calendar::always_available("cal").with_blocked(50_000, 60_000);
279
280        assert_eq!(cal.next_available_time(40_000), Some(40_000));
281        assert_eq!(cal.next_available_time(55_000), Some(60_000));
282    }
283
284    #[test]
285    fn test_available_time_in_range() {
286        let cal = Calendar::new("cal")
287            .with_window(0, 100_000)
288            .with_blocked(40_000, 60_000); // 20s blocked
289
290        let avail = cal.available_time_in_range(0, 100_000);
291        assert_eq!(avail, 80_000); // 100k - 20k blocked
292
293        let avail2 = cal.available_time_in_range(50_000, 70_000);
294        assert_eq!(avail2, 10_000); // 60k-70k (50k-60k blocked)
295    }
296
297    #[test]
298    fn test_available_time_no_windows() {
299        let cal = Calendar::always_available("cal").with_blocked(20_000, 30_000);
300
301        let avail = cal.available_time_in_range(0, 50_000);
302        assert_eq!(avail, 40_000); // 50k - 10k blocked
303    }
304}