/** * Romanian working-day arithmetic. * * Fixed public holidays + Orthodox Easter-derived moveable feasts. * Uses the Meeus algorithm for Orthodox Easter computation. */ // ── Fixed Romanian public holidays (month is 0-indexed) ── interface FixedHoliday { month: number; day: number; } const FIXED_HOLIDAYS: FixedHoliday[] = [ { month: 0, day: 1 }, // Anul Nou { month: 0, day: 2 }, // Anul Nou (zi 2) { month: 0, day: 24 }, // Ziua Unirii { month: 4, day: 1 }, // Ziua Muncii { month: 5, day: 1 }, // Ziua Copilului { month: 7, day: 15 }, // Adormirea Maicii Domnului { month: 10, day: 30 }, // Sfântul Andrei { month: 11, day: 1 }, // Ziua Națională { month: 11, day: 25 }, // Crăciunul { month: 11, day: 26 }, // Crăciunul (zi 2) ]; // ── Orthodox Easter via Meeus algorithm ── function orthodoxEaster(year: number): Date { const a = year % 4; const b = year % 7; const c = year % 19; const d = (19 * c + 15) % 30; const e = (2 * a + 4 * b - d + 34) % 7; const month = Math.floor((d + e + 114) / 31); // 3 = March, 4 = April const day = ((d + e + 114) % 31) + 1; // Julian date — convert to Gregorian by adding 13 days (valid 1900–2099) const julian = new Date(year, month - 1, day); julian.setDate(julian.getDate() + 13); return julian; } function getMovableHolidays(year: number): Date[] { const easter = orthodoxEaster(year); const goodFriday = new Date(easter); goodFriday.setDate(easter.getDate() - 2); const easterMonday = new Date(easter); easterMonday.setDate(easter.getDate() + 1); const rusaliiSunday = new Date(easter); rusaliiSunday.setDate(easter.getDate() + 49); const rusaliiMonday = new Date(easter); rusaliiMonday.setDate(easter.getDate() + 50); return [goodFriday, easter, easterMonday, rusaliiSunday, rusaliiMonday]; } // ── Holiday cache per year ── const holidayCache = new Map>(); function toKey(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function getHolidaySet(year: number): Set { const cached = holidayCache.get(year); if (cached) return cached; const set = new Set(); for (const h of FIXED_HOLIDAYS) { set.add(toKey(new Date(year, h.month, h.day))); } for (const d of getMovableHolidays(year)) { set.add(toKey(d)); } holidayCache.set(year, set); return set; } // ── Public API ── export function isPublicHoliday(date: Date): boolean { return getHolidaySet(date.getFullYear()).has(toKey(date)); } export function isWeekend(date: Date): boolean { const day = date.getDay(); return day === 0 || day === 6; } export function isWorkingDay(date: Date): boolean { return !isWeekend(date) && !isPublicHoliday(date); } /** * Add calendar days (simply skips no days). * Supports negative values. */ export function addCalendarDays(start: Date, days: number): Date { const result = new Date(start); result.setDate(result.getDate() + days); return result; } /** * Add working days, skipping weekends and Romanian public holidays. * Supports negative values (for backward deadlines). */ export function addWorkingDays(start: Date, days: number): Date { const result = new Date(start); const direction = days >= 0 ? 1 : -1; let remaining = Math.abs(days); while (remaining > 0) { result.setDate(result.getDate() + direction); if (isWorkingDay(result)) { remaining--; } } return result; } /** * Compute the due date for a deadline definition. */ export function computeDueDate( startDate: Date, days: number, dayType: 'calendar' | 'working', isBackward?: boolean, ): Date { const effectiveDays = isBackward ? -days : days; return dayType === 'working' ? addWorkingDays(startDate, effectiveDays) : addCalendarDays(startDate, effectiveDays); }