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,214 @@
|
|
|
1
|
+
// NZ public-holiday + regional-anniversary pre-seed for the working-time
|
|
2
|
+
// calendar engine. Hybrid generation: pure-TS rules for the holidays whose
|
|
3
|
+
// observed date reduces to a formula; small static tables for the ones
|
|
4
|
+
// that don't (Matariki — set by Act; Canterbury Show Day — set annually).
|
|
5
|
+
//
|
|
6
|
+
// Range supported: 2022 (Matariki Act adoption) through 2052 (last year
|
|
7
|
+
// the Act pre-announces). Calls outside that range throw RangeError.
|
|
8
|
+
|
|
9
|
+
import type { Calendar, CalendarException, CalendarId, WorkInterval } from '../types.js';
|
|
10
|
+
import { CANTERBURY_DATES, CANTERBURY_RANGE } from './canterbury-table.js';
|
|
11
|
+
import { computeEasterSunday } from './internal/computus.js';
|
|
12
|
+
import { mondayisePair, mondayiseSingle } from './internal/mondayisation.js';
|
|
13
|
+
import {
|
|
14
|
+
firstMondayAfter,
|
|
15
|
+
fridayBefore,
|
|
16
|
+
nearestMondayTo,
|
|
17
|
+
nthMondayOfMonth,
|
|
18
|
+
} from './internal/month-rules.js';
|
|
19
|
+
import { MATARIKI_DATES, MATARIKI_RANGE } from './matariki-table.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The 13 statutory NZ regions with their own anniversary day, plus Northland
|
|
23
|
+
* which observes Waitangi Day per Holidays Act 2003 (no extra exception
|
|
24
|
+
* is added for `'northland'` — the region value is accepted, the
|
|
25
|
+
* national-only set is returned).
|
|
26
|
+
*/
|
|
27
|
+
export type NZRegion =
|
|
28
|
+
| 'auckland'
|
|
29
|
+
| 'canterbury'
|
|
30
|
+
| 'chatham-islands'
|
|
31
|
+
| 'hawkes-bay'
|
|
32
|
+
| 'marlborough'
|
|
33
|
+
| 'nelson'
|
|
34
|
+
| 'northland'
|
|
35
|
+
| 'otago'
|
|
36
|
+
| 'south-canterbury'
|
|
37
|
+
| 'southland'
|
|
38
|
+
| 'taranaki'
|
|
39
|
+
| 'wellington'
|
|
40
|
+
| 'westland';
|
|
41
|
+
|
|
42
|
+
export interface NZDefaultCalendarOptions {
|
|
43
|
+
/** Year (single) or years (array). 2022 ≤ year ≤ 2052. Duplicates deduplicated. */
|
|
44
|
+
years: number | number[];
|
|
45
|
+
/** Optional regional anniversary on top of the 11 national holidays. */
|
|
46
|
+
region?: NZRegion;
|
|
47
|
+
/**
|
|
48
|
+
* Override the default Mon–Fri 8am–5pm working week.
|
|
49
|
+
* 7 entries, Sunday=0 … Saturday=6.
|
|
50
|
+
*/
|
|
51
|
+
workWeek?: WorkInterval[][];
|
|
52
|
+
/** Calendar.id. Default: 'nz-default'. */
|
|
53
|
+
id?: CalendarId;
|
|
54
|
+
/** Calendar.name. Default: 'New Zealand Standard'. */
|
|
55
|
+
name?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MIN_YEAR = MATARIKI_RANGE.minYear; // 2022
|
|
59
|
+
const MAX_YEAR = MATARIKI_RANGE.maxYear; // 2052
|
|
60
|
+
|
|
61
|
+
const DEFAULT_INTERVAL: WorkInterval = { startMinutes: 8 * 60, endMinutes: 17 * 60 };
|
|
62
|
+
const DEFAULT_WORK_WEEK: WorkInterval[][] = [
|
|
63
|
+
[], // Sun
|
|
64
|
+
[DEFAULT_INTERVAL], // Mon
|
|
65
|
+
[DEFAULT_INTERVAL], // Tue
|
|
66
|
+
[DEFAULT_INTERVAL], // Wed
|
|
67
|
+
[DEFAULT_INTERVAL], // Thu
|
|
68
|
+
[DEFAULT_INTERVAL], // Fri
|
|
69
|
+
[], // Sat
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export function nzPublicHolidays(years: number | number[], region?: NZRegion): CalendarException[] {
|
|
73
|
+
const yearList = normaliseYears(years);
|
|
74
|
+
const all: CalendarException[] = [];
|
|
75
|
+
for (const year of yearList) {
|
|
76
|
+
all.push(...nationalHolidays(year));
|
|
77
|
+
if (region) {
|
|
78
|
+
const regional = regionalAnniversary(year, region);
|
|
79
|
+
if (regional) all.push(regional);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return all.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normaliseYears(input: number | number[]): number[] {
|
|
86
|
+
const list = Array.isArray(input) ? [...new Set(input)] : [input];
|
|
87
|
+
for (const y of list) {
|
|
88
|
+
if (y < MIN_YEAR || y > MAX_YEAR) {
|
|
89
|
+
throw new RangeError(
|
|
90
|
+
`nzPublicHolidays: year ${y} out of supported range ${MIN_YEAR}-${MAX_YEAR}. ` +
|
|
91
|
+
`Range is bounded by the Te Kāhui o Matariki Public Holiday Act 2022 Schedule 1.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return list.sort((a, b) => a - b);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function nationalHolidays(year: number): CalendarException[] {
|
|
99
|
+
const out: CalendarException[] = [];
|
|
100
|
+
|
|
101
|
+
// New Year + 2 January (pair rule)
|
|
102
|
+
const [ny, jan2] = mondayisePair(new Date(year, 0, 1), new Date(year, 0, 2));
|
|
103
|
+
out.push(makeException(ny, "New Year's Day", new Date(year, 0, 1)));
|
|
104
|
+
out.push(makeException(jan2, '2 January', new Date(year, 0, 2)));
|
|
105
|
+
|
|
106
|
+
// Waitangi Day (single-day mondayisation)
|
|
107
|
+
const waitangi = mondayiseSingle(new Date(year, 1, 6));
|
|
108
|
+
out.push(makeException(waitangi, 'Waitangi Day', new Date(year, 1, 6)));
|
|
109
|
+
|
|
110
|
+
// Good Friday + Easter Monday (Easter Sunday ± 2 / +1)
|
|
111
|
+
const easterSun = computeEasterSunday(year);
|
|
112
|
+
const goodFri = new Date(year, easterSun.getMonth(), easterSun.getDate() - 2);
|
|
113
|
+
const easterMon = new Date(year, easterSun.getMonth(), easterSun.getDate() + 1);
|
|
114
|
+
out.push(makeException(goodFri, 'Good Friday'));
|
|
115
|
+
out.push(makeException(easterMon, 'Easter Monday'));
|
|
116
|
+
|
|
117
|
+
// ANZAC Day (single-day mondayisation)
|
|
118
|
+
const anzac = mondayiseSingle(new Date(year, 3, 25));
|
|
119
|
+
out.push(makeException(anzac, 'ANZAC Day', new Date(year, 3, 25)));
|
|
120
|
+
|
|
121
|
+
// King's Birthday (first Monday of June)
|
|
122
|
+
out.push(makeException(nthMondayOfMonth(year, 5, 1), "King's Birthday"));
|
|
123
|
+
|
|
124
|
+
// Matariki (static table — guaranteed in range by the gate above)
|
|
125
|
+
// biome-ignore lint/style/noNonNullAssertion: year is range-gated 2022-2052; entry always present
|
|
126
|
+
out.push(makeException(MATARIKI_DATES[year]!, 'Matariki'));
|
|
127
|
+
|
|
128
|
+
// Labour Day (fourth Monday of October)
|
|
129
|
+
out.push(makeException(nthMondayOfMonth(year, 9, 4), 'Labour Day'));
|
|
130
|
+
|
|
131
|
+
// Christmas + Boxing Day (pair rule)
|
|
132
|
+
const [christmas, boxing] = mondayisePair(new Date(year, 11, 25), new Date(year, 11, 26));
|
|
133
|
+
out.push(makeException(christmas, 'Christmas Day', new Date(year, 11, 25)));
|
|
134
|
+
out.push(makeException(boxing, 'Boxing Day', new Date(year, 11, 26)));
|
|
135
|
+
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function regionalAnniversary(year: number, region: NZRegion): CalendarException | null {
|
|
140
|
+
switch (region) {
|
|
141
|
+
case 'auckland':
|
|
142
|
+
return makeException(nearestMondayTo(year, 0, 29), 'Auckland Anniversary');
|
|
143
|
+
case 'wellington':
|
|
144
|
+
return makeException(nearestMondayTo(year, 0, 22), 'Wellington Anniversary');
|
|
145
|
+
case 'nelson':
|
|
146
|
+
return makeException(nearestMondayTo(year, 1, 1), 'Nelson Anniversary');
|
|
147
|
+
case 'otago':
|
|
148
|
+
return makeException(nearestMondayTo(year, 2, 23), 'Otago Anniversary');
|
|
149
|
+
case 'taranaki':
|
|
150
|
+
return makeException(nthMondayOfMonth(year, 2, 2), 'Taranaki Anniversary');
|
|
151
|
+
case 'southland': {
|
|
152
|
+
const easterSun = computeEasterSunday(year);
|
|
153
|
+
const easterTue = new Date(year, easterSun.getMonth(), easterSun.getDate() + 2);
|
|
154
|
+
return makeException(easterTue, 'Southland Anniversary');
|
|
155
|
+
}
|
|
156
|
+
case 'south-canterbury':
|
|
157
|
+
return makeException(nthMondayOfMonth(year, 8, 4), 'South Canterbury Anniversary');
|
|
158
|
+
case 'hawkes-bay': {
|
|
159
|
+
const labourDay = nthMondayOfMonth(year, 9, 4);
|
|
160
|
+
return makeException(fridayBefore(labourDay), "Hawke's Bay Anniversary");
|
|
161
|
+
}
|
|
162
|
+
case 'marlborough': {
|
|
163
|
+
const labourDay = nthMondayOfMonth(year, 9, 4);
|
|
164
|
+
return makeException(firstMondayAfter(labourDay), 'Marlborough Anniversary');
|
|
165
|
+
}
|
|
166
|
+
case 'canterbury': {
|
|
167
|
+
const date = CANTERBURY_DATES[year];
|
|
168
|
+
if (!date) {
|
|
169
|
+
throw new RangeError(
|
|
170
|
+
`nzPublicHolidays: Canterbury Anniversary Day for ${year} is not in the verified ` +
|
|
171
|
+
`static table (range ${CANTERBURY_RANGE.minYear}-${CANTERBURY_RANGE.maxYear}). ` +
|
|
172
|
+
`Show Day is set annually by the Canterbury A&P Association; ` +
|
|
173
|
+
`add the verified date from employment.govt.nz to canterbury-table.ts to extend coverage.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return makeException(date, 'Canterbury Anniversary');
|
|
177
|
+
}
|
|
178
|
+
case 'westland':
|
|
179
|
+
return makeException(nthMondayOfMonth(year, 11, 1), 'Westland Anniversary');
|
|
180
|
+
case 'chatham-islands':
|
|
181
|
+
return makeException(nearestMondayTo(year, 10, 30), 'Chatham Islands Anniversary');
|
|
182
|
+
case 'northland':
|
|
183
|
+
// Per Holidays Act 2003: Northland observes Waitangi Day as its
|
|
184
|
+
// anniversary. No extra exception is added.
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function makeException(
|
|
190
|
+
observedDate: Date,
|
|
191
|
+
baseName: string,
|
|
192
|
+
statutoryDate?: Date,
|
|
193
|
+
): CalendarException {
|
|
194
|
+
const moved =
|
|
195
|
+
statutoryDate &&
|
|
196
|
+
(observedDate.getFullYear() !== statutoryDate.getFullYear() ||
|
|
197
|
+
observedDate.getMonth() !== statutoryDate.getMonth() ||
|
|
198
|
+
observedDate.getDate() !== statutoryDate.getDate());
|
|
199
|
+
return {
|
|
200
|
+
date: new Date(observedDate.getFullYear(), observedDate.getMonth(), observedDate.getDate()),
|
|
201
|
+
isWorking: false,
|
|
202
|
+
name: moved ? `${baseName} (observed)` : baseName,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function nzDefaultCalendar(options: NZDefaultCalendarOptions): Calendar {
|
|
207
|
+
const { years, region, workWeek, id, name } = options;
|
|
208
|
+
return {
|
|
209
|
+
id: id ?? 'nz-default',
|
|
210
|
+
name: name ?? 'New Zealand Standard',
|
|
211
|
+
workWeek: workWeek ?? DEFAULT_WORK_WEEK,
|
|
212
|
+
exceptions: nzPublicHolidays(years, region),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Project } from '../types.js';
|
|
2
|
+
import type { EditCommand } from './commands.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Past/future command stacks for undo/redo. Immutable value type;
|
|
6
|
+
* every operation returns a new CommandHistory.
|
|
7
|
+
*
|
|
8
|
+
* Per ADR-006: a new pushCommand after undo clears the future stack
|
|
9
|
+
* (standard editor behaviour — new edit branches off).
|
|
10
|
+
*/
|
|
11
|
+
export interface CommandHistory {
|
|
12
|
+
readonly past: ReadonlyArray<EditCommand>;
|
|
13
|
+
readonly future: ReadonlyArray<EditCommand>;
|
|
14
|
+
readonly canUndo: boolean;
|
|
15
|
+
readonly canRedo: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function newHistory(): CommandHistory {
|
|
19
|
+
return { past: [], future: [], canUndo: false, canRedo: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function pushCommand(h: CommandHistory, command: EditCommand): CommandHistory {
|
|
23
|
+
return {
|
|
24
|
+
past: [...h.past, command],
|
|
25
|
+
future: [],
|
|
26
|
+
canUndo: true,
|
|
27
|
+
canRedo: false,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UndoResult {
|
|
32
|
+
readonly nextHistory: CommandHistory;
|
|
33
|
+
readonly nextProject: Project;
|
|
34
|
+
readonly undoneCommand: EditCommand;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function undo(h: CommandHistory, project: Project): UndoResult | null {
|
|
38
|
+
if (h.past.length === 0) return null;
|
|
39
|
+
const cmd = h.past[h.past.length - 1];
|
|
40
|
+
if (!cmd) return null; // unreachable under noUncheckedIndexedAccess; preserves type narrowing
|
|
41
|
+
const inverse = cmd.inverse(project);
|
|
42
|
+
const nextProject = inverse.apply(project);
|
|
43
|
+
const nextPast = h.past.slice(0, -1);
|
|
44
|
+
return {
|
|
45
|
+
nextHistory: {
|
|
46
|
+
past: nextPast,
|
|
47
|
+
future: [...h.future, cmd],
|
|
48
|
+
canUndo: nextPast.length > 0,
|
|
49
|
+
canRedo: true,
|
|
50
|
+
},
|
|
51
|
+
nextProject,
|
|
52
|
+
undoneCommand: cmd,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RedoResult {
|
|
57
|
+
readonly nextHistory: CommandHistory;
|
|
58
|
+
readonly nextProject: Project;
|
|
59
|
+
readonly redoneCommand: EditCommand;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function redo(h: CommandHistory, project: Project): RedoResult | null {
|
|
63
|
+
if (h.future.length === 0) return null;
|
|
64
|
+
const cmd = h.future[h.future.length - 1];
|
|
65
|
+
if (!cmd) return null; // unreachable under noUncheckedIndexedAccess; preserves type narrowing
|
|
66
|
+
const nextProject = cmd.apply(project);
|
|
67
|
+
const nextFuture = h.future.slice(0, -1);
|
|
68
|
+
return {
|
|
69
|
+
nextHistory: {
|
|
70
|
+
past: [...h.past, cmd],
|
|
71
|
+
future: nextFuture,
|
|
72
|
+
canUndo: true,
|
|
73
|
+
canRedo: nextFuture.length > 0,
|
|
74
|
+
},
|
|
75
|
+
nextProject,
|
|
76
|
+
redoneCommand: cmd,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type { Link, LinkId, Project, Task, TaskId } from '../types.js';
|
|
2
|
+
import { EditError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The contract every edit command must satisfy. apply must be pure
|
|
6
|
+
* (no mutation, no I/O) — returns a new Project. inverse(project)
|
|
7
|
+
* captures the state needed to reverse the edit when applied to the
|
|
8
|
+
* post-edit Project.
|
|
9
|
+
*
|
|
10
|
+
* Per ADR-006: every edit flows through schedule() recompute at the
|
|
11
|
+
* hook layer, not inside apply(). Commands operate on raw data only.
|
|
12
|
+
*/
|
|
13
|
+
export interface EditCommand {
|
|
14
|
+
/** Discriminator for command kind. */
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
/** Human-readable label for UI undo text (e.g. "Rename Foundation pour"). */
|
|
17
|
+
readonly label: string;
|
|
18
|
+
/** Pure: applies the edit to a Project, returning a new Project. Throws EditError on invalid input. */
|
|
19
|
+
apply(project: Project): Project;
|
|
20
|
+
/**
|
|
21
|
+
* Returns an inverse command: applying inverse(P) on apply(P) yields P.
|
|
22
|
+
*
|
|
23
|
+
* Pre-edit state capture timing varies by command kind:
|
|
24
|
+
* - Create*Command: constructor argument is the inverse's only input
|
|
25
|
+
* - Update*Command, Delete*Command: snapshot pre-state internally during
|
|
26
|
+
* apply() (single-use per instance). The `project` argument is
|
|
27
|
+
* accepted for interface consistency but ignored.
|
|
28
|
+
*
|
|
29
|
+
* Consumers (CompositeCommand, CommandHistory) only need to call apply()
|
|
30
|
+
* before inverse() and pass the current project to inverse().
|
|
31
|
+
*/
|
|
32
|
+
inverse(project: Project): EditCommand;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class CreateTaskCommand implements EditCommand {
|
|
36
|
+
readonly kind = 'create-task';
|
|
37
|
+
readonly label: string;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly task: Task,
|
|
41
|
+
private readonly insertAt?: number,
|
|
42
|
+
) {
|
|
43
|
+
this.label = `Create task "${task.text}"`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
apply(project: Project): Project {
|
|
47
|
+
if (project.tasks.some((t) => t.id === this.task.id)) {
|
|
48
|
+
throw new EditError(`duplicate task id ${String(this.task.id)}`, this.kind);
|
|
49
|
+
}
|
|
50
|
+
const tasks = [...project.tasks];
|
|
51
|
+
if (this.insertAt === undefined) {
|
|
52
|
+
tasks.push(this.task);
|
|
53
|
+
} else {
|
|
54
|
+
tasks.splice(this.insertAt, 0, this.task);
|
|
55
|
+
}
|
|
56
|
+
return { ...project, tasks };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
inverse(_project: Project): EditCommand {
|
|
60
|
+
return new DeleteTaskCommand(this.task.id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class UpdateTaskCommand implements EditCommand {
|
|
65
|
+
readonly kind = 'update-task';
|
|
66
|
+
readonly label: string;
|
|
67
|
+
// Snapshot the pre-edit task state during apply() rather than reading it
|
|
68
|
+
// in inverse(project). Required because the patched fields in `project`
|
|
69
|
+
// at inverse() time are the post-edit values; the originals only survive
|
|
70
|
+
// via this capture. Side effect: each command instance is effectively
|
|
71
|
+
// single-use — calling apply() twice overwrites the snapshot, which
|
|
72
|
+
// is fine for the redo-then-undo flow but means consumers should not
|
|
73
|
+
// share a command instance across unrelated apply-sites.
|
|
74
|
+
private originalTask?: Task;
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
private readonly taskId: TaskId,
|
|
78
|
+
private readonly patch: Partial<Task>,
|
|
79
|
+
customLabel?: string,
|
|
80
|
+
) {
|
|
81
|
+
if (customLabel !== undefined) {
|
|
82
|
+
this.label = customLabel;
|
|
83
|
+
} else {
|
|
84
|
+
const keys = Object.keys(patch);
|
|
85
|
+
this.label =
|
|
86
|
+
keys.length === 1
|
|
87
|
+
? `Update task "${String(taskId)}" (${keys[0]})`
|
|
88
|
+
: `Update task "${String(taskId)}"`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
apply(project: Project): Project {
|
|
93
|
+
const idx = project.tasks.findIndex((t) => t.id === this.taskId);
|
|
94
|
+
if (idx === -1) {
|
|
95
|
+
throw new EditError(`missing task ${String(this.taskId)}`, this.kind);
|
|
96
|
+
}
|
|
97
|
+
const tasks = [...project.tasks];
|
|
98
|
+
this.originalTask = tasks[idx];
|
|
99
|
+
tasks[idx] = Object.assign({}, tasks[idx], this.patch);
|
|
100
|
+
return { ...project, tasks };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
inverse(_project: Project): EditCommand {
|
|
104
|
+
if (!this.originalTask) {
|
|
105
|
+
throw new EditError(`inverse: apply() was not called on this command`, this.kind);
|
|
106
|
+
}
|
|
107
|
+
// Capture the value of each patched key from the original task.
|
|
108
|
+
const previousPatch: Partial<Task> = {};
|
|
109
|
+
for (const key of Object.keys(this.patch)) {
|
|
110
|
+
const k = key as keyof Task;
|
|
111
|
+
(previousPatch as Record<keyof Task, unknown>)[k] = (
|
|
112
|
+
this.originalTask as Record<keyof Task, unknown>
|
|
113
|
+
)[k];
|
|
114
|
+
}
|
|
115
|
+
return new UpdateTaskCommand(this.taskId, previousPatch);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class DeleteTaskCommand implements EditCommand {
|
|
120
|
+
readonly kind = 'delete-task';
|
|
121
|
+
readonly label: string;
|
|
122
|
+
// Snapshot pre-state during apply() — same pattern as UpdateTaskCommand.
|
|
123
|
+
// Required because CommandHistory.undo calls cmd.inverse(currentProject)
|
|
124
|
+
// where currentProject is the POST-delete state (no longer has the task
|
|
125
|
+
// or its incident links to read from).
|
|
126
|
+
private snapshot?: {
|
|
127
|
+
task: Task;
|
|
128
|
+
taskIndex: number;
|
|
129
|
+
// Captured as (linkIndex, link) pairs to restore at original positions.
|
|
130
|
+
incidentLinkPositions: Array<[number, Link]>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
constructor(private readonly taskId: TaskId) {
|
|
134
|
+
this.label = `Delete task "${String(taskId)}"`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
apply(project: Project): Project {
|
|
138
|
+
const target = project.tasks.find((t) => t.id === this.taskId);
|
|
139
|
+
if (!target) {
|
|
140
|
+
throw new EditError(`missing task ${String(this.taskId)}`, this.kind);
|
|
141
|
+
}
|
|
142
|
+
const taskIndex = project.tasks.indexOf(target);
|
|
143
|
+
// Capture incident links with their original indices.
|
|
144
|
+
const incidentLinkPositions: Array<[number, Link]> = [];
|
|
145
|
+
for (let i = 0; i < project.links.length; i++) {
|
|
146
|
+
const link = project.links[i];
|
|
147
|
+
if (link && (link.source === this.taskId || link.target === this.taskId)) {
|
|
148
|
+
incidentLinkPositions.push([i, link]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.snapshot = { task: target, taskIndex, incidentLinkPositions };
|
|
152
|
+
|
|
153
|
+
const tasks = project.tasks.filter((t) => t.id !== this.taskId);
|
|
154
|
+
const links = project.links.filter((l) => l.source !== this.taskId && l.target !== this.taskId);
|
|
155
|
+
return { ...project, tasks, links };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
inverse(_project: Project): EditCommand {
|
|
159
|
+
if (!this.snapshot) {
|
|
160
|
+
throw new EditError(`inverse: apply() was not called on this command`, this.kind);
|
|
161
|
+
}
|
|
162
|
+
const snapshotData = this.snapshot;
|
|
163
|
+
const { task: snapshotTask, taskIndex, incidentLinkPositions } = snapshotData;
|
|
164
|
+
|
|
165
|
+
// Return an ad-hoc EditCommand that restores both task + links atomically.
|
|
166
|
+
// Not exported — only reachable as the inverse of DeleteTaskCommand.
|
|
167
|
+
const restoreCommand: EditCommand = {
|
|
168
|
+
kind: 'restore-task',
|
|
169
|
+
label: `Restore task "${String(this.taskId)}"`,
|
|
170
|
+
apply(p: Project): Project {
|
|
171
|
+
if (p.tasks.some((t) => t.id === snapshotTask.id)) {
|
|
172
|
+
throw new EditError(`duplicate task id ${String(snapshotTask.id)}`, 'restore-task');
|
|
173
|
+
}
|
|
174
|
+
// Restore task at original index (clamped to current length).
|
|
175
|
+
const tasks = [...p.tasks];
|
|
176
|
+
const insertTaskAt = Math.min(taskIndex, tasks.length);
|
|
177
|
+
tasks.splice(insertTaskAt, 0, snapshotTask);
|
|
178
|
+
|
|
179
|
+
// Restore links at their original indices, adjusting for deletions.
|
|
180
|
+
// INVARIANT: incidentLinkPositions is guaranteed ascending-by-index
|
|
181
|
+
// because the capture loop in apply() walks 0..N. Splice-in-place
|
|
182
|
+
// only produces the correct final order when fed sorted-ascending
|
|
183
|
+
// positions — if a future refactor breaks the capture order, this
|
|
184
|
+
// restoration breaks silently. If insertion order ever becomes
|
|
185
|
+
// unsortable, switch to a single-pass rebuild keyed on original idx.
|
|
186
|
+
const links = [...p.links];
|
|
187
|
+
for (const [originalIdx, link] of incidentLinkPositions) {
|
|
188
|
+
links.splice(originalIdx, 0, link);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...p,
|
|
193
|
+
tasks,
|
|
194
|
+
links,
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
inverse(_p: Project): EditCommand {
|
|
198
|
+
return new DeleteTaskCommand(snapshotTask.id);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
return restoreCommand;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class CreateLinkCommand implements EditCommand {
|
|
206
|
+
readonly kind = 'create-link';
|
|
207
|
+
readonly label: string;
|
|
208
|
+
|
|
209
|
+
constructor(private readonly link: Link) {
|
|
210
|
+
this.label = `Link ${String(link.source)} → ${String(link.target)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
apply(project: Project): Project {
|
|
214
|
+
if (this.link.source === this.link.target) {
|
|
215
|
+
throw new EditError(`self-link not allowed (${String(this.link.source)})`, this.kind);
|
|
216
|
+
}
|
|
217
|
+
if (project.links.some((l) => l.id === this.link.id)) {
|
|
218
|
+
throw new EditError(`duplicate link id ${String(this.link.id)}`, this.kind);
|
|
219
|
+
}
|
|
220
|
+
if (!project.tasks.some((t) => t.id === this.link.source)) {
|
|
221
|
+
throw new EditError(`source task ${String(this.link.source)} not found`, this.kind);
|
|
222
|
+
}
|
|
223
|
+
if (!project.tasks.some((t) => t.id === this.link.target)) {
|
|
224
|
+
throw new EditError(`target task ${String(this.link.target)} not found`, this.kind);
|
|
225
|
+
}
|
|
226
|
+
return { ...project, links: [...project.links, this.link] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
inverse(_project: Project): EditCommand {
|
|
230
|
+
return new DeleteLinkCommand(this.link.id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export class UpdateLinkCommand implements EditCommand {
|
|
235
|
+
readonly kind = 'update-link';
|
|
236
|
+
readonly label: string;
|
|
237
|
+
// Snapshot pre-edit link state during apply() — same pattern as
|
|
238
|
+
// UpdateTaskCommand. Required for snapshot-at-apply inverse semantics.
|
|
239
|
+
private originalLink?: Link;
|
|
240
|
+
|
|
241
|
+
constructor(
|
|
242
|
+
private readonly linkId: LinkId,
|
|
243
|
+
private readonly patch: Partial<Link>,
|
|
244
|
+
customLabel?: string,
|
|
245
|
+
) {
|
|
246
|
+
this.label = customLabel ?? `Update link "${String(linkId)}"`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
apply(project: Project): Project {
|
|
250
|
+
const target = project.links.find((l) => l.id === this.linkId);
|
|
251
|
+
if (!target) {
|
|
252
|
+
throw new EditError(`missing link ${String(this.linkId)}`, this.kind);
|
|
253
|
+
}
|
|
254
|
+
const idx = project.links.indexOf(target);
|
|
255
|
+
const links = [...project.links];
|
|
256
|
+
this.originalLink = target;
|
|
257
|
+
links[idx] = Object.assign({}, target, this.patch);
|
|
258
|
+
return { ...project, links };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
inverse(_project: Project): EditCommand {
|
|
262
|
+
if (!this.originalLink) {
|
|
263
|
+
throw new EditError(`inverse: apply() was not called on this command`, this.kind);
|
|
264
|
+
}
|
|
265
|
+
const previousPatch: Partial<Link> = {};
|
|
266
|
+
for (const key of Object.keys(this.patch)) {
|
|
267
|
+
const k = key as keyof Link;
|
|
268
|
+
(previousPatch as Record<keyof Link, unknown>)[k] = (
|
|
269
|
+
this.originalLink as Record<keyof Link, unknown>
|
|
270
|
+
)[k];
|
|
271
|
+
}
|
|
272
|
+
return new UpdateLinkCommand(this.linkId, previousPatch);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export class DeleteLinkCommand implements EditCommand {
|
|
277
|
+
readonly kind = 'delete-link';
|
|
278
|
+
readonly label: string;
|
|
279
|
+
// Snapshot link + original index during apply() so inverse restores at
|
|
280
|
+
// its original position in the array (preserving order).
|
|
281
|
+
private snapshot?: { link: Link; linkIndex: number };
|
|
282
|
+
|
|
283
|
+
constructor(private readonly linkId: LinkId) {
|
|
284
|
+
this.label = `Delete link "${String(linkId)}"`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
apply(project: Project): Project {
|
|
288
|
+
const target = project.links.find((l) => l.id === this.linkId);
|
|
289
|
+
if (!target) {
|
|
290
|
+
throw new EditError(`missing link ${String(this.linkId)}`, this.kind);
|
|
291
|
+
}
|
|
292
|
+
const linkIndex = project.links.indexOf(target);
|
|
293
|
+
this.snapshot = { link: target, linkIndex };
|
|
294
|
+
return {
|
|
295
|
+
...project,
|
|
296
|
+
links: project.links.filter((l) => l.id !== this.linkId),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
inverse(_project: Project): EditCommand {
|
|
301
|
+
if (!this.snapshot) {
|
|
302
|
+
throw new EditError(`inverse: apply() was not called on this command`, this.kind);
|
|
303
|
+
}
|
|
304
|
+
const { link: snapshotLink, linkIndex } = this.snapshot;
|
|
305
|
+
const linkId = this.linkId;
|
|
306
|
+
// Ad-hoc 'restore-link' command — not exported. Unlike DeleteTaskCommand's
|
|
307
|
+
// 'restore-task' (which also restores incident links), a link has no
|
|
308
|
+
// incident dependencies — just re-insert it at its original index.
|
|
309
|
+
const restoreCommand: EditCommand = {
|
|
310
|
+
kind: 'restore-link',
|
|
311
|
+
label: `Restore link "${String(linkId)}"`,
|
|
312
|
+
apply(p: Project): Project {
|
|
313
|
+
if (p.links.some((l) => l.id === snapshotLink.id)) {
|
|
314
|
+
throw new EditError(`duplicate link id ${String(snapshotLink.id)}`, 'restore-link');
|
|
315
|
+
}
|
|
316
|
+
const links = [...p.links];
|
|
317
|
+
const insertAt = Math.min(linkIndex, links.length);
|
|
318
|
+
links.splice(insertAt, 0, snapshotLink);
|
|
319
|
+
return { ...p, links };
|
|
320
|
+
},
|
|
321
|
+
inverse(_p: Project): EditCommand {
|
|
322
|
+
return new DeleteLinkCommand(snapshotLink.id);
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
return restoreCommand;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Project } from '../types.js';
|
|
2
|
+
import type { EditCommand } from './commands.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wraps N EditCommands into a single history entry. Apply runs each
|
|
6
|
+
* member in order; if any throws, the whole composite throws (caller
|
|
7
|
+
* sees a single error — the no-partial-output guarantee). Inverse walks
|
|
8
|
+
* members in REVERSE order, collecting each member's inverse — which
|
|
9
|
+
* reads from the snapshot captured during the member's apply.
|
|
10
|
+
*
|
|
11
|
+
* Single-use semantics: composite.apply() MUST be called before
|
|
12
|
+
* composite.inverse(), because the stateful member commands
|
|
13
|
+
* (Update*Command, Delete*Command) only have their snapshots after
|
|
14
|
+
* apply(). Calling inverse() without apply() throws the underlying
|
|
15
|
+
* member's "apply() was not called" EditError.
|
|
16
|
+
*
|
|
17
|
+
* **Contract caveat for members.** This implementation assumes every
|
|
18
|
+
* member is either stateless (Create*Command — its inverse derives
|
|
19
|
+
* from constructor args) or uses the snapshot-at-apply pattern (the
|
|
20
|
+
* built-in Update*Command and Delete*Command). The `_project` arg
|
|
21
|
+
* passed to each member's `inverse()` here is the composite's `_project`
|
|
22
|
+
* (the post-composite-apply state), NOT the post-member-apply state
|
|
23
|
+
* for that particular member. Built-in stateful commands ignore the
|
|
24
|
+
* arg and read their own snapshot, so this works correctly.
|
|
25
|
+
*
|
|
26
|
+
* If a future EditCommand implementation needs the actual post-member-
|
|
27
|
+
* apply state inside a composite, the implementation must either:
|
|
28
|
+
* (a) adopt snapshot-at-apply itself (recommended — matches built-ins),
|
|
29
|
+
* (b) extend CompositeCommand to walk the apply chain and pass each
|
|
30
|
+
* member its specific post-state to inverse().
|
|
31
|
+
*
|
|
32
|
+
* Produced by DraftProject.commit() when N>1 pending commands need to
|
|
33
|
+
* land as one history entry. Single-pending-command commits return
|
|
34
|
+
* the member directly without wrapping (per DraftProject contract).
|
|
35
|
+
*/
|
|
36
|
+
export class CompositeCommand implements EditCommand {
|
|
37
|
+
readonly kind = 'composite';
|
|
38
|
+
readonly label: string;
|
|
39
|
+
readonly members: ReadonlyArray<EditCommand>;
|
|
40
|
+
|
|
41
|
+
constructor(members: ReadonlyArray<EditCommand>, label: string) {
|
|
42
|
+
this.members = members;
|
|
43
|
+
this.label = label;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
apply(project: Project): Project {
|
|
47
|
+
let cur = project;
|
|
48
|
+
for (const cmd of this.members) {
|
|
49
|
+
cur = cmd.apply(cur);
|
|
50
|
+
}
|
|
51
|
+
return cur;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
inverse(_project: Project): EditCommand {
|
|
55
|
+
// Members store their own pre-state via snapshot-at-apply. We just
|
|
56
|
+
// collect each member's inverse in reverse order.
|
|
57
|
+
const inverses: EditCommand[] = [];
|
|
58
|
+
const reversed = [...this.members].reverse();
|
|
59
|
+
for (const cmd of reversed) {
|
|
60
|
+
inverses.push(cmd.inverse(_project));
|
|
61
|
+
}
|
|
62
|
+
return new CompositeCommand(inverses, `Undo: ${this.label}`);
|
|
63
|
+
}
|
|
64
|
+
}
|