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.
- package/CHANGELOG.md +57 -0
- package/README.md +5 -0
- package/dist/export/index.cjs +1 -0
- package/dist/export/index.d.cts +112 -0
- package/dist/export/index.d.cts.map +1 -0
- package/dist/export/index.d.ts +112 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +1 -0
- package/dist/index.cjs +3492 -0
- package/dist/index.d.cts +628 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +628 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3461 -0
- package/dist/pdf-CAQDrX0w.cjs +120 -0
- package/dist/pdf-CBaoJRTI.js +120 -0
- package/dist/png-C8t74695.cjs +88 -0
- package/dist/png-DKZeKnRh.js +88 -0
- package/dist/xlsx-5FRPFck7.js +89 -0
- package/dist/xlsx-Gh5L_NL3.cjs +111 -0
- package/package.json +86 -0
- package/src/Gantt.css +23 -0
- package/src/Gantt.tsx +636 -0
- package/src/SpikeGantt.tsx +114 -0
- package/src/analysis.ts +83 -0
- package/src/baseline.ts +119 -0
- package/src/calendars/canterbury-table.ts +44 -0
- package/src/calendars/internal/computus.ts +25 -0
- package/src/calendars/internal/date-utils.ts +13 -0
- package/src/calendars/internal/mondayisation.ts +46 -0
- package/src/calendars/internal/month-rules.ts +65 -0
- package/src/calendars/matariki-table.ts +63 -0
- package/src/calendars/nz-holidays.ts +214 -0
- package/src/editing/command-history.ts +78 -0
- package/src/editing/commands.ts +327 -0
- package/src/editing/composite-command.ts +64 -0
- package/src/editing/draft-project.ts +59 -0
- package/src/editing/errors.ts +14 -0
- package/src/editing/factories.ts +92 -0
- package/src/editing/use-editable-project.ts +122 -0
- package/src/export/index.ts +12 -0
- package/src/export/offscreen.tsx +89 -0
- package/src/export/pdf-dimensions.ts +64 -0
- package/src/export/pdf.ts +68 -0
- package/src/export/png.ts +48 -0
- package/src/export/types.ts +42 -0
- package/src/export/xlsx.ts +70 -0
- package/src/index.ts +89 -0
- package/src/mspdi/parse.ts +820 -0
- package/src/mspdi/serialize.ts +352 -0
- package/src/mspdi/types.ts +53 -0
- package/src/schedule.ts +470 -0
- package/src/topological-sort.ts +51 -0
- package/src/types.ts +254 -0
- package/src/visibility.ts +35 -0
- 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
|
+
}
|
package/src/analysis.ts
ADDED
|
@@ -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
|
+
}
|
package/src/baseline.ts
ADDED
|
@@ -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
|
+
);
|