construction-gantt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +5 -0
  3. package/dist/export/index.cjs +1 -0
  4. package/dist/export/index.d.cts +112 -0
  5. package/dist/export/index.d.cts.map +1 -0
  6. package/dist/export/index.d.ts +112 -0
  7. package/dist/export/index.d.ts.map +1 -0
  8. package/dist/export/index.js +1 -0
  9. package/dist/index.cjs +3492 -0
  10. package/dist/index.d.cts +628 -0
  11. package/dist/index.d.cts.map +1 -0
  12. package/dist/index.d.ts +628 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +3461 -0
  15. package/dist/pdf-CAQDrX0w.cjs +120 -0
  16. package/dist/pdf-CBaoJRTI.js +120 -0
  17. package/dist/png-C8t74695.cjs +88 -0
  18. package/dist/png-DKZeKnRh.js +88 -0
  19. package/dist/xlsx-5FRPFck7.js +89 -0
  20. package/dist/xlsx-Gh5L_NL3.cjs +111 -0
  21. package/package.json +86 -0
  22. package/src/Gantt.css +23 -0
  23. package/src/Gantt.tsx +636 -0
  24. package/src/SpikeGantt.tsx +114 -0
  25. package/src/analysis.ts +83 -0
  26. package/src/baseline.ts +119 -0
  27. package/src/calendars/canterbury-table.ts +44 -0
  28. package/src/calendars/internal/computus.ts +25 -0
  29. package/src/calendars/internal/date-utils.ts +13 -0
  30. package/src/calendars/internal/mondayisation.ts +46 -0
  31. package/src/calendars/internal/month-rules.ts +65 -0
  32. package/src/calendars/matariki-table.ts +63 -0
  33. package/src/calendars/nz-holidays.ts +214 -0
  34. package/src/editing/command-history.ts +78 -0
  35. package/src/editing/commands.ts +327 -0
  36. package/src/editing/composite-command.ts +64 -0
  37. package/src/editing/draft-project.ts +59 -0
  38. package/src/editing/errors.ts +14 -0
  39. package/src/editing/factories.ts +92 -0
  40. package/src/editing/use-editable-project.ts +122 -0
  41. package/src/export/index.ts +12 -0
  42. package/src/export/offscreen.tsx +89 -0
  43. package/src/export/pdf-dimensions.ts +64 -0
  44. package/src/export/pdf.ts +68 -0
  45. package/src/export/png.ts +48 -0
  46. package/src/export/types.ts +42 -0
  47. package/src/export/xlsx.ts +70 -0
  48. package/src/index.ts +89 -0
  49. package/src/mspdi/parse.ts +820 -0
  50. package/src/mspdi/serialize.ts +352 -0
  51. package/src/mspdi/types.ts +53 -0
  52. package/src/schedule.ts +470 -0
  53. package/src/topological-sort.ts +51 -0
  54. package/src/types.ts +254 -0
  55. package/src/visibility.ts +35 -0
  56. package/src/working-time.ts +235 -0
@@ -0,0 +1,114 @@
1
+ // ADR-002 confirmation spike.
2
+ //
3
+ // Proves the wrapper strategy: we feed SVAR pre-computed fields via the tasks
4
+ // prop and render them via the taskTemplate slot. No fork. No PRO. Pure
5
+ // composition over SVAR's MIT free tier.
6
+ //
7
+ // If this renders correctly with the "starts weekend" pill appearing only on
8
+ // the tasks whose start falls on a Saturday/Sunday, ADR-002's shape (c) is
9
+ // confirmed and v0.1 implementation work can proceed.
10
+
11
+ import { Gantt, type ITask } from '@svar-ui/react-gantt';
12
+ import '@svar-ui/react-gantt/style.css';
13
+ import type { FC, ReactElement } from 'react';
14
+
15
+ type SpikeTask = ITask & {
16
+ is_weekend_start?: boolean;
17
+ };
18
+
19
+ function isWeekend(date: Date): boolean {
20
+ const day = date.getDay();
21
+ return day === 0 || day === 6;
22
+ }
23
+
24
+ const baseTasks: SpikeTask[] = [
25
+ {
26
+ id: 1,
27
+ text: 'Site preparation',
28
+ start: new Date(2026, 0, 5),
29
+ end: new Date(2026, 0, 9),
30
+ duration: 5,
31
+ type: 'task',
32
+ progress: 100,
33
+ },
34
+ {
35
+ id: 2,
36
+ text: 'Foundation pour',
37
+ start: new Date(2026, 0, 10),
38
+ end: new Date(2026, 0, 14),
39
+ duration: 5,
40
+ type: 'task',
41
+ progress: 60,
42
+ },
43
+ {
44
+ id: 3,
45
+ text: 'Framing',
46
+ start: new Date(2026, 0, 15),
47
+ end: new Date(2026, 0, 22),
48
+ duration: 8,
49
+ type: 'task',
50
+ progress: 20,
51
+ },
52
+ {
53
+ id: 4,
54
+ text: 'Council inspection',
55
+ start: new Date(2026, 0, 23),
56
+ end: new Date(2026, 0, 23),
57
+ duration: 0,
58
+ type: 'milestone',
59
+ progress: 0,
60
+ },
61
+ ];
62
+
63
+ const spikeTasks: SpikeTask[] = baseTasks.map((task) => ({
64
+ ...task,
65
+ is_weekend_start: task.start ? isWeekend(task.start) : false,
66
+ }));
67
+
68
+ const SpikeTaskBar: FC<{ data: SpikeTask }> = ({ data }) => {
69
+ return (
70
+ <div
71
+ style={{
72
+ display: 'flex',
73
+ alignItems: 'center',
74
+ gap: 6,
75
+ height: '100%',
76
+ padding: '0 6px',
77
+ fontSize: 12,
78
+ color: '#1f2937',
79
+ }}
80
+ >
81
+ <span>{data.text}</span>
82
+ {data.is_weekend_start && (
83
+ <span
84
+ style={{
85
+ padding: '0 6px',
86
+ background: '#fde68a',
87
+ color: '#78350f',
88
+ borderRadius: 4,
89
+ fontSize: 10,
90
+ fontWeight: 600,
91
+ lineHeight: '16px',
92
+ }}
93
+ >
94
+ starts weekend
95
+ </span>
96
+ )}
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export function SpikeGantt(): ReactElement {
102
+ return (
103
+ <div style={{ height: 420 }}>
104
+ <Gantt
105
+ tasks={spikeTasks}
106
+ taskTemplate={SpikeTaskBar}
107
+ start={new Date(2026, 0, 1)}
108
+ end={new Date(2026, 1, 1)}
109
+ cellWidth={36}
110
+ cellHeight={42}
111
+ />
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,83 @@
1
+ // Analysis helpers — consumer-facing summary queries over a scheduled
2
+ // Project. Useful for dashboards, management-focus views, and CI-style
3
+ // programme-health checks.
4
+ //
5
+ // Assumes the project has been through `schedule()` (tasks have
6
+ // `computed` populated). If not, the helpers return safe defaults
7
+ // rather than throwing.
8
+
9
+ import type { Project, Task } from './types';
10
+
11
+ /**
12
+ * Return the critical-path leaf tasks (summary tasks excluded), ordered
13
+ * by `earlyStart` ascending. A construction PM reading this list is
14
+ * looking at "the tasks that, if any of them slip, the contract finish
15
+ * slips."
16
+ *
17
+ * The list mirrors what `task.computed.isCritical` says — i.e., includes
18
+ * tasks with negative total slack (already-late tasks).
19
+ */
20
+ export function getCriticalPath(project: Project): Task[] {
21
+ const leaves = project.tasks.filter((t) => t.type !== 'summary');
22
+ const critical = leaves.filter((t) => t.computed?.isCritical === true);
23
+ return critical.slice().sort((a, b) => {
24
+ const aTime = a.computed?.earlyStart.getTime() ?? 0;
25
+ const bTime = b.computed?.earlyStart.getTime() ?? 0;
26
+ return aTime - bTime;
27
+ });
28
+ }
29
+
30
+ export interface ProjectStats {
31
+ /** Leaf task count (summaries excluded). */
32
+ totalTasks: number;
33
+ /** Leaf tasks on the critical path (totalSlack <= 0). */
34
+ criticalTasks: number;
35
+ /** Leaf tasks with strictly negative totalSlack (the contract-trouble subset). */
36
+ lateTasks: number;
37
+ /** Latest earlyFinish across leaf tasks. Project's natural finish date. */
38
+ projectFinish?: Date;
39
+ /** Duration-weighted average progress across leaf tasks. 0 if no tasks. */
40
+ weightedProgress: number;
41
+ }
42
+
43
+ /**
44
+ * Summary statistics over a scheduled Project. Useful for status
45
+ * dashboards, programme-health badges in management views, and CI
46
+ * checks that flag "did the critical path grow this commit?".
47
+ */
48
+ export function getProjectStats(project: Project): ProjectStats {
49
+ const leaves = project.tasks.filter((t) => t.type !== 'summary');
50
+ if (leaves.length === 0) {
51
+ return {
52
+ totalTasks: 0,
53
+ criticalTasks: 0,
54
+ lateTasks: 0,
55
+ weightedProgress: 0,
56
+ };
57
+ }
58
+
59
+ let criticalTasks = 0;
60
+ let lateTasks = 0;
61
+ let projectFinishMs = Number.NEGATIVE_INFINITY;
62
+ let totalDuration = 0;
63
+ let progressDurationProduct = 0;
64
+
65
+ for (const t of leaves) {
66
+ if (t.computed?.isCritical) criticalTasks++;
67
+ if ((t.computed?.totalSlack ?? 0) < 0) lateTasks++;
68
+ const finish = t.computed?.earlyFinish?.getTime() ?? t.end.getTime();
69
+ if (finish > projectFinishMs) projectFinishMs = finish;
70
+ totalDuration += t.duration;
71
+ progressDurationProduct += t.duration * t.progress;
72
+ }
73
+
74
+ const weightedProgress = totalDuration > 0 ? progressDurationProduct / totalDuration : 0;
75
+
76
+ return {
77
+ totalTasks: leaves.length,
78
+ criticalTasks,
79
+ lateTasks,
80
+ projectFinish: Number.isFinite(projectFinishMs) ? new Date(projectFinishMs) : undefined,
81
+ weightedProgress,
82
+ };
83
+ }
@@ -0,0 +1,119 @@
1
+ // Baseline capture + variance API.
2
+ //
3
+ // Baselines snapshot the schedule at a point in time so we can compare the
4
+ // live programme against it later. Construction PMs need multiple
5
+ // baselines for variation-claim delay analysis under NZS 3910 / AS 4000
6
+ // (per ADR-003). We match MS Project's data model: up to 11 baselines
7
+ // (BaselineIndex 0..10) per project.
8
+
9
+ import type {
10
+ Baseline,
11
+ BaselineIndex,
12
+ BaselineTaskSnapshot,
13
+ Calendar,
14
+ Project,
15
+ Task,
16
+ TaskId,
17
+ } from './types';
18
+ import { workingMinutesBetween } from './working-time';
19
+
20
+ /**
21
+ * Snapshot the current schedule (task starts, ends, durations) as a baseline
22
+ * at the given index. Returns a new Project with the baseline added; the
23
+ * input is not mutated. If a baseline already exists at this index, it is
24
+ * replaced (matches MS Project "Save Baseline" behavior).
25
+ */
26
+ export function captureBaseline(
27
+ project: Project,
28
+ index: BaselineIndex,
29
+ options?: { name?: string; capturedAt?: Date },
30
+ ): Project {
31
+ const tasks = new Map<TaskId, BaselineTaskSnapshot>();
32
+ for (const task of project.tasks) {
33
+ tasks.set(task.id, {
34
+ start: new Date(task.start),
35
+ end: new Date(task.end),
36
+ duration: task.duration,
37
+ });
38
+ }
39
+ const baseline: Baseline = {
40
+ index,
41
+ name: options?.name,
42
+ capturedAt: options?.capturedAt ? new Date(options.capturedAt) : new Date(),
43
+ tasks,
44
+ };
45
+ const existing = project.baselines.findIndex((b) => b.index === index);
46
+ const newBaselines = [...project.baselines];
47
+ if (existing >= 0) {
48
+ newBaselines[existing] = baseline;
49
+ } else {
50
+ newBaselines.push(baseline);
51
+ }
52
+ return { ...project, baselines: newBaselines };
53
+ }
54
+
55
+ export interface TaskBaselineVariance {
56
+ /** Working-minute drift in start. Positive = task is later than baseline. */
57
+ startVariance: number;
58
+ /** Working-minute drift in finish. Positive = task is later than baseline. */
59
+ finishVariance: number;
60
+ /** Change in duration (calendar-minute units of the task model). */
61
+ durationVariance: number;
62
+ }
63
+
64
+ /**
65
+ * Compute baseline variance for every task in `project`. Returns a Map
66
+ * keyed by TaskId. Tasks not present in the named baseline are omitted
67
+ * from the returned map.
68
+ *
69
+ * `baselineIndex` selects the baseline on `project.baselines`. If the
70
+ * project has no baseline at that index, returns an empty Map (caller
71
+ * decides whether to treat this as a soft error or hard error).
72
+ *
73
+ * The calendar used for working-time variance arithmetic is the project
74
+ * default calendar, looked up by `project.defaultCalendarId`. If that
75
+ * lookup fails, throw an Error (consistent with `schedule()`'s behavior
76
+ * on a missing default calendar).
77
+ */
78
+ export function getTaskBaselineVarianceAll(
79
+ project: Project,
80
+ baselineIndex: BaselineIndex,
81
+ ): Map<TaskId, TaskBaselineVariance> {
82
+ const baseline = project.baselines.find((b) => b.index === baselineIndex);
83
+ if (!baseline) return new Map();
84
+
85
+ const calendar = project.calendars.find((c) => c.id === project.defaultCalendarId);
86
+ if (!calendar) {
87
+ throw new Error(
88
+ `Default calendar '${project.defaultCalendarId}' not found. Cannot compute baseline variance.`,
89
+ );
90
+ }
91
+
92
+ const result = new Map<TaskId, TaskBaselineVariance>();
93
+ for (const task of project.tasks) {
94
+ const variance = getTaskBaselineVariance(task, baseline, calendar);
95
+ if (variance !== undefined) {
96
+ result.set(task.id, variance);
97
+ }
98
+ }
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Compute working-time variance between a task's current dates and its
104
+ * snapshot in a baseline. Returns undefined if the task isn't in the
105
+ * baseline (added after the baseline was captured).
106
+ */
107
+ export function getTaskBaselineVariance(
108
+ task: Task,
109
+ baseline: Baseline,
110
+ calendar: Calendar,
111
+ ): TaskBaselineVariance | undefined {
112
+ const snap = baseline.tasks.get(task.id);
113
+ if (!snap) return undefined;
114
+ return {
115
+ startVariance: workingMinutesBetween(snap.start, task.start, calendar),
116
+ finishVariance: workingMinutesBetween(snap.end, task.end, calendar),
117
+ durationVariance: task.duration - snap.duration,
118
+ };
119
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Canterbury Anniversary Day observance dates. Unlike Matariki, this
3
+ * holiday is set annually by the Canterbury A&P Association — the Friday
4
+ * of Show Week — and doesn't reduce to a closed-form rule.
5
+ *
6
+ * Source: https://www.employment.govt.nz/leave-and-holidays/public-holidays/public-holidays-and-anniversary-dates/
7
+ *
8
+ * The governing rule (from date-holidays NZ.yaml and employment.govt.nz):
9
+ * "the Friday after the 2nd Tuesday in November" (Christchurch Show Day).
10
+ * This rule was used to derive and verify every entry below. 2026 was
11
+ * additionally confirmed directly from employment.govt.nz. 2027–2028 are
12
+ * rule-derived only (not yet published by employment.govt.nz at time of
13
+ * implementation).
14
+ *
15
+ * Every entry verified against the official listing on implementation.
16
+ * A typo or transcription error produces a silent wrong-date bug for
17
+ * every Canterbury-region consumer in the affected year.
18
+ *
19
+ * Years past `CANTERBURY_RANGE.maxYear` are unpublished and produce a
20
+ * specific error from the public API rather than a fabricated date.
21
+ */
22
+
23
+ // month is 1-indexed for readability against employment.govt.nz; converted
24
+ // to JS's 0-indexed month when constructing the Date.
25
+ const RAW: ReadonlyArray<readonly [year: number, month: number, day: number]> = [
26
+ [2022, 11, 11], // Fri 11 Nov 2022 — rule-verified
27
+ [2023, 11, 17], // Fri 17 Nov 2023 — rule-verified
28
+ [2024, 11, 15], // Fri 15 Nov 2024 — rule-verified
29
+ [2025, 11, 14], // Fri 14 Nov 2025 — rule-verified
30
+ [2026, 11, 13], // Fri 13 Nov 2026 — rule-verified + employment.govt.nz confirmed
31
+ [2027, 11, 12], // Fri 12 Nov 2027 — rule-derived (not yet on employment.govt.nz)
32
+ [2028, 11, 17], // Fri 17 Nov 2028 — rule-derived (not yet on employment.govt.nz)
33
+ // Append additional verified years here.
34
+ ];
35
+
36
+ export const CANTERBURY_DATES: Readonly<Record<number, Date>> = Object.freeze(
37
+ Object.fromEntries(RAW.map(([y, m, d]) => [y, new Date(y, m - 1, d)])),
38
+ );
39
+
40
+ const years = RAW.map(([y]) => y);
41
+ export const CANTERBURY_RANGE = Object.freeze({
42
+ minYear: Math.min(...years),
43
+ maxYear: Math.max(...years),
44
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Returns the Date for Easter Sunday in the given year, using the
3
+ * Anonymous Gregorian algorithm (Meeus/Jones/Butcher). Pure-integer
4
+ * arithmetic; valid for any year in the Gregorian calendar (1583+).
5
+ *
6
+ * Reference: Astronomical Algorithms (Meeus, 1991), §8.
7
+ */
8
+ export function computeEasterSunday(year: number): Date {
9
+ const a = year % 19;
10
+ const b = Math.floor(year / 100);
11
+ const c = year % 100;
12
+ const d = Math.floor(b / 4);
13
+ const e = b % 4;
14
+ const f = Math.floor((b + 8) / 25);
15
+ const g = Math.floor((b - f + 1) / 3);
16
+ const h = (19 * a + b - d - g + 15) % 30;
17
+ const i = Math.floor(c / 4);
18
+ const k = c % 4;
19
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
20
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
21
+ const monthZeroIndexed = Math.floor((h + l - 7 * m + 114) / 31) - 1; // March=2, April=3
22
+ const day = ((h + l - 7 * m + 114) % 31) + 1;
23
+
24
+ return new Date(year, monthZeroIndexed, day);
25
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Tiny shared date-arithmetic helpers used across the calendars module.
3
+ * Kept module-private (not re-exported from the package entry).
4
+ */
5
+
6
+ export function dayOfWeek(date: Date): number {
7
+ // 0 = Sun … 6 = Sat
8
+ return date.getDay();
9
+ }
10
+
11
+ export function addDays(date: Date, days: number): Date {
12
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
13
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Mondayisation rules per Holidays Act 2003 (NZ).
3
+ *
4
+ * Single-day mondayisation (Waitangi, ANZAC): if the statutory date falls
5
+ * on Saturday or Sunday, the observed date is the following Monday.
6
+ *
7
+ * Pair-rule mondayisation (Jan 1 / 2 Jan; Dec 25 / 26): the pair is
8
+ * inseparable. If either falls on a weekend, the pair shifts forward
9
+ * together — the first takes Monday (or stays where it is if already a
10
+ * weekday), the second takes Tuesday if its natural slot conflicts with
11
+ * the first's Monday, or stays unchanged otherwise.
12
+ */
13
+
14
+ import { addDays, dayOfWeek } from './date-utils.js';
15
+
16
+ export function mondayiseSingle(date: Date): Date {
17
+ const dow = dayOfWeek(date);
18
+ if (dow === 6) return addDays(date, 2); // Sat → Mon
19
+ if (dow === 0) return addDays(date, 1); // Sun → Mon
20
+ return new Date(date);
21
+ }
22
+
23
+ /**
24
+ * Apply the pair rule to a [first, second] pair where `second` is exactly
25
+ * one calendar day after `first` (Jan 1 / 2 Jan or Dec 25 / 26). Returns
26
+ * the observed [first, second] pair.
27
+ */
28
+ export function mondayisePair(first: Date, second: Date): [Date, Date] {
29
+ const firstDow = dayOfWeek(first);
30
+
31
+ // First on Sat → first observed Mon (+2), second observed Tue (+2)
32
+ if (firstDow === 6) {
33
+ return [addDays(first, 2), addDays(second, 2)];
34
+ }
35
+ // First on Sun → first observed Mon (+1), second observed Tue (+1).
36
+ // (Second's natural day is Mon, which is now taken by first; bump to Tue.)
37
+ if (firstDow === 0) {
38
+ return [addDays(first, 1), addDays(second, 1)];
39
+ }
40
+ // First on Fri → first stays Fri; second (originally Sat) shifts to Mon (+2).
41
+ if (firstDow === 5) {
42
+ return [new Date(first), addDays(second, 2)];
43
+ }
44
+ // First on Mon-Thu → no shift; second is naturally Tue–Fri.
45
+ return [new Date(first), new Date(second)];
46
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Date-arithmetic helpers for the named-Monday / named-Friday rules in the
3
+ * Holidays Act 2003 (NZ) regional anniversaries.
4
+ *
5
+ * All inputs/outputs are local-time `Date`s. No timezone normalisation —
6
+ * the schedule engine treats holiday-exception dates as calendar-day
7
+ * markers, not instants.
8
+ */
9
+
10
+ import { addDays, dayOfWeek } from './date-utils.js';
11
+
12
+ /**
13
+ * The N-th Monday of the given month. `month` is zero-indexed (Jan = 0).
14
+ * `n` is 1-indexed (1 = first Monday).
15
+ */
16
+ export function nthMondayOfMonth(year: number, month: number, n: number): Date {
17
+ const firstOfMonth = new Date(year, month, 1);
18
+ const firstDow = dayOfWeek(firstOfMonth);
19
+ // Days to add to reach the first Monday of the month.
20
+ // (8 - dow) % 7: Sun(0)→1, Mon(1)→0, Tue(2)→6, Wed(3)→5, Thu(4)→4, Fri(5)→3, Sat(6)→2
21
+ const offsetToFirstMonday = (8 - firstDow) % 7;
22
+ return new Date(year, month, 1 + offsetToFirstMonday + (n - 1) * 7);
23
+ }
24
+
25
+ /**
26
+ * The first Monday strictly after the given date.
27
+ */
28
+ export function firstMondayAfter(date: Date): Date {
29
+ const dow = dayOfWeek(date);
30
+ // Days from `date` to the next Monday strictly after it.
31
+ // Sun(0): +1, Mon(1): +7, Tue(2): +6, Wed(3): +5, Thu(4): +4, Fri(5): +3, Sat(6): +2
32
+ const delta = dow === 0 ? 1 : (8 - dow) % 7 || 7;
33
+ return addDays(date, delta);
34
+ }
35
+
36
+ /**
37
+ * The Friday immediately before the given date. Used for Hawke's Bay
38
+ * (Friday before Labour Day).
39
+ */
40
+ export function fridayBefore(date: Date): Date {
41
+ const dow = dayOfWeek(date);
42
+ // Days BACK from `date` to the most recent Friday strictly before it.
43
+ // Sun(0): -2, Mon(1): -3, Tue(2): -4, Wed(3): -5, Thu(4): -6, Fri(5): -7, Sat(6): -1
44
+ const delta = -((dow + 2) % 7 || 7);
45
+ return addDays(date, delta);
46
+ }
47
+
48
+ /**
49
+ * The Monday nearest the given calendar date. No tie-break logic needed:
50
+ * no day-of-week produces equidistant prev/next Monday — the function is
51
+ * well-defined for all 7 inputs.
52
+ *
53
+ * `month` is zero-indexed.
54
+ */
55
+ export function nearestMondayTo(year: number, month: number, day: number): Date {
56
+ const target = new Date(year, month, day);
57
+ const dow = dayOfWeek(target);
58
+ if (dow === 1) return target; // Already Monday
59
+
60
+ // Distance to previous Monday: dow - 1 days back if dow ≥ 1 else 6.
61
+ // Distance to next Monday: 8 - dow if dow ≥ 1 else 1.
62
+ const distPrev = dow === 0 ? 6 : dow - 1;
63
+ const distNext = dow === 0 ? 1 : 8 - dow;
64
+ return distPrev <= distNext ? addDays(target, -distPrev) : addDays(target, distNext);
65
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Matariki observance dates per the Te Kāhui o Matariki Public Holiday Act
3
+ * 2022, Schedule 1. Pre-announced for 30 years (2022–2052); the Act sets
4
+ * each date explicitly rather than via formula.
5
+ *
6
+ * Primary source: Te Kāhui o Matariki Public Holiday Act 2022, Schedule 1
7
+ * https://www.legislation.govt.nz/act/public/2022/0014/latest/whole.html
8
+ * (legislation.govt.nz returned HTTP 403 to WebFetch; cross-referenced via:)
9
+ *
10
+ * Verified sources used during implementation:
11
+ * 1. https://raw.githubusercontent.com/commenthol/date-holidays/master/data/countries/NZ.yaml
12
+ * (date-holidays library, tracks official NZ public holiday data)
13
+ * 2. https://github.com/commenthol/date-holidays/blob/master/data/countries/NZ.yaml
14
+ * (same data, HTML view — identical 31-entry list)
15
+ * 3. Wikipedia "Matariki" article (2022–2035 confirmed matching)
16
+ * 4. Local JS Date validation: all 31 entries confirmed as Friday, June/July
17
+ *
18
+ * Every entry was verified to be a Friday falling in June or July. A typo
19
+ * here is invisible to typecheck and rule-tests — it produces a silent
20
+ * wrong-date bug for every consumer in the affected year.
21
+ */
22
+
23
+ export const MATARIKI_RANGE = { minYear: 2022, maxYear: 2052 } as const;
24
+
25
+ // month is 1-indexed for readability against the Act text; converted to
26
+ // JS's 0-indexed month when constructing the Date.
27
+ const RAW: ReadonlyArray<readonly [year: number, month: number, day: number]> = [
28
+ [2022, 6, 24], // Fri 24 Jun 2022
29
+ [2023, 7, 14], // Fri 14 Jul 2023
30
+ [2024, 6, 28], // Fri 28 Jun 2024
31
+ [2025, 6, 20], // Fri 20 Jun 2025
32
+ [2026, 7, 10], // Fri 10 Jul 2026
33
+ [2027, 6, 25], // Fri 25 Jun 2027
34
+ [2028, 7, 14], // Fri 14 Jul 2028
35
+ [2029, 7, 6], // Fri 6 Jul 2029
36
+ [2030, 6, 21], // Fri 21 Jun 2030
37
+ [2031, 7, 11], // Fri 11 Jul 2031
38
+ [2032, 7, 2], // Fri 2 Jul 2032
39
+ [2033, 6, 24], // Fri 24 Jun 2033
40
+ [2034, 7, 7], // Fri 7 Jul 2034
41
+ [2035, 6, 29], // Fri 29 Jun 2035
42
+ [2036, 7, 18], // Fri 18 Jul 2036
43
+ [2037, 7, 10], // Fri 10 Jul 2037
44
+ [2038, 6, 25], // Fri 25 Jun 2038
45
+ [2039, 7, 15], // Fri 15 Jul 2039
46
+ [2040, 7, 6], // Fri 6 Jul 2040
47
+ [2041, 7, 19], // Fri 19 Jul 2041
48
+ [2042, 7, 11], // Fri 11 Jul 2042
49
+ [2043, 7, 3], // Fri 3 Jul 2043
50
+ [2044, 6, 24], // Fri 24 Jun 2044
51
+ [2045, 7, 7], // Fri 7 Jul 2045
52
+ [2046, 6, 29], // Fri 29 Jun 2046
53
+ [2047, 7, 19], // Fri 19 Jul 2047
54
+ [2048, 7, 3], // Fri 3 Jul 2048
55
+ [2049, 6, 25], // Fri 25 Jun 2049
56
+ [2050, 7, 15], // Fri 15 Jul 2050
57
+ [2051, 6, 30], // Fri 30 Jun 2051
58
+ [2052, 6, 21], // Fri 21 Jun 2052
59
+ ];
60
+
61
+ export const MATARIKI_DATES: Readonly<Record<number, Date>> = Object.freeze(
62
+ Object.fromEntries(RAW.map(([y, m, d]) => [y, new Date(y, m - 1, d)])),
63
+ );