js-public-holidays-za
Version: 
Get South African public holidays for any date range, including observed holidays.
153 lines (135 loc) • 5.41 kB
JavaScript
// -----------------------------------------------
// South Africa Public Holidays (ESM Module)
// -----------------------------------------------
/**
 * Get all South African public holidays between start and end (inclusive).
 * @param {Date|string|number} start - Start date
 * @param {Date|string|number} end - End date
 * @param {Object} [opts]
 * @param {boolean} [opts.includeObservedMondays=true]
 *   If a holiday falls on a Sunday, include the Monday as "Observed".
 * @returns {Array<{ date: Date, name: string, weekday: string }>}
 */
export function getSAPublicHolidaysBetween(start, end, opts = {}) {
    const { includeObservedMondays = true } = opts;
    const s = new Date(start);
    s.setHours(0, 0, 0, 0);
    const e = new Date(end);
    e.setHours(23, 59, 59, 999);
    if (Number.isNaN(+s) || Number.isNaN(+e)) throw new Error("Invalid start or end date");
    if (s > e) throw new Error("Start must be <= end");
    const startYear = s.getFullYear();
    const endYear = e.getFullYear();
    // Build a map keyed by yyyy-mm-dd so we can merge collisions cleanly
    const map = new Map();
    for (let y = startYear; y <= endYear; y++) {
        const yearHolidays = buildSAHolidaysForYear(y);
        // Add base holidays
        for (const { date, name }
            of yearHolidays) {
            addHoliday(map, date, name);
        }
        // Observed Monday rule (Public Holidays Act): if a holiday falls on Sunday,
        // the following Monday is a public holiday too.
        if (includeObservedMondays) {
            for (const { date, name }
                of yearHolidays) {
                if (date.getDay() === 0) { // Sunday
                    const monday = new Date(date);
                    monday.setDate(date.getDate() + 1);
                    addHoliday(map, monday, `${name} (Observed)`);
                }
            }
        }
    }
    // Filter by range and sort
    const results = [];
    for (const [iso, info] of map) {
        const d = parseISODate(iso);
        if (d >= s && d <= e) {
            results.push({
                date: d,
                name: info.names.join("; "),
                weekday: d.toLocaleDateString("en-ZA", { weekday: "long" })
            });
        }
    }
    results.sort((a, b) => a.date - b.date);
    return results;
}
/**
 * Build base SA public holidays for a given year.
 * Includes fixed-date and Easter-relative (Good Friday, Family Day).
 * Note: This does NOT add any discretionary "extra" days (e.g., 27 Dec by proclamation).
 */
export function buildSAHolidaysForYear(y) {
    // Fixed-date holidays (month is 0-based)
    const fixed = [
        [new Date(y, 0, 1), "New Year's Day"],
        [new Date(y, 2, 21), "Human Rights Day"],
        [new Date(y, 3, 27), "Freedom Day"],
        [new Date(y, 4, 1), "Workers' Day"],
        [new Date(y, 5, 16), "Youth Day"],
        [new Date(y, 7, 9), "National Women's Day"],
        [new Date(y, 8, 24), "Heritage Day"],
        [new Date(y, 11, 16), "Day of Reconciliation"],
        [new Date(y, 11, 25), "Christmas Day"],
        [new Date(y, 11, 26), "Day of Goodwill"]
    ];
    // Easter-based
    const easter = getEasterDate(y); // Easter Sunday
    const goodFriday = new Date(easter); // Good Friday = Easter Sunday - 2
    goodFriday.setDate(easter.getDate() - 2);
    const familyDay = new Date(easter); // Family Day = Easter Monday = Easter Sunday + 1
    familyDay.setDate(easter.getDate() + 1);
    fixed.push([goodFriday, "Good Friday"]);
    fixed.push([familyDay, "Family Day"]);
    // Normalize to midnight
    return fixed.map(([d, name]) => ({
        date: new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0),
        name
    }));
}
// Helper: add to map keyed by ISO (yyyy-mm-dd); merge names if same day gets multiple holidays
function addHoliday(map, date, name) {
    const iso = toISODateOnly(date);
    if (!map.has(iso)) map.set(iso, { names: [] });
    const entry = map.get(iso);
    if (!entry.names.includes(name)) entry.names.push(name);
}
// Easter Sunday via Meeus/Jones/Butcher algorithm
export function getEasterDate(year) {
    const f = Math.floor,
        G = year % 19,
        C = f(year / 100),
        H = (C - f(C / 4) - f((8 * C + 13) / 25) + 19 * G + 15) % 30,
        I = H - f(H / 28) * (1 - f(29 / (H + 1)) * f((21 - G) / 11)),
        J = (year + f(year / 4) + I + 2 - C + f(C / 4)) % 7,
        L = I - J,
        month = 3 + f((L + 40) / 44),
        day = L + 28 - 31 * f(month / 4);
    return new Date(year, month - 1, day);
}
// ---- small date helpers ----
function toISODateOnly(d) {
    const y = d.getFullYear();
    const m = String(d.getMonth() + 1).padStart(2, "0");
    const day = String(d.getDate()).padStart(2, "0");
    return `${y}-${m}-${day}`;
}
function parseISODate(iso) {
    const [y, m, d] = iso.split("-").map(Number);
    return new Date(y, m - 1, d);
}
// ---------------------------
// Module ready for import in ESM/CommonJS environments
// Example usage can be found in the README.md
// CommonJS compatibility
// Allows: const { getSAPublicHolidaysBetween, buildSAHolidaysForYear, getEasterDate } = require('js-public-holidays-za');
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
    module.exports = {
        getSAPublicHolidaysBetween,
        buildSAHolidaysForYear,
        getEasterDate
    };
}