Skip to main content

amlich_core/
holidays.rs

1use crate::holiday_data::{lunar_festivals, solar_holidays};
2/**
3 * Vietnamese Holidays Module
4 *
5 * Provides functions to get Vietnamese lunar holidays for a given year.
6 * Holiday data is loaded from shared JSON files at compile time.
7 */
8use crate::julian::{jd_from_date, jd_to_date};
9use crate::lunar::{convert_lunar_to_solar, LunarDate};
10use crate::tietkhi::get_all_tiet_khi_for_year;
11use crate::types::VIETNAM_TIMEZONE;
12
13/// Information about a Vietnamese holiday
14#[derive(Debug, Clone)]
15pub struct Holiday {
16    pub name: String,
17    pub description: String,
18    pub lunar_date: Option<LunarDate>,
19    pub solar_day: i32,
20    pub solar_month: i32,
21    pub solar_year: i32,
22    pub is_solar: bool,
23    pub category: String,
24    pub is_major: bool,
25}
26
27#[derive(Debug, Clone)]
28pub struct UpcomingEvent {
29    pub name: String,
30    pub jd: i32,
31    pub days_left: i32,
32    pub is_lunar: bool,
33}
34
35pub fn get_upcoming_events(
36    current_jd: i32,
37    current_solar_year: i32,
38    limit_days: i32,
39) -> Vec<UpcomingEvent> {
40    let mut all_holidays = get_vietnamese_holidays(current_solar_year);
41    // If it's near end of year, fetch next year too
42    all_holidays.extend(get_vietnamese_holidays(current_solar_year + 1));
43
44    let mut upcoming = Vec::new();
45
46    // Track seen JDs to prioritize major holidays or first encountered
47    let mut seen_jds = std::collections::HashSet::new();
48
49    // Sort to make sure we process chronologically, but we want major holidays to take precedence
50    // So we first sort by JD, then by `is_major` desc
51    all_holidays.sort_by(|a, b| {
52        let jd_a = jd_from_date(a.solar_day, a.solar_month, a.solar_year);
53        let jd_b = jd_from_date(b.solar_day, b.solar_month, b.solar_year);
54        jd_a.cmp(&jd_b).then_with(|| b.is_major.cmp(&a.is_major))
55    });
56
57    for h in all_holidays {
58        let jd = jd_from_date(h.solar_day, h.solar_month, h.solar_year);
59        let days_left = jd - current_jd;
60        if days_left > 0 && days_left <= limit_days {
61            if !seen_jds.contains(&jd) {
62                seen_jds.insert(jd);
63                upcoming.push(UpcomingEvent {
64                    name: h.name.clone(),
65                    jd,
66                    days_left,
67                    is_lunar: !h.is_solar,
68                });
69            }
70        }
71    }
72
73    // Sort back by JD just in case
74    upcoming.sort_by_key(|e| e.jd);
75    upcoming
76}
77
78struct LunarHolidayInput<'a> {
79    name: &'a str,
80    lunar_day: i32,
81    lunar_month: i32,
82    lunar_year: i32,
83    description: &'a str,
84    category: &'a str,
85    is_major: bool,
86}
87
88// Helper function to create a lunar holiday
89fn create_lunar_holiday(input: LunarHolidayInput<'_>, time_zone: f64) -> Option<Holiday> {
90    let solar = convert_lunar_to_solar(
91        input.lunar_day,
92        input.lunar_month,
93        input.lunar_year,
94        false,
95        time_zone,
96    );
97    if solar.0 > 0 {
98        Some(Holiday {
99            name: input.name.to_string(),
100            description: input.description.to_string(),
101            lunar_date: Some(LunarDate {
102                day: input.lunar_day,
103                month: input.lunar_month,
104                year: input.lunar_year,
105                is_leap: false,
106            }),
107            solar_day: solar.0,
108            solar_month: solar.1,
109            solar_year: solar.2,
110            is_solar: false,
111            category: input.category.to_string(),
112            is_major: input.is_major,
113        })
114    } else {
115        None
116    }
117}
118
119fn nth_weekday_of_month(year: i32, month: i32, weekday: usize, nth: i32) -> (i32, i32, i32) {
120    let first_jd = jd_from_date(1, month, year);
121    let first_weekday = (first_jd + 1) % 7;
122    let target_weekday = weekday as i32;
123    let offset = (7 + target_weekday - first_weekday) % 7;
124    let day = 1 + offset + 7 * (nth - 1);
125    (day, month, year)
126}
127
128/// Get all Vietnamese lunar holidays for a given solar year
129///
130/// # Arguments
131/// * `solar_year` - Solar year
132///
133/// # Returns
134/// Vector of holidays sorted by date
135pub fn get_vietnamese_holidays(solar_year: i32) -> Vec<Holiday> {
136    let time_zone = VIETNAM_TIMEZONE;
137    let mut holidays = Vec::new();
138
139    // -- Lunar festivals from shared JSON data --
140    for festival in lunar_festivals() {
141        // Skip solar-based entries (e.g. Thanh Minh) — handled separately via solar term computation
142        if festival.is_solar {
143            continue;
144        }
145
146        let name = &festival.names.vi[0];
147        let description = &festival.names.en[0];
148        let lunar_year = solar_year + festival.year_offset;
149
150        if let Some(h) = create_lunar_holiday(
151            LunarHolidayInput {
152                name,
153                lunar_day: festival.lunar_day,
154                lunar_month: festival.lunar_month,
155                lunar_year,
156                description,
157                category: &festival.category,
158                is_major: festival.is_major,
159            },
160            time_zone,
161        ) {
162            holidays.push(h);
163        }
164    }
165
166    // Thanh Minh (solar-based, computed from solar term transition)
167    let thanh_minh_date = get_all_tiet_khi_for_year(solar_year, time_zone)
168        .into_iter()
169        .find(|t| t.name == "Thanh Minh")
170        .map(|t| jd_to_date(t.jd))
171        .unwrap_or((5, 4, solar_year));
172
173    holidays.push(Holiday {
174        name: "Tết Thanh Minh".to_string(),
175        description: "Tomb Sweeping Day (Solar calendar)".to_string(),
176        lunar_date: None,
177        solar_day: thanh_minh_date.0,
178        solar_month: thanh_minh_date.1,
179        solar_year: thanh_minh_date.2,
180        is_solar: true,
181        category: "festival".to_string(),
182        is_major: true,
183    });
184
185    // -- Solar holidays from shared JSON data --
186    for holiday_data in solar_holidays() {
187        let name = &holiday_data.names.vi[0];
188        let description = &holiday_data.names.en[0];
189
190        holidays.push(Holiday {
191            name: name.clone(),
192            description: description.clone(),
193            lunar_date: None,
194            solar_day: holiday_data.solar_day,
195            solar_month: holiday_data.solar_month,
196            solar_year,
197            is_solar: true,
198            category: holiday_data.category.clone(),
199            is_major: holiday_data.is_major,
200        });
201    }
202
203    let mothers_day = nth_weekday_of_month(solar_year, 5, 0, 2);
204    holidays.push(Holiday {
205        name: "Ngày của Mẹ".to_string(),
206        description: "Mother's Day (2nd Sunday of May)".to_string(),
207        lunar_date: None,
208        solar_day: mothers_day.0,
209        solar_month: mothers_day.1,
210        solar_year: mothers_day.2,
211        is_solar: true,
212        category: "social".to_string(),
213        is_major: true,
214    });
215
216    let fathers_day = nth_weekday_of_month(solar_year, 6, 0, 3);
217    holidays.push(Holiday {
218        name: "Ngày của Cha".to_string(),
219        description: "Father's Day (3rd Sunday of June)".to_string(),
220        lunar_date: None,
221        solar_day: fathers_day.0,
222        solar_month: fathers_day.1,
223        solar_year: fathers_day.2,
224        is_solar: true,
225        category: "social".to_string(),
226        is_major: true,
227    });
228
229    // Add all Rằm (15th) and Mùng 1 (1st) of each lunar month
230    for month in 1..=12 {
231        let mung_mot_name = format!("Mùng 1 tháng {}", month);
232        if let Some(h) = create_lunar_holiday(
233            LunarHolidayInput {
234                name: &mung_mot_name,
235                lunar_day: 1,
236                lunar_month: month,
237                lunar_year: solar_year,
238                description: "First day of lunar month",
239                category: "lunar-cycle",
240                is_major: false,
241            },
242            time_zone,
243        ) {
244            holidays.push(h);
245        }
246
247        let ram_name = format!("Rằm tháng {}", month);
248        if let Some(h) = create_lunar_holiday(
249            LunarHolidayInput {
250                name: &ram_name,
251                lunar_day: 15,
252                lunar_month: month,
253                lunar_year: solar_year,
254                description: "Full moon day",
255                category: "lunar-cycle",
256                is_major: false,
257            },
258            time_zone,
259        ) {
260            holidays.push(h);
261        }
262    }
263
264    // Sort by date
265    holidays.sort_by(|a, b| {
266        let date_a = (a.solar_year, a.solar_month, a.solar_day);
267        let date_b = (b.solar_year, b.solar_month, b.solar_day);
268        date_a.cmp(&date_b)
269    });
270
271    holidays
272}
273
274/// Get major Vietnamese holidays only (no monthly Mùng 1/Rằm)
275///
276/// # Arguments
277/// * `solar_year` - Solar year
278///
279/// # Returns
280/// Vector of major holidays only
281pub fn get_major_holidays(solar_year: i32) -> Vec<Holiday> {
282    get_vietnamese_holidays(solar_year)
283        .into_iter()
284        .filter(|h| h.is_major)
285        .collect()
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_get_vietnamese_holidays_2024() {
294        let holidays = get_vietnamese_holidays(2024);
295
296        // Should have holidays (at least Tết, major festivals, + monthly dates)
297        assert!(holidays.len() > 20, "Should have many holidays");
298
299        // Check that holidays are sorted
300        for i in 1..holidays.len() {
301            let date_prev = (
302                holidays[i - 1].solar_year,
303                holidays[i - 1].solar_month,
304                holidays[i - 1].solar_day,
305            );
306            let date_curr = (
307                holidays[i].solar_year,
308                holidays[i].solar_month,
309                holidays[i].solar_day,
310            );
311            assert!(date_curr >= date_prev, "Holidays should be sorted by date");
312        }
313    }
314
315    #[test]
316    fn test_tet_nguyen_dan_present() {
317        let holidays = get_vietnamese_holidays(2024);
318
319        // Should have Tết Nguyên Đán
320        let tet = holidays.iter().find(|h| h.name.contains("Tết Nguyên Đán"));
321        assert!(tet.is_some(), "Should have Tết Nguyên Đán");
322
323        let tet = tet.unwrap();
324        // Tết 2024 is February 10, 2024
325        assert_eq!(tet.solar_day, 10);
326        assert_eq!(tet.solar_month, 2);
327        assert_eq!(tet.solar_year, 2024);
328    }
329
330    #[test]
331    fn test_thanh_minh_is_solar() {
332        let holidays = get_vietnamese_holidays(2024);
333
334        let thanh_minh = holidays.iter().find(|h| h.name.contains("Thanh Minh"));
335        assert!(thanh_minh.is_some(), "Should have Thanh Minh");
336
337        let thanh_minh = thanh_minh.unwrap();
338        assert!(thanh_minh.is_solar, "Thanh Minh should be solar-based");
339        assert_eq!(thanh_minh.solar_month, 4);
340        assert_eq!(thanh_minh.solar_day, 5);
341    }
342
343    #[test]
344    fn test_trung_thu_present() {
345        let holidays = get_vietnamese_holidays(2024);
346
347        let trung_thu = holidays.iter().find(|h| h.name.contains("Trung Thu"));
348        assert!(trung_thu.is_some(), "Should have Tết Trung Thu");
349
350        // Trung Thu is 15/8 lunar
351        let trung_thu = trung_thu.unwrap();
352        assert!(trung_thu.lunar_date.is_some());
353        let lunar = trung_thu.lunar_date.as_ref().unwrap();
354        assert_eq!(lunar.day, 15);
355        assert_eq!(lunar.month, 8);
356    }
357
358    #[test]
359    fn test_get_major_holidays() {
360        let all = get_vietnamese_holidays(2024);
361        let major = get_major_holidays(2024);
362
363        // Major holidays should be fewer than all holidays
364        assert!(major.len() < all.len(), "Major holidays should be a subset");
365
366        // Should still have Tết
367        assert!(major.iter().any(|h| h.name.contains("Tết Nguyên Đán")));
368
369        // Should still have Trung Thu
370        assert!(major.iter().any(|h| h.name.contains("Trung Thu")));
371    }
372
373    #[test]
374    fn test_lunar_dates_populated() {
375        let holidays = get_vietnamese_holidays(2024);
376
377        // Most holidays should have lunar dates (except Thanh Minh)
378        let with_lunar = holidays.iter().filter(|h| h.lunar_date.is_some()).count();
379        assert!(with_lunar > 20, "Most holidays should have lunar dates");
380    }
381
382    #[test]
383    fn test_floating_mother_father_days() {
384        let holidays = get_vietnamese_holidays(2024);
385
386        let mother_day = holidays.iter().find(|h| h.name == "Ngày của Mẹ");
387        assert!(mother_day.is_some(), "Should have Mother's Day");
388        let mother_day = mother_day.unwrap();
389        assert_eq!(mother_day.solar_day, 12);
390        assert_eq!(mother_day.solar_month, 5);
391
392        let father_day = holidays.iter().find(|h| h.name == "Ngày của Cha");
393        assert!(father_day.is_some(), "Should have Father's Day");
394        let father_day = father_day.unwrap();
395        assert_eq!(father_day.solar_day, 16);
396        assert_eq!(father_day.solar_month, 6);
397    }
398}