Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_datime/src/calendar/holiday_engines.rs

21.9 KiB, 55 runs

created by r1870400018:8388, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Advanced holiday calculation engines for different jurisdictions.
2//!
3//! This module provides sophisticated holiday engines that can automatically
4//! calculate complex holidays like Easter, Thanksgiving, and other date-based
5//! holidays for various countries and jurisdictions.
6//!
7//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
8//! Anthropic Claude
9
10use crate::{
11 calendar::CalendarDate,
12 constant::{DayOfWeek, MonthOfYear},
13 time::CalClockZone,
14};
15use oxedyne_fe2o3_core::prelude::*;
16use std::collections::HashMap;
17
18#[derive(Clone, Debug, PartialEq)]
19pub enum HolidayType {
20 Fixed { month: u8, day: u8 },
21 EasterBased { offset_days: i32 },
22 Relative { month: u8, weekday: DayOfWeek, occurrence: i32 },
23 LastWeekday { month: u8, weekday: DayOfWeek },
24 FirstWeekdayAfter { month: u8, day: u8, weekday: DayOfWeek },
25}
26
27#[derive(Clone, Debug)]
28pub struct HolidayDefinition {
29 pub name: String,
30 pub holiday_type: HolidayType,
31 pub weekend_adjustment: WeekendAdjustment,
32 pub description: Option<String>,
33}
34
35/// How a holiday landing on a weekend is observed.
36#[derive(Clone, Debug, PartialEq)]
37pub enum WeekendAdjustment {
38 None,
39 Nearest, // Saturday to Friday, Sunday to Monday
40 Monday, // the following Monday, from either weekend day
41 Friday, // the preceding Friday, from either weekend day
42 Custom { saturday_shift: i32, sunday_shift: i32 },
43}
44
45#[derive(Clone, Debug)]
46pub struct HolidayEngine {
47 jurisdiction: String,
48 holidays: HashMap<String, HolidayDefinition>,
49 default_weekend_adjustment: WeekendAdjustment,
50}
51
52impl HolidayEngine {
53 pub fn new<S: Into<String>>(jurisdiction: S) -> Self {
54 Self {
55 jurisdiction: jurisdiction.into(),
56 holidays: HashMap::new(),
57 default_weekend_adjustment: WeekendAdjustment::None,
58 }
59 }
60
61 pub fn with_default_weekend_adjustment(mut self, adjustment: WeekendAdjustment) -> Self {
62 self.default_weekend_adjustment = adjustment;
63 self
64 }
65
66 pub fn add_holiday(mut self, holiday: HolidayDefinition) -> Self {
67 self.holidays.insert(holiday.name.clone(), holiday);
68 self
69 }
70
71 pub fn calculate_holidays(&self, year: i32, zone: CalClockZone) -> Outcome<Vec<(String, CalendarDate)>> {
72 let mut holidays = Vec::new();
73
74 for (name, definition) in &self.holidays {
75 let calculated_date = res!(self.calculate_holiday(definition, year, zone.clone()));
76 holidays.push((name.clone(), calculated_date));
77 }
78
79 // Sort by date.
80 holidays.sort_by(|a, b| a.1.cmp(&b.1));
81 Ok(holidays)
82 }
83
84 pub fn calculate_holiday(&self, definition: &HolidayDefinition, year: i32, zone: CalClockZone) -> Outcome<CalendarDate> {
85 let base_date = match &definition.holiday_type {
86 HolidayType::Fixed { month, day } => {
87 let month_enum = res!(MonthOfYear::from_number(*month));
88 res!(CalendarDate::from_ymd(year, month_enum, *day, zone.clone()))
89 },
90 HolidayType::EasterBased { offset_days } => {
91 let easter = res!(Self::calculate_easter(year, zone.clone()));
92 res!(easter.add_days(*offset_days))
93 },
94 HolidayType::Relative { month, weekday, occurrence } => {
95 res!(Self::calculate_nth_weekday(year, *month, *weekday, *occurrence, zone.clone()))
96 },
97 HolidayType::LastWeekday { month, weekday } => {
98 res!(Self::calculate_last_weekday(year, *month, *weekday, zone.clone()))
99 },
100 HolidayType::FirstWeekdayAfter { month, day, weekday } => {
101 res!(Self::calculate_first_weekday_after(year, *month, *day, *weekday, zone.clone()))
102 },
103 };
104
105 // Apply weekend adjustment.
106 self.apply_weekend_adjustment(&base_date, &definition.weekend_adjustment)
107 }
108
109 fn apply_weekend_adjustment(&self, date: &CalendarDate, adjustment: &WeekendAdjustment) -> Outcome<CalendarDate> {
110 let dow = date.day_of_week();
111
112 match adjustment {
113 WeekendAdjustment::None => Ok(date.clone()),
114 WeekendAdjustment::Nearest => {
115 match dow {
116 DayOfWeek::Saturday => date.add_days(-1), // Friday
117 DayOfWeek::Sunday => date.add_days(1), // Monday
118 _ => Ok(date.clone()),
119 }
120 },
121 WeekendAdjustment::Monday => {
122 match dow {
123 DayOfWeek::Saturday | DayOfWeek::Sunday => {
124 // Find next Monday.
125 let days_to_monday = match dow {
126 DayOfWeek::Saturday => 2,
127 DayOfWeek::Sunday => 1,
128 _ => 0,
129 };
130 date.add_days(days_to_monday)
131 },
132 _ => Ok(date.clone()),
133 }
134 },
135 WeekendAdjustment::Friday => {
136 match dow {
137 DayOfWeek::Saturday | DayOfWeek::Sunday => {
138 // Find previous Friday.
139 let days_to_friday = match dow {
140 DayOfWeek::Saturday => -1,
141 DayOfWeek::Sunday => -2,
142 _ => 0,
143 };
144 date.add_days(days_to_friday)
145 },
146 _ => Ok(date.clone()),
147 }
148 },
149 WeekendAdjustment::Custom { saturday_shift, sunday_shift } => {
150 match dow {
151 DayOfWeek::Saturday => date.add_days(*saturday_shift),
152 DayOfWeek::Sunday => date.add_days(*sunday_shift),
153 _ => Ok(date.clone()),
154 }
155 },
156 }
157 }
158
159 /// The Western computus, as used by most Western churches.
160 pub fn calculate_easter(year: i32, zone: CalClockZone) -> Outcome<CalendarDate> {
161 // Use the algorithm from Oudin (1940) for Gregorian calendar Easter.
162 let a = year % 19;
163 let b = year / 100;
164 let c = year % 100;
165 let d = b / 4;
166 let e = b % 4;
167 let f = (b + 8) / 25;
168 let g = (b - f + 1) / 3;
169 let h = (19 * a + b - d - g + 15) % 30;
170 let i = c / 4;
171 let k = c % 4;
172 let l = (32 + 2 * e + 2 * i - h - k) % 7;
173 let m = (a + 11 * h + 22 * l) / 451;
174 let month = (h + l - 7 * m + 114) / 31;
175 let day = ((h + l - 7 * m + 114) % 31) + 1;
176
177 let month_enum = res!(MonthOfYear::from_number(month as u8));
178 CalendarDate::from_ymd(year, month_enum, day as u8, zone)
179 }
180
181 fn calculate_nth_weekday(year: i32, month: u8, weekday: DayOfWeek, occurrence: i32, zone: CalClockZone) -> Outcome<CalendarDate> {
182 let month_enum = res!(MonthOfYear::from_number(month));
183 let first_of_month = res!(CalendarDate::from_ymd(year, month_enum, 1, zone.clone()));
184
185 // Find the first occurrence of the target weekday.
186 let first_weekday_day = {
187 let first_dow = first_of_month.day_of_week();
188 let target_dow_num = weekday.of() as i32;
189 let first_dow_num = first_dow.of() as i32;
190
191 let days_to_add = if target_dow_num >= first_dow_num {
192 target_dow_num - first_dow_num
193 } else {
194 7 - (first_dow_num - target_dow_num)
195 };
196 1 + days_to_add
197 };
198
199 // Calculate the nth occurrence.
200 let target_day = first_weekday_day + (occurrence - 1) * 7;
201
202 // Verify the day is within the month.
203 let days_in_month = month_enum.days_in_month(year);
204 if target_day > days_in_month as i32 {
205 return Err(err!("No {}th {} in {} {}", occurrence, weekday, month_enum, year; Invalid, Input));
206 }
207
208 CalendarDate::from_ymd(year, month_enum, target_day as u8, zone)
209 }
210
211 fn calculate_last_weekday(year: i32, month: u8, weekday: DayOfWeek, zone: CalClockZone) -> Outcome<CalendarDate> {
212 let month_enum = res!(MonthOfYear::from_number(month));
213 let days_in_month = month_enum.days_in_month(year);
214
215 // Start from the last day and work backwards.
216 for day in (1..=days_in_month).rev() {
217 let candidate = res!(CalendarDate::from_ymd(year, month_enum, day, zone.clone()));
218 if candidate.day_of_week() == weekday {
219 return Ok(candidate);
220 }
221 }
222
223 Err(err!("No {} found in {} {}", weekday, month_enum, year; Invalid, Input))
224 }
225
226 fn calculate_first_weekday_after(year: i32, month: u8, day: u8, weekday: DayOfWeek, zone: CalClockZone) -> Outcome<CalendarDate> {
227 let month_enum = res!(MonthOfYear::from_number(month));
228 let start_date = res!(CalendarDate::from_ymd(year, month_enum, day, zone.clone()));
229
230 // Search forward for the target weekday.
231 let mut current = res!(start_date.add_days(1)); // Start from the day after.
232 for _ in 0..7 { // Maximum 7 days to find any weekday.
233 if current.day_of_week() == weekday {
234 return Ok(current);
235 }
236 current = res!(current.add_days(1));
237 }
238
239 Err(err!("Could not find {} after {} {} {}", weekday, month_enum, day, year; Invalid, Input))
240 }
241
242 pub fn is_holiday(&self, date: &CalendarDate) -> Outcome<bool> {
243 let year = date.year();
244 let holidays = res!(self.calculate_holidays(year, date.zone().clone()));
245
246 for (_, holiday_date) in holidays {
247 if holiday_date == *date {
248 return Ok(true);
249 }
250 }
251
252 Ok(false)
253 }
254
255 pub fn jurisdiction(&self) -> &str {
256 &self.jurisdiction
257 }
258
259 pub fn holiday_names(&self) -> Vec<String> {
260 self.holidays.keys().cloned().collect()
261 }
262}
263
264// Pre-built engines for common jurisdictions.
265impl HolidayEngine {
266 pub fn us_federal() -> Self {
267 Self::new("United States Federal")
268 .with_default_weekend_adjustment(WeekendAdjustment::Nearest)
269 .add_holiday(HolidayDefinition {
270 name: "New Year's Day".to_string(),
271 holiday_type: HolidayType::Fixed { month: 1, day: 1 },
272 weekend_adjustment: WeekendAdjustment::Nearest,
273 description: Some("January 1st".to_string()),
274 })
275 .add_holiday(HolidayDefinition {
276 name: "Martin Luther King Jr. Day".to_string(),
277 holiday_type: HolidayType::Relative {
278 month: 1,
279 weekday: DayOfWeek::Monday,
280 occurrence: 3
281 },
282 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
283 description: Some("Third Monday in January".to_string()),
284 })
285 .add_holiday(HolidayDefinition {
286 name: "Presidents' Day".to_string(),
287 holiday_type: HolidayType::Relative {
288 month: 2,
289 weekday: DayOfWeek::Monday,
290 occurrence: 3
291 },
292 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
293 description: Some("Third Monday in February".to_string()),
294 })
295 .add_holiday(HolidayDefinition {
296 name: "Memorial Day".to_string(),
297 holiday_type: HolidayType::LastWeekday {
298 month: 5,
299 weekday: DayOfWeek::Monday
300 },
301 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
302 description: Some("Last Monday in May".to_string()),
303 })
304 .add_holiday(HolidayDefinition {
305 name: "Independence Day".to_string(),
306 holiday_type: HolidayType::Fixed { month: 7, day: 4 },
307 weekend_adjustment: WeekendAdjustment::Nearest,
308 description: Some("July 4th".to_string()),
309 })
310 .add_holiday(HolidayDefinition {
311 name: "Labour Day".to_string(),
312 holiday_type: HolidayType::Relative {
313 month: 9,
314 weekday: DayOfWeek::Monday,
315 occurrence: 1
316 },
317 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
318 description: Some("First Monday in September".to_string()),
319 })
320 .add_holiday(HolidayDefinition {
321 name: "Columbus Day".to_string(),
322 holiday_type: HolidayType::Relative {
323 month: 10,
324 weekday: DayOfWeek::Monday,
325 occurrence: 2
326 },
327 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
328 description: Some("Second Monday in October".to_string()),
329 })
330 .add_holiday(HolidayDefinition {
331 name: "Veterans Day".to_string(),
332 holiday_type: HolidayType::Fixed { month: 11, day: 11 },
333 weekend_adjustment: WeekendAdjustment::Nearest,
334 description: Some("November 11th".to_string()),
335 })
336 .add_holiday(HolidayDefinition {
337 name: "Thanksgiving".to_string(),
338 holiday_type: HolidayType::Relative {
339 month: 11,
340 weekday: DayOfWeek::Thursday,
341 occurrence: 4
342 },
343 weekend_adjustment: WeekendAdjustment::None, // Always on Thursday.
344 description: Some("Fourth Thursday in November".to_string()),
345 })
346 .add_holiday(HolidayDefinition {
347 name: "Christmas Day".to_string(),
348 holiday_type: HolidayType::Fixed { month: 12, day: 25 },
349 weekend_adjustment: WeekendAdjustment::Nearest,
350 description: Some("December 25th".to_string()),
351 })
352 }
353
354 pub fn uk() -> Self {
355 Self::new("United Kingdom")
356 .with_default_weekend_adjustment(WeekendAdjustment::Monday)
357 .add_holiday(HolidayDefinition {
358 name: "New Year's Day".to_string(),
359 holiday_type: HolidayType::Fixed { month: 1, day: 1 },
360 weekend_adjustment: WeekendAdjustment::Monday,
361 description: Some("January 1st".to_string()),
362 })
363 .add_holiday(HolidayDefinition {
364 name: "Good Friday".to_string(),
365 holiday_type: HolidayType::EasterBased { offset_days: -2 },
366 weekend_adjustment: WeekendAdjustment::None, // Always on Friday.
367 description: Some("Friday before Easter".to_string()),
368 })
369 .add_holiday(HolidayDefinition {
370 name: "Easter Monday".to_string(),
371 holiday_type: HolidayType::EasterBased { offset_days: 1 },
372 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
373 description: Some("Monday after Easter".to_string()),
374 })
375 .add_holiday(HolidayDefinition {
376 name: "Early May Bank Holiday".to_string(),
377 holiday_type: HolidayType::Relative {
378 month: 5,
379 weekday: DayOfWeek::Monday,
380 occurrence: 1
381 },
382 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
383 description: Some("First Monday in May".to_string()),
384 })
385 .add_holiday(HolidayDefinition {
386 name: "Spring Bank Holiday".to_string(),
387 holiday_type: HolidayType::LastWeekday {
388 month: 5,
389 weekday: DayOfWeek::Monday
390 },
391 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
392 description: Some("Last Monday in May".to_string()),
393 })
394 .add_holiday(HolidayDefinition {
395 name: "Summer Bank Holiday".to_string(),
396 holiday_type: HolidayType::LastWeekday {
397 month: 8,
398 weekday: DayOfWeek::Monday
399 },
400 weekend_adjustment: WeekendAdjustment::None, // Always on Monday.
401 description: Some("Last Monday in August".to_string()),
402 })
403 .add_holiday(HolidayDefinition {
404 name: "Christmas Day".to_string(),
405 holiday_type: HolidayType::Fixed { month: 12, day: 25 },
406 weekend_adjustment: WeekendAdjustment::Monday,
407 description: Some("December 25th".to_string()),
408 })
409 .add_holiday(HolidayDefinition {
410 name: "Boxing Day".to_string(),
411 holiday_type: HolidayType::Fixed { month: 12, day: 26 },
412 weekend_adjustment: WeekendAdjustment::Monday,
413 description: Some("December 26th".to_string()),
414 })
415 }
416
417 pub fn ecb() -> Self {
418 Self::new("European Central Bank")
419 .with_default_weekend_adjustment(WeekendAdjustment::None)
420 .add_holiday(HolidayDefinition {
421 name: "New Year's Day".to_string(),
422 holiday_type: HolidayType::Fixed { month: 1, day: 1 },
423 weekend_adjustment: WeekendAdjustment::None,
424 description: Some("January 1st".to_string()),
425 })
426 .add_holiday(HolidayDefinition {
427 name: "Good Friday".to_string(),
428 holiday_type: HolidayType::EasterBased { offset_days: -2 },
429 weekend_adjustment: WeekendAdjustment::None,
430 description: Some("Friday before Easter".to_string()),
431 })
432 .add_holiday(HolidayDefinition {
433 name: "Easter Monday".to_string(),
434 holiday_type: HolidayType::EasterBased { offset_days: 1 },
435 weekend_adjustment: WeekendAdjustment::None,
436 description: Some("Monday after Easter".to_string()),
437 })
438 .add_holiday(HolidayDefinition {
439 name: "Labour Day".to_string(),
440 holiday_type: HolidayType::Fixed { month: 5, day: 1 },
441 weekend_adjustment: WeekendAdjustment::None,
442 description: Some("May 1st".to_string()),
443 })
444 .add_holiday(HolidayDefinition {
445 name: "Christmas Day".to_string(),
446 holiday_type: HolidayType::Fixed { month: 12, day: 25 },
447 weekend_adjustment: WeekendAdjustment::None,
448 description: Some("December 25th".to_string()),
449 })
450 .add_holiday(HolidayDefinition {
451 name: "Boxing Day".to_string(),
452 holiday_type: HolidayType::Fixed { month: 12, day: 26 },
453 weekend_adjustment: WeekendAdjustment::None,
454 description: Some("December 26th".to_string()),
455 })
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::time::CalClockZone;
463
464 #[test]
465 fn test_easter_calculation() {
466 let zone = CalClockZone::utc();
467
468 // Test known Easter dates.
469 let easter_2024 = HolidayEngine::calculate_easter(2024, zone.clone()).unwrap();
470 assert_eq!(easter_2024.month(), 3);
471 assert_eq!(easter_2024.day(), 31);
472
473 let easter_2025 = HolidayEngine::calculate_easter(2025, zone.clone()).unwrap();
474 assert_eq!(easter_2025.month(), 4);
475 assert_eq!(easter_2025.day(), 20);
476 }
477
478 #[test]
479 fn test_us_federal_holidays() {
480 let zone = CalClockZone::utc();
481 let engine = HolidayEngine::us_federal();
482 let holidays_2024 = engine.calculate_holidays(2024, zone).unwrap();
483
484 // Should have 10 federal holidays.
485 assert_eq!(holidays_2024.len(), 10);
486
487 // Check a few specific holidays.
488 let new_years = holidays_2024.iter().find(|(name, _)| name == "New Year's Day").unwrap();
489 assert_eq!(new_years.1.month(), 1);
490 assert_eq!(new_years.1.day(), 1);
491
492 let thanksgiving = holidays_2024.iter().find(|(name, _)| name == "Thanksgiving").unwrap();
493 assert_eq!(thanksgiving.1.month(), 11);
494 assert_eq!(thanksgiving.1.day(), 28); // Fourth Thursday in November 2024.
495 }
496
497 #[test]
498 fn test_weekend_adjustment() {
499 let zone = CalClockZone::utc();
500 let engine = HolidayEngine::us_federal();
501
502 // Test a year where July 4th falls on a weekend.
503 let july_4_2021 = CalendarDate::from_ymd(2021, MonthOfYear::July, 4, zone.clone()).unwrap();
504 assert_eq!(july_4_2021.day_of_week(), DayOfWeek::Sunday);
505
506 let adjusted = engine.apply_weekend_adjustment(&july_4_2021, &WeekendAdjustment::Nearest).unwrap();
507 assert_eq!(adjusted.day(), 5); // Should be observed on Monday.
508 }
509
510 #[test]
511 fn test_uk_holidays() {
512 let zone = CalClockZone::utc();
513 let engine = HolidayEngine::uk();
514 let holidays_2024 = engine.calculate_holidays(2024, zone).unwrap();
515
516 // Should have 8 UK holidays.
517 assert_eq!(holidays_2024.len(), 8);
518
519 // Check Good Friday (Easter-based).
520 let good_friday = holidays_2024.iter().find(|(name, _)| name == "Good Friday").unwrap();
521 assert_eq!(good_friday.1.month(), 3);
522 assert_eq!(good_friday.1.day(), 29); // Good Friday 2024.
523 }
524
525 #[test]
526 fn test_holiday_type_calculations() {
527 let zone = CalClockZone::utc();
528
529 // Test relative calculation (3rd Monday in January).
530 let mlk_day = HolidayEngine::calculate_nth_weekday(2024, 1, DayOfWeek::Monday, 3, zone.clone()).unwrap();
531 assert_eq!(mlk_day.day(), 15); // January 15, 2024.
532
533 // Test last weekday calculation (last Monday in May).
534 let memorial_day = HolidayEngine::calculate_last_weekday(2024, 5, DayOfWeek::Monday, zone.clone()).unwrap();
535 assert_eq!(memorial_day.day(), 27); // May 27, 2024.
536 }
537}