1use crate::raw_date::RawDate;
2use crate::{DayFlags, Resolved, flags_raw};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum WorkWeek {
10 FortyHours,
12
13 ThirtySixHours,
15
16 TwentyFourHours,
18}
19
20impl WorkWeek {
21 #[inline]
22 pub(crate) const fn daily_minutes(self) -> u32 {
23 match self {
24 Self::FortyHours => 8 * 60,
25 Self::ThirtySixHours => 7 * 60 + 12,
26 Self::TwentyFourHours => 4 * 60 + 48,
27 }
28 }
29}
30
31pub(crate) fn non_working_days_between_raw(start: RawDate, end: RawDate) -> Option<Resolved<u32>> {
38 fold_days(start, end, |flags| u32::from(flags.is_day_off()))
39}
40
41pub(crate) fn working_minutes_between_raw(
46 start: RawDate,
47 end: RawDate,
48 week: WorkWeek,
49) -> Option<Resolved<u32>> {
50 fold_days(start, end, |flags| working_minutes_for_day(flags, week))
51}
52
53#[inline]
54fn working_minutes_for_day(flags: DayFlags, week: WorkWeek) -> u32 {
55 if !flags.is_working_day() {
56 return 0;
57 }
58
59 let minutes = week.daily_minutes();
60
61 if flags.is_short_day() {
62 minutes.saturating_sub(60)
63 } else {
64 minutes
65 }
66}
67
68fn fold_days(
69 start: RawDate,
70 end: RawDate,
71 value: impl Fn(DayFlags) -> u32,
72) -> Option<Resolved<u32>> {
73 if !start.is_supported() || !end.is_supported_range_end() || start > end {
74 return None;
75 }
76
77 let mut date = start;
78 let mut total = 0u32;
79 let mut has_predict = false;
80
81 while date < end {
82 let resolved = flags_raw(date);
83
84 has_predict |= resolved.is_predict();
85 total = total.saturating_add(value(resolved.value()));
86 date = date.next_day();
87 }
88
89 if has_predict {
90 Some(Resolved::Predict(total))
91 } else {
92 Some(Resolved::Fact(total))
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn test_working_minutes_for_short_days() {
102 let flags = DayFlags::WORKING_DAY.with(DayFlags::SHORT_DAY);
103
104 assert_eq!(working_minutes_for_day(flags, WorkWeek::FortyHours), 420);
105 assert_eq!(
106 working_minutes_for_day(flags, WorkWeek::ThirtySixHours),
107 372
108 );
109 assert_eq!(
110 working_minutes_for_day(flags, WorkWeek::TwentyFourHours),
111 228
112 );
113 }
114
115 #[test]
116 fn test_year_totals_2003_2026() {
117 for (year, calendar_days, working_days, non_working_days, h40, h36, h24) in [
118 (2003, 365, 250, 115, 1992 * 60, 1792 * 60, 1192 * 60),
119 (
120 2004,
121 366,
122 251,
123 115,
124 2004 * 60,
125 1803 * 60 + 12,
126 1200 * 60 + 48,
127 ),
128 (
129 2005,
130 365,
131 248,
132 117,
133 1981 * 60,
134 1782 * 60 + 36,
135 1187 * 60 + 24,
136 ),
137 (
138 2006,
139 365,
140 248,
141 117,
142 1980 * 60,
143 1781 * 60 + 36,
144 1186 * 60 + 24,
145 ),
146 (
147 2007,
148 365,
149 249,
150 116,
151 1986 * 60,
152 1786 * 60 + 48,
153 1189 * 60 + 12,
154 ),
155 (2008, 366, 250, 116, 1993 * 60, 1793 * 60, 1193 * 60),
156 (
157 2009,
158 365,
159 249,
160 116,
161 1987 * 60,
162 1787 * 60 + 48,
163 1190 * 60 + 12,
164 ),
165 (
166 2010,
167 365,
168 249,
169 116,
170 1987 * 60,
171 1787 * 60 + 48,
172 1190 * 60 + 12,
173 ),
174 (
175 2011,
176 365,
177 248,
178 117,
179 1981 * 60,
180 1782 * 60 + 36,
181 1187 * 60 + 24,
182 ),
183 (
184 2012,
185 366,
186 249,
187 117,
188 1986 * 60,
189 1786 * 60 + 48,
190 1189 * 60 + 12,
191 ),
192 (
193 2013,
194 365,
195 247,
196 118,
197 1970 * 60,
198 1772 * 60 + 24,
199 1179 * 60 + 36,
200 ),
201 (
202 2014,
203 365,
204 247,
205 118,
206 1970 * 60,
207 1772 * 60 + 24,
208 1179 * 60 + 36,
209 ),
210 (
211 2015,
212 365,
213 247,
214 118,
215 1971 * 60,
216 1773 * 60 + 24,
217 1180 * 60 + 36,
218 ),
219 (
220 2016,
221 366,
222 247,
223 119,
224 1974 * 60,
225 1776 * 60 + 24,
226 1183 * 60 + 36,
227 ),
228 (
229 2017,
230 365,
231 247,
232 118,
233 1973 * 60,
234 1775 * 60 + 24,
235 1182 * 60 + 36,
236 ),
237 (
238 2018,
239 365,
240 247,
241 118,
242 1970 * 60,
243 1772 * 60 + 24,
244 1179 * 60 + 36,
245 ),
246 (
247 2019,
248 365,
249 247,
250 118,
251 1970 * 60,
252 1772 * 60 + 24,
253 1179 * 60 + 36,
254 ),
255 (
256 2020,
257 366,
258 248,
259 118,
260 1979 * 60,
261 1780 * 60 + 36,
262 1185 * 60 + 24,
263 ),
264 (
265 2021,
266 365,
267 247,
268 118,
269 1972 * 60,
270 1774 * 60 + 24,
271 1181 * 60 + 36,
272 ),
273 (
274 2022,
275 365,
276 247,
277 118,
278 1973 * 60,
279 1775 * 60 + 24,
280 1182 * 60 + 36,
281 ),
282 (
283 2023,
284 365,
285 247,
286 118,
287 1973 * 60,
288 1775 * 60 + 24,
289 1182 * 60 + 36,
290 ),
291 (
292 2024,
293 366,
294 248,
295 118,
296 1979 * 60,
297 1780 * 60 + 36,
298 1185 * 60 + 24,
299 ),
300 (
301 2025,
302 365,
303 247,
304 118,
305 1972 * 60,
306 1774 * 60 + 24,
307 1181 * 60 + 36,
308 ),
309 (
310 2026,
311 365,
312 247,
313 118,
314 1972 * 60,
315 1774 * 60 + 24,
316 1181 * 60 + 36,
317 ),
318 ] {
319 let start = RawDate::from_ymd(year, 1, 1).expect("valid start date");
320 let end = RawDate::from_ymd(year + 1, 1, 1).expect("valid end date");
321
322 let non_working = non_working_days_between_raw(start, end).expect("valid range");
323 assert_eq!(non_working, Resolved::Fact(non_working_days), "{year}");
324 assert_eq!(calendar_days - non_working.value(), working_days, "{year}");
325
326 assert_eq!(
327 working_minutes_between_raw(start, end, WorkWeek::FortyHours),
328 Some(Resolved::Fact(h40)),
329 "{year}"
330 );
331 assert_eq!(
332 working_minutes_between_raw(start, end, WorkWeek::ThirtySixHours),
333 Some(Resolved::Fact(h36)),
334 "{year}"
335 );
336 assert_eq!(
337 working_minutes_between_raw(start, end, WorkWeek::TwentyFourHours),
338 Some(Resolved::Fact(h24)),
339 "{year}"
340 );
341 }
342 }
343
344 #[test]
345 fn test_empty_range_is_fact_zero() {
346 let date = RawDate::from_ymd(2026, 1, 1).expect("valid date");
347
348 assert_eq!(
349 non_working_days_between_raw(date, date),
350 Some(Resolved::Fact(0))
351 );
352 assert_eq!(
353 working_minutes_between_raw(date, date, WorkWeek::FortyHours),
354 Some(Resolved::Fact(0))
355 );
356 }
357
358 #[test]
359 fn test_reversed_range_is_none() {
360 let start = RawDate::from_ymd(2026, 1, 2).expect("valid start date");
361 let end = RawDate::from_ymd(2026, 1, 1).expect("valid end date");
362
363 assert_eq!(non_working_days_between_raw(start, end), None);
364 assert_eq!(
365 working_minutes_between_raw(start, end, WorkWeek::FortyHours),
366 None
367 );
368 }
369
370 #[test]
371 fn test_mixed_fact_predict_range_is_predict() {
372 let start = RawDate::from_ymd(2026, 12, 31).expect("valid start date");
373 let end = RawDate::from_ymd(2027, 1, 2).expect("valid end date");
374
375 assert_eq!(
376 non_working_days_between_raw(start, end),
377 Some(Resolved::Predict(2))
378 );
379 }
380
381 #[test]
382 fn test_range_can_include_last_supported_day() {
383 let start = RawDate::from_ymd(crate::MAX_YEAR, 12, 31).expect("valid start date");
384 let end = RawDate::from_ymd_unchecked(crate::MAX_YEAR + 1, 1, 1);
385
386 assert!(non_working_days_between_raw(start, end).is_some());
387 assert!(working_minutes_between_raw(start, end, WorkWeek::FortyHours).is_some());
388 }
389}