feat(metrics): SA public holidays and workday counting
This commit is contained in:
parent
a1ef2ff55c
commit
265d623220
3 changed files with 176 additions and 0 deletions
53
src/lib/metrics/holidays.ts
Normal file
53
src/lib/metrics/holidays.ts
Normal 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);
|
||||
}
|
||||
69
src/lib/metrics/workdays.test.ts
Normal file
69
src/lib/metrics/workdays.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
src/lib/metrics/workdays.ts
Normal file
54
src/lib/metrics/workdays.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue