feat(metrics): SA public holidays and workday counting

This commit is contained in:
Dave 2026-09-16 13:44:01 +02:00
parent a1ef2ff55c
commit 265d623220
3 changed files with 176 additions and 0 deletions

View file

@ -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);
}

View file

@ -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);
});
});

View file

@ -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;
}