diff --git a/src/lib/metrics/holidays.ts b/src/lib/metrics/holidays.ts new file mode 100644 index 0000000..5364dec --- /dev/null +++ b/src/lib/metrics/holidays.ts @@ -0,0 +1,53 @@ +/** + * South African public holidays, as an explicit table. + * + * Computing the Easter-derived dates was considered and rejected. A table is + * auditable at a glance, and its failure mode is one visibly odd day that + * someone notices — whereas a wrong Easter algorithm produces a + * plausible-looking systematic drift that nobody catches. + * + * Includes Monday observances: South African law moves a public holiday that + * falls on a Sunday to the following Monday. Those Mondays are listed here + * explicitly rather than derived, for the same reason. + * + * Verified against the developer's hand-built build log: 24 May → 8 Sep 2026 + * gives 108 calendar days and 75 workdays with this table. + * + * EXTEND THIS before a project spans a year that is not listed. + */ +export const SA_HOLIDAYS: readonly string[] = [ + // 2024 — Easter Sunday 31 Mar + "2024-01-01", "2024-03-21", "2024-03-29", "2024-04-01", "2024-04-27", + "2024-05-01", "2024-06-16", "2024-06-17", "2024-08-09", "2024-09-24", + "2024-12-16", "2024-12-25", "2024-12-26", + + // 2025 — Easter Sunday 20 Apr + "2025-01-01", "2025-03-21", "2025-04-18", "2025-04-21", "2025-04-27", + "2025-04-28", "2025-05-01", "2025-06-16", "2025-08-09", "2025-09-24", + "2025-12-16", "2025-12-25", "2025-12-26", + + // 2026 — Easter Sunday 5 Apr. Women's Day falls on Sunday 9 Aug, + // so Monday 10 Aug is observed. + "2026-01-01", "2026-03-21", "2026-04-03", "2026-04-06", "2026-04-27", + "2026-05-01", "2026-06-16", "2026-08-09", "2026-08-10", "2026-09-24", + "2026-12-16", "2026-12-25", "2026-12-26", + + // 2027 — Easter Sunday 28 Mar. Human Rights Day falls on Sunday 21 Mar, + // so Monday 22 Mar is observed. Boxing Day falls on Sunday 26 Dec, + // so Monday 27 Dec is observed. + "2027-01-01", "2027-03-21", "2027-03-22", "2027-03-26", "2027-03-29", + "2027-04-27", "2027-05-01", "2027-06-16", "2027-08-09", "2027-09-24", + "2027-12-16", "2027-12-25", "2027-12-26", "2027-12-27", + + // 2028 — Easter Sunday 16 Apr. New Year falls on Saturday; Workers' Day + // on Monday. + "2028-01-01", "2028-03-21", "2028-04-14", "2028-04-17", "2028-04-27", + "2028-05-01", "2028-06-16", "2028-08-09", "2028-09-24", "2028-12-16", + "2028-12-25", "2028-12-26", +]; + +const HOLIDAY_SET = new Set(SA_HOLIDAYS); + +export function isPublicHoliday(isoDate: string): boolean { + return HOLIDAY_SET.has(isoDate); +} \ No newline at end of file diff --git a/src/lib/metrics/workdays.test.ts b/src/lib/metrics/workdays.test.ts new file mode 100644 index 0000000..f458f41 --- /dev/null +++ b/src/lib/metrics/workdays.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { SA_HOLIDAYS } from "@/lib/metrics/holidays"; +import { countWorkdays, isWorkday, countCalendarDays } from "@/lib/metrics/workdays"; + +describe("countCalendarDays", () => { + it("counts both endpoints", () => { + expect(countCalendarDays("2026-09-01", "2026-09-01")).toBe(1); + expect(countCalendarDays("2026-09-01", "2026-09-03")).toBe(3); + }); + + it("returns 0 when the range is inverted", () => { + expect(countCalendarDays("2026-09-03", "2026-09-01")).toBe(0); + }); +}); + +describe("isWorkday", () => { + it("excludes weekends", () => { + expect(isWorkday("2026-09-12")).toBe(false); // Saturday + expect(isWorkday("2026-09-13")).toBe(false); // Sunday + expect(isWorkday("2026-09-14")).toBe(true); // Monday + }); + + it("excludes a public holiday that falls on a weekday", () => { + expect(isWorkday("2026-06-16")).toBe(false); // Youth Day, a Tuesday + }); + + it("excludes the Monday after a Sunday holiday", () => { + // South African law moves a Sunday public holiday to the Monday. + // Women's Day 2026 is Sunday 9 August, so Monday 10 August is a holiday. + expect(isWorkday("2026-08-10")).toBe(false); + expect(isWorkday("2026-08-11")).toBe(true); + }); +}); + +describe("countWorkdays — reproduces the Hermes build log", () => { + it("gives 108 calendar days and 75 workdays for 24 May to 8 Sep 2026", () => { + // These are the exact figures from the developer's hand-built log. If this + // test fails, either the holiday table or the counting drifted, and every + // "active X of Y workdays" figure on the dashboard is wrong. + expect(countCalendarDays("2026-05-24", "2026-09-08")).toBe(108); + expect(countWorkdays("2026-05-24", "2026-09-08")).toBe(75); + }); +}); + +describe("SA_HOLIDAYS", () => { + it("covers every year a project might span", () => { + for (const year of [2024, 2025, 2026, 2027, 2028]) { + const inYear = SA_HOLIDAYS.filter((d) => d.startsWith(String(year))); + // Twelve statutory days, plus any Monday observances. + expect(inYear.length).toBeGreaterThanOrEqual(12); + } + }); + + it("has the Easter-derived dates right", () => { + // Easter Sunday: 2025-04-20, 2026-04-05, 2027-03-28. + expect(SA_HOLIDAYS).toContain("2025-04-18"); // Good Friday + expect(SA_HOLIDAYS).toContain("2025-04-21"); // Family Day + expect(SA_HOLIDAYS).toContain("2026-04-03"); + expect(SA_HOLIDAYS).toContain("2026-04-06"); + expect(SA_HOLIDAYS).toContain("2027-03-26"); + expect(SA_HOLIDAYS).toContain("2027-03-29"); + }); + + it("is sorted and free of duplicates", () => { + const sorted = [...SA_HOLIDAYS].sort(); + expect(SA_HOLIDAYS).toEqual(sorted); + expect(new Set(SA_HOLIDAYS).size).toBe(SA_HOLIDAYS.length); + }); +}); \ No newline at end of file diff --git a/src/lib/metrics/workdays.ts b/src/lib/metrics/workdays.ts new file mode 100644 index 0000000..9257ee7 --- /dev/null +++ b/src/lib/metrics/workdays.ts @@ -0,0 +1,54 @@ +/** + * Workday counting. + * + * The dashboard leads with active days as a fraction of WORKDAYS, not calendar + * days. "60 of 75 workdays" and "60 of 108 calendar days" describe the same + * three months; the second implies absence where there was a weekend. The + * first is the honest framing, and it is the strongest idea in the developer's + * original build log. + * + * All arithmetic is UTC on plain YYYY-MM-DD strings. Local time would make the + * figures shift with the reader's timezone, which is indefensible on a page a + * client is questioning. + */ + +import { isPublicHoliday } from "@/lib/metrics/holidays"; + +const DAY_MS = 86_400_000; + +function toUtc(isoDate: string): number { + return Date.UTC( + Number(isoDate.slice(0, 4)), + Number(isoDate.slice(5, 7)) - 1, + Number(isoDate.slice(8, 10)) + ); +} + +export function toIsoDate(value: Date | number): string { + return new Date(value).toISOString().slice(0, 10); +} + +/** Inclusive of both endpoints. 0 when the range is inverted. */ +export function countCalendarDays(from: string, to: string): number { + const days = Math.round((toUtc(to) - toUtc(from)) / DAY_MS) + 1; + return days > 0 ? days : 0; +} + +export function isWorkday(isoDate: string): boolean { + const day = new Date(toUtc(isoDate)).getUTCDay(); + if (day === 0 || day === 6) return false; + return !isPublicHoliday(isoDate); +} + +/** Weekdays in the inclusive range, excluding public holidays. */ +export function countWorkdays(from: string, to: string): number { + const start = toUtc(from); + const end = toUtc(to); + if (end < start) return 0; + + let count = 0; + for (let t = start; t <= end; t += DAY_MS) { + if (isWorkday(toIsoDate(t))) count += 1; + } + return count; +} \ No newline at end of file