domain/daycare/attendance.rs
1//! Daycare recurring-attendance materialization for predictable front-desk work queues.
2//!
3//! ```
4//! use chrono::{NaiveDate, Weekday};
5//! use domain::daycare;
6//!
7//! let recurrence = daycare::attendance::Recurrence::new(
8//! daycare::attendance::DateRange::new(
9//! NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(),
10//! NaiveDate::from_ymd_opt(2026, 6, 19).unwrap(),
11//! )
12//! .unwrap(),
13//! daycare::attendance::Days::try_new(vec![Weekday::Mon, Weekday::Wed, Weekday::Fri]).unwrap(),
14//! );
15//!
16//! let visits = daycare::attendance::Materializer.materialize(&recurrence, &[
17//! NaiveDate::from_ymd_opt(2026, 6, 17).unwrap(),
18//! ]);
19//! assert_eq!(visits.len(), 2);
20//! ```
21
22use super::*;
23use chrono::Datelike;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26/// Inclusive date range for recurring daycare attendance materialization.
27pub struct DateRange {
28 start: NaiveDate,
29 end: NaiveDate,
30}
31
32impl DateRange {
33 /// Creates an attendance date range, rejecting ranges whose end precedes the start.
34 pub fn new(start: NaiveDate, end: NaiveDate) -> std::result::Result<Self, DateRangeError> {
35 if end < start {
36 return Err(DateRangeError::EndBeforeStart);
37 }
38 Ok(Self { start, end })
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
43/// Validation errors for recurring daycare attendance date ranges.
44pub enum DateRangeError {
45 #[error("daycare attendance recurrence end date must not precede start date")]
46 /// The recurrence end date was earlier than the start date.
47 EndBeforeStart,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51/// Weekdays on which a recurring daycare package or reservation should materialize visits.
52pub struct Days(Vec<chrono::Weekday>);
53
54impl Days {
55 /// Creates a non-empty weekday set for recurring daycare attendance.
56 pub fn try_new(days: Vec<chrono::Weekday>) -> std::result::Result<Self, DaysError> {
57 if days.is_empty() {
58 return Err(DaysError::Empty);
59 }
60 Ok(Self(days))
61 }
62
63 /// Reports whether the recurrence includes the supplied weekday.
64 pub fn contains(&self, day: chrono::Weekday) -> bool {
65 self.0.contains(&day)
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
70/// Validation errors for recurring daycare attendance weekdays.
71pub enum DaysError {
72 #[error("daycare attendance recurrence requires at least one weekday")]
73 /// No weekdays were supplied, so no attendance could be materialized.
74 Empty,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78/// Recurring daycare attendance rule used to pre-build front-desk work queues.
79pub struct Recurrence {
80 /// Inclusive date window in which visits may be materialized.
81 pub date_range: DateRange,
82 /// Weekdays that should generate visits inside the date range.
83 pub days: Days,
84}
85
86impl Recurrence {
87 /// Creates an attendance date range, rejecting ranges whose end precedes the start.
88 pub const fn new(date_range: DateRange, days: Days) -> Self {
89 Self { date_range, days }
90 }
91}
92
93#[derive(Debug, Clone, Default)]
94/// Service that expands recurrence rules into concrete daycare visit dates.
95pub struct Materializer;
96
97impl Materializer {
98 /// Materializes concrete visit dates while excluding source-system exceptions and closures.
99 pub fn materialize(&self, recurrence: &Recurrence, exceptions: &[NaiveDate]) -> Vec<NaiveDate> {
100 let mut dates = Vec::new();
101 let mut current = recurrence.date_range.start;
102 while current <= recurrence.date_range.end {
103 if recurrence.days.contains(current.weekday()) && !exceptions.contains(¤t) {
104 dates.push(current);
105 }
106 current = current
107 .succ_opt()
108 .expect("bounded date range should have next date");
109 }
110 dates
111 }
112}