Skip to main content

holiday_engine/
lib.rs

1//! # holiday-engine
2//!
3//! **Chinese statutory holiday data** for 2026 (officially published) and
4//! 2027 (estimated based on lunar calendar).
5//!
6//! Covers all 7 Chinese statutory holidays plus their adjusted work days (調休).
7//! Updated annually when the State Council releases next year's schedule.
8//!
9//! ## The data
10//!
11//! | Holiday | 2026 Range | Days | Notes |
12//! |---------|-----------|------|-------|
13//! | 元旦 New Year | Jan 1 | 1 | |
14//! | 春节 Spring Festival | Feb 15-21 | 7 | +2 adjusted work days |
15//! | 清明节 Qingming | Apr 5-6 | 2 | |
16//! | 劳动节 Labour Day | May 1-5 | 5 | +1 adjusted |
17//! | 端午节 Dragon Boat | Jun 19-21 | 3 | |
18//! | 中秋节 Mid-Autumn | Sep 25-27* | 3 | Sep 27 overwritten by National Day adjustment |
19//! | 国庆节 National Day | Oct 1-7 | 7 | +2 adjusted |
20//!
21//! ## Quick start
22//!
23//! ```rust
24//! use holiday_engine::{get_china_holidays, is_naturally_off};
25//! use chrono::NaiveDate;
26//!
27//! let holidays = get_china_holidays();
28//! let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
29//! assert!(is_naturally_off(date, &holidays));
30//!
31//! // Feb 14, 2026 is a Saturday but an adjusted WORK day
32//! let work_date = NaiveDate::from_ymd_opt(2026, 2, 14).unwrap();
33//! assert!(!is_naturally_off(work_date, &holidays));
34//! ```
35
36use chrono::{Datelike, NaiveDate, Weekday};
37use std::collections::HashMap;
38
39/// Information about a holiday or adjusted work day.
40#[derive(Debug, Clone)]
41pub struct HolidayInfo {
42    pub date: NaiveDate,
43    /// Holiday name. Contains `[待确认]` for 2027 estimated entries.
44    pub name: &'static str,
45    /// `true` = statutory holiday (day off), `false` = adjusted work day (補班).
46    pub is_holiday: bool,
47    /// `true` = officially confirmed by State Council, `false` = estimated.
48    pub is_confirmed: bool,
49}
50
51/// Get all Chinese statutory holidays and adjusted work days.
52///
53/// Returns a `HashMap<NaiveDate, HolidayInfo>` covering:
54/// - **2026**: Official data from 国办发明电〔2025〕
55/// - **2027**: Estimated based on lunar calendar (marked `[待确认]`)
56///
57/// # Duplicate dates
58///
59/// Some dates appear in two different holiday definitions (e.g. Sep 27
60/// is both Mid-Autumn holiday AND National Day adjusted work day).
61/// The **last insert wins**, which matches Flutter/Android behavior.
62///
63/// ```rust
64/// use holiday_engine::get_china_holidays;
65/// use chrono::NaiveDate;
66///
67/// let holidays = get_china_holidays();
68///
69/// // New Year's Day
70/// let d = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
71/// let info = holidays.get(&d).unwrap();
72/// assert!(info.is_holiday);
73/// assert!(info.is_confirmed);
74/// assert_eq!(info.name, "元旦");
75/// ```
76pub fn get_china_holidays() -> HashMap<NaiveDate, HolidayInfo> {
77    let mut holidays: Vec<HolidayInfo> = Vec::new();
78
79    // ═══════════════════════════════════════════
80    // 2026 Official Holidays
81    // ═══════════════════════════════════════════
82
83    holidays.push(HolidayInfo {
84        date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
85        name: "元旦", is_holiday: true, is_confirmed: true,
86    });
87
88    for d in 0..7 {
89        holidays.push(HolidayInfo {
90            date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap() + chrono::Duration::days(d),
91            name: "春节", is_holiday: true, is_confirmed: true,
92        });
93    }
94    holidays.push(HolidayInfo {
95        date: NaiveDate::from_ymd_opt(2026, 2, 14).unwrap(),
96        name: "春节调休", is_holiday: false, is_confirmed: true,
97    });
98    holidays.push(HolidayInfo {
99        date: NaiveDate::from_ymd_opt(2026, 2, 28).unwrap(),
100        name: "春节调休", is_holiday: false, is_confirmed: true,
101    });
102
103    holidays.push(HolidayInfo {
104        date: NaiveDate::from_ymd_opt(2026, 4, 5).unwrap(),
105        name: "清明节", is_holiday: true, is_confirmed: true,
106    });
107    holidays.push(HolidayInfo {
108        date: NaiveDate::from_ymd_opt(2026, 4, 6).unwrap(),
109        name: "清明节", is_holiday: true, is_confirmed: true,
110    });
111
112    for d in 0..5 {
113        holidays.push(HolidayInfo {
114            date: NaiveDate::from_ymd_opt(2026, 5, 1).unwrap() + chrono::Duration::days(d),
115            name: "劳动节", is_holiday: true, is_confirmed: true,
116        });
117    }
118    holidays.push(HolidayInfo {
119        date: NaiveDate::from_ymd_opt(2026, 5, 9).unwrap(),
120        name: "劳动节调休", is_holiday: false, is_confirmed: true,
121    });
122
123    for d in 0..3 {
124        holidays.push(HolidayInfo {
125            date: NaiveDate::from_ymd_opt(2026, 6, 19).unwrap() + chrono::Duration::days(d),
126            name: "端午节", is_holiday: true, is_confirmed: true,
127        });
128    }
129
130    for d in 0..3 {
131        holidays.push(HolidayInfo {
132            date: NaiveDate::from_ymd_opt(2026, 9, 25).unwrap() + chrono::Duration::days(d),
133            name: "中秋节", is_holiday: true, is_confirmed: true,
134        });
135    }
136
137    for d in 0..7 {
138        holidays.push(HolidayInfo {
139            date: NaiveDate::from_ymd_opt(2026, 10, 1).unwrap() + chrono::Duration::days(d),
140            name: "国庆节", is_holiday: true, is_confirmed: true,
141        });
142    }
143    holidays.push(HolidayInfo {
144        date: NaiveDate::from_ymd_opt(2026, 9, 27).unwrap(),
145        name: "国庆节调休", is_holiday: false, is_confirmed: true,
146    });
147    holidays.push(HolidayInfo {
148        date: NaiveDate::from_ymd_opt(2026, 10, 10).unwrap(),
149        name: "国庆节调休", is_holiday: false, is_confirmed: true,
150    });
151
152    // ═══════════════════════════════════════════
153    // 2027 Estimated (based on lunar calendar)
154    // ═══════════════════════════════════════════
155
156    for d in 0..3 {
157        holidays.push(HolidayInfo {
158            date: NaiveDate::from_ymd_opt(2027, 1, 1).unwrap() + chrono::Duration::days(d),
159            name: "元旦[待确认]", is_holiday: true, is_confirmed: false,
160        });
161    }
162
163    for d in 0..7 {
164        holidays.push(HolidayInfo {
165            date: NaiveDate::from_ymd_opt(2027, 2, 5).unwrap() + chrono::Duration::days(d),
166            name: "春节[待确认]", is_holiday: true, is_confirmed: false,
167        });
168    }
169    holidays.push(HolidayInfo {
170        date: NaiveDate::from_ymd_opt(2027, 1, 31).unwrap(),
171        name: "春节调休[待确认]", is_holiday: false, is_confirmed: false,
172    });
173    holidays.push(HolidayInfo {
174        date: NaiveDate::from_ymd_opt(2027, 2, 13).unwrap(),
175        name: "春节调休[待确认]", is_holiday: false, is_confirmed: false,
176    });
177
178    holidays.push(HolidayInfo {
179        date: NaiveDate::from_ymd_opt(2027, 4, 5).unwrap(),
180        name: "清明节[待确认]", is_holiday: true, is_confirmed: false,
181    });
182
183    for d in 0..5 {
184        holidays.push(HolidayInfo {
185            date: NaiveDate::from_ymd_opt(2027, 5, 1).unwrap() + chrono::Duration::days(d),
186            name: "劳动节[待确认]", is_holiday: true, is_confirmed: false,
187        });
188    }
189    holidays.push(HolidayInfo {
190        date: NaiveDate::from_ymd_opt(2027, 5, 8).unwrap(),
191        name: "劳动节调休[待确认]", is_holiday: false, is_confirmed: false,
192    });
193
194    holidays.into_iter().map(|h| (h.date, h)).collect()
195}
196
197/// Returns `true` if the date is a weekend (Saturday or Sunday).
198///
199/// ```rust
200/// use holiday_engine::is_weekend;
201/// use chrono::NaiveDate;
202///
203/// let sat = NaiveDate::from_ymd_opt(2026, 5, 23).unwrap();
204/// assert!(is_weekend(sat));
205/// let fri = NaiveDate::from_ymd_opt(2026, 5, 22).unwrap();
206/// assert!(!is_weekend(fri));
207/// ```
208pub fn is_weekend(date: NaiveDate) -> bool {
209    matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
210}
211
212/// Returns `true` if the date is "naturally off":
213///
214/// Either a statutory holiday, or a weekend that is NOT an adjusted work day.
215///
216/// This is used by the leave optimizer to determine which days contribute
217/// to rest blocks without needing leave.
218///
219/// ```rust
220/// use holiday_engine::{get_china_holidays, is_naturally_off};
221/// use chrono::NaiveDate;
222///
223/// let holidays = get_china_holidays();
224/// // New Year's Day — holiday
225/// assert!(is_naturally_off(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), &holidays));
226/// // Feb 14 is Saturday but it's an adjusted work day
227/// assert!(!is_naturally_off(NaiveDate::from_ymd_opt(2026, 2, 14).unwrap(), &holidays));
228/// ```
229pub fn is_naturally_off(date: NaiveDate, holidays: &HashMap<NaiveDate, HolidayInfo>) -> bool {
230    if let Some(info) = holidays.get(&date) {
231        return info.is_holiday;
232    }
233    is_weekend(date)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn all_2026_confirmed() {
242        let holidays = get_china_holidays();
243        let confirmed_2026: Vec<_> = holidays
244            .values()
245            .filter(|h| h.date.year() == 2026 && h.is_confirmed)
246            .collect();
247        assert!(confirmed_2026.len() >= 30);
248    }
249
250    #[test]
251    fn all_2027_unconfirmed() {
252        let holidays = get_china_holidays();
253        let unconfirmed_2027: Vec<_> = holidays.values().filter(|h| h.date.year() == 2027).collect();
254        assert!(!unconfirmed_2027.is_empty());
255        for h in unconfirmed_2027 {
256            assert!(!h.is_confirmed);
257            assert!(h.name.contains("[待确认]"));
258        }
259    }
260
261    #[test]
262    fn new_years_day_is_holiday() {
263        let holidays = get_china_holidays();
264        let d = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
265        let info = holidays.get(&d).unwrap();
266        assert!(info.is_holiday);
267        assert_eq!(info.name, "元旦");
268    }
269
270    #[test]
271    fn spring_festival_adjustment_is_workday() {
272        let holidays = get_china_holidays();
273        let d = NaiveDate::from_ymd_opt(2026, 2, 14).unwrap();
274        let info = holidays.get(&d).unwrap();
275        assert!(!info.is_holiday);
276    }
277
278    #[test]
279    fn national_day_is_seven_days() {
280        let holidays = get_china_holidays();
281        let count = holidays.values().filter(|h| h.date.year() == 2026 && h.name == "国庆节").count();
282        assert_eq!(count, 7);
283    }
284
285    #[test]
286    fn labour_day_is_five_days() {
287        let holidays = get_china_holidays();
288        let count = holidays.values().filter(|h| h.date.year() == 2026 && h.name == "劳动节").count();
289        assert_eq!(count, 5);
290    }
291
292    #[test]
293    fn is_weekend_saturday_and_sunday() {
294        assert!(is_weekend(NaiveDate::from_ymd_opt(2026, 5, 23).unwrap()));
295        assert!(is_weekend(NaiveDate::from_ymd_opt(2026, 5, 24).unwrap()));
296        assert!(!is_weekend(NaiveDate::from_ymd_opt(2026, 5, 22).unwrap()));
297    }
298
299    #[test]
300    fn is_naturally_off_respects_adjusted_workday() {
301        let holidays = get_china_holidays();
302        let d = NaiveDate::from_ymd_opt(2026, 2, 14).unwrap();
303        assert!(!is_naturally_off(d, &holidays));
304    }
305
306    #[test]
307    fn is_naturally_off_for_regular_holiday() {
308        let holidays = get_china_holidays();
309        let d = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
310        assert!(is_naturally_off(d, &holidays));
311    }
312
313    #[test]
314    fn holiday_data_contains_no_duplicate_dates() {
315        let holidays = get_china_holidays();
316        let mut dates: Vec<_> = holidays.keys().copied().collect();
317        let before = dates.len();
318        dates.sort();
319        dates.dedup();
320        assert_eq!(before, dates.len(), "Duplicate dates found in holiday data");
321    }
322
323    #[test]
324    fn dragon_boat_festival_is_three_days() {
325        let holidays = get_china_holidays();
326        let count = holidays.values().filter(|h| h.date.year() == 2026 && h.name == "端午节").count();
327        assert_eq!(count, 3);
328    }
329
330    #[test]
331    fn mid_autumn_festival_is_two_days_effective() {
332        let holidays = get_china_holidays();
333        let count = holidays.values().filter(|h| h.date.year() == 2026 && h.name == "中秋节").count();
334        assert_eq!(count, 2);
335    }
336}