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
package/dist/index.js ADDED
@@ -0,0 +1,3461 @@
1
+ function __insertCSS(code) {
2
+ if (!code || typeof document == 'undefined') return
3
+ let head = document.head || document.getElementsByTagName('head')[0]
4
+ let style = document.createElement('style')
5
+ style.type = 'text/css'
6
+ head.appendChild(style)
7
+ ;style.styleSheet ? (style.styleSheet.cssText = code) : style.appendChild(document.createTextNode(code))
8
+ }
9
+
10
+ import { useRef, useReducer, useMemo, forwardRef, useImperativeHandle } from 'react';
11
+ import { jsx, jsxs } from 'react/jsx-runtime';
12
+ import { Gantt as Gantt$1 } from '@svar-ui/react-gantt';
13
+ import '@svar-ui/react-gantt/style.css';
14
+ import { XMLParser, XMLBuilder } from 'fast-xml-parser';
15
+
16
+ // Analysis helpers — consumer-facing summary queries over a scheduled
17
+ // Project. Useful for dashboards, management-focus views, and CI-style
18
+ // programme-health checks.
19
+ //
20
+ // Assumes the project has been through `schedule()` (tasks have
21
+ // `computed` populated). If not, the helpers return safe defaults
22
+ // rather than throwing.
23
+ /**
24
+ * Return the critical-path leaf tasks (summary tasks excluded), ordered
25
+ * by `earlyStart` ascending. A construction PM reading this list is
26
+ * looking at "the tasks that, if any of them slip, the contract finish
27
+ * slips."
28
+ *
29
+ * The list mirrors what `task.computed.isCritical` says — i.e., includes
30
+ * tasks with negative total slack (already-late tasks).
31
+ */ function getCriticalPath(project) {
32
+ const leaves = project.tasks.filter((t)=>t.type !== 'summary');
33
+ const critical = leaves.filter((t)=>t.computed?.isCritical === true);
34
+ return critical.slice().sort((a, b)=>{
35
+ const aTime = a.computed?.earlyStart.getTime() ?? 0;
36
+ const bTime = b.computed?.earlyStart.getTime() ?? 0;
37
+ return aTime - bTime;
38
+ });
39
+ }
40
+ /**
41
+ * Summary statistics over a scheduled Project. Useful for status
42
+ * dashboards, programme-health badges in management views, and CI
43
+ * checks that flag "did the critical path grow this commit?".
44
+ */ function getProjectStats(project) {
45
+ const leaves = project.tasks.filter((t)=>t.type !== 'summary');
46
+ if (leaves.length === 0) {
47
+ return {
48
+ totalTasks: 0,
49
+ criticalTasks: 0,
50
+ lateTasks: 0,
51
+ weightedProgress: 0
52
+ };
53
+ }
54
+ let criticalTasks = 0;
55
+ let lateTasks = 0;
56
+ let projectFinishMs = Number.NEGATIVE_INFINITY;
57
+ let totalDuration = 0;
58
+ let progressDurationProduct = 0;
59
+ for (const t of leaves){
60
+ if (t.computed?.isCritical) criticalTasks++;
61
+ if ((t.computed?.totalSlack ?? 0) < 0) lateTasks++;
62
+ const finish = t.computed?.earlyFinish?.getTime() ?? t.end.getTime();
63
+ if (finish > projectFinishMs) projectFinishMs = finish;
64
+ totalDuration += t.duration;
65
+ progressDurationProduct += t.duration * t.progress;
66
+ }
67
+ const weightedProgress = totalDuration > 0 ? progressDurationProduct / totalDuration : 0;
68
+ return {
69
+ totalTasks: leaves.length,
70
+ criticalTasks,
71
+ lateTasks,
72
+ projectFinish: Number.isFinite(projectFinishMs) ? new Date(projectFinishMs) : undefined,
73
+ weightedProgress
74
+ };
75
+ }
76
+
77
+ function isWorkingDay(date, calendar) {
78
+ return getIntervalsForDay(date, calendar).length > 0;
79
+ }
80
+ function getDayWorkingMinutes(date, calendar) {
81
+ return getIntervalsForDay(date, calendar).reduce((sum, iv)=>sum + (iv.endMinutes - iv.startMinutes), 0);
82
+ }
83
+ /**
84
+ * Add `minutes` of working time to `start`, skipping non-working time
85
+ * (weekends, holidays, partial-day shift gaps). The result is the wall-clock
86
+ * Date exactly `minutes` working-time-minutes after `start`.
87
+ *
88
+ * If `start` falls in a non-working moment (weekend, after-hours, lunch
89
+ * break), the working-time clock begins from the next working interval.
90
+ *
91
+ * Note: not DST-aware. Working hours are wall-clock; cross-DST scheduling
92
+ * is approximate. Real DST handling is a future enhancement.
93
+ */ function addWorkingMinutes(start, minutes, calendar) {
94
+ if (minutes <= 0) return new Date(start);
95
+ let current = new Date(start);
96
+ let remaining = minutes;
97
+ while(remaining > 0){
98
+ const interval = findNextWorkingInterval(current, calendar);
99
+ if (!interval) {
100
+ throw new Error('Calendar has no working time within ~1 year after the given date');
101
+ }
102
+ if (current < interval.start) current = new Date(interval.start);
103
+ const availableMinutes = (interval.end.getTime() - current.getTime()) / 60_000;
104
+ if (remaining <= availableMinutes) {
105
+ return new Date(current.getTime() + remaining * 60_000);
106
+ }
107
+ remaining -= availableMinutes;
108
+ current = new Date(interval.end);
109
+ }
110
+ return current;
111
+ }
112
+ /**
113
+ * Subtract `minutes` of working time from `end`, walking backward through
114
+ * working intervals. Backward-pass mirror of {@link addWorkingMinutes}.
115
+ *
116
+ * If `end` falls in a non-working moment, the clock begins from the end of
117
+ * the most recent working interval.
118
+ */ function subtractWorkingMinutes(end, minutes, calendar) {
119
+ if (minutes <= 0) return new Date(end);
120
+ let current = new Date(end);
121
+ let remaining = minutes;
122
+ while(remaining > 0){
123
+ const interval = findPreviousWorkingInterval(current, calendar);
124
+ if (!interval) {
125
+ throw new Error('Calendar has no working time within ~1 year before the given date');
126
+ }
127
+ if (current > interval.end) current = new Date(interval.end);
128
+ const availableMinutes = (current.getTime() - interval.start.getTime()) / 60_000;
129
+ if (remaining <= availableMinutes) {
130
+ return new Date(current.getTime() - remaining * 60_000);
131
+ }
132
+ remaining -= availableMinutes;
133
+ current = new Date(interval.start);
134
+ }
135
+ return current;
136
+ }
137
+ /**
138
+ * Return the next working moment at or after `date`. If `date` already falls
139
+ * inside a working interval, returns it unchanged. If it falls in non-working
140
+ * time (weekend, after-hours, lunch break, holiday), returns the start of the
141
+ * next working interval.
142
+ */ function snapToNextWorkingMoment(date, calendar) {
143
+ const interval = findNextWorkingInterval(date, calendar);
144
+ if (!interval) {
145
+ throw new Error('Calendar has no working time within ~1 year after the given date');
146
+ }
147
+ return date < interval.start ? new Date(interval.start) : new Date(date);
148
+ }
149
+ /**
150
+ * Return the previous working moment at or before `date`. Mirror of
151
+ * {@link snapToNextWorkingMoment} for backward-pass scheduling.
152
+ */ function snapToPreviousWorkingMoment(date, calendar) {
153
+ const interval = findPreviousWorkingInterval(date, calendar);
154
+ if (!interval) {
155
+ throw new Error('Calendar has no working time within ~1 year before the given date');
156
+ }
157
+ return date > interval.end ? new Date(interval.end) : new Date(date);
158
+ }
159
+ /**
160
+ * Working-minute distance from `a` to `b`. Positive if `a` is earlier than
161
+ * `b`, negative if `a` is later. Used to compute slack (lateStart - earlyStart
162
+ * in working time).
163
+ */ function workingMinutesBetween(a, b, calendar) {
164
+ if (a.getTime() === b.getTime()) return 0;
165
+ const reverse = a > b;
166
+ const start = reverse ? b : a;
167
+ const end = reverse ? a : b;
168
+ let current = new Date(start);
169
+ let total = 0;
170
+ while(current < end){
171
+ const interval = findNextWorkingInterval(current, calendar);
172
+ if (!interval) break;
173
+ if (current < interval.start) current = new Date(interval.start);
174
+ if (current >= end) break;
175
+ if (interval.end >= end) {
176
+ total += (end.getTime() - current.getTime()) / 60_000;
177
+ break;
178
+ }
179
+ total += (interval.end.getTime() - current.getTime()) / 60_000;
180
+ current = new Date(interval.end);
181
+ }
182
+ return reverse ? -total : total;
183
+ }
184
+ /**
185
+ * Find the earliest working interval whose end is strictly after `date`.
186
+ * Walks forward up to ~1 year before giving up.
187
+ */ function findNextWorkingInterval(date, calendar) {
188
+ const msPerDay = 24 * 60 * 60 * 1000;
189
+ let day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
190
+ for(let i = 0; i < 366; i++){
191
+ const intervals = getIntervalsForDay(day, calendar);
192
+ for (const iv of intervals){
193
+ const intervalStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, iv.startMinutes);
194
+ const intervalEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, iv.endMinutes);
195
+ if (intervalEnd > date) {
196
+ return {
197
+ start: intervalStart,
198
+ end: intervalEnd
199
+ };
200
+ }
201
+ }
202
+ day = new Date(day.getTime() + msPerDay);
203
+ }
204
+ return null;
205
+ }
206
+ /**
207
+ * Find the latest working interval whose start is strictly before `date`.
208
+ * Walks backward up to ~1 year before giving up.
209
+ */ function findPreviousWorkingInterval(date, calendar) {
210
+ const msPerDay = 24 * 60 * 60 * 1000;
211
+ let day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
212
+ for(let i = 0; i < 366; i++){
213
+ const intervals = getIntervalsForDay(day, calendar);
214
+ for(let j = intervals.length - 1; j >= 0; j--){
215
+ const iv = intervals[j];
216
+ if (!iv) continue;
217
+ const intervalStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, iv.startMinutes);
218
+ const intervalEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, iv.endMinutes);
219
+ if (intervalStart < date) {
220
+ return {
221
+ start: intervalStart,
222
+ end: intervalEnd
223
+ };
224
+ }
225
+ }
226
+ day = new Date(day.getTime() - msPerDay);
227
+ }
228
+ return null;
229
+ }
230
+ function getIntervalsForDay(date, calendar) {
231
+ const exception = findException(date, calendar);
232
+ if (exception) {
233
+ return exception.isWorking ? exception.intervals ?? [] : [];
234
+ }
235
+ return calendar.workWeek[date.getDay()] ?? [];
236
+ }
237
+ function findException(date, calendar) {
238
+ return calendar.exceptions.find((ex)=>isSameCalendarDay(ex.date, date));
239
+ }
240
+ function isSameCalendarDay(a, b) {
241
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
242
+ }
243
+
244
+ // Baseline capture + variance API.
245
+ //
246
+ // Baselines snapshot the schedule at a point in time so we can compare the
247
+ // live programme against it later. Construction PMs need multiple
248
+ // baselines for variation-claim delay analysis under NZS 3910 / AS 4000
249
+ // (per ADR-003). We match MS Project's data model: up to 11 baselines
250
+ // (BaselineIndex 0..10) per project.
251
+ /**
252
+ * Snapshot the current schedule (task starts, ends, durations) as a baseline
253
+ * at the given index. Returns a new Project with the baseline added; the
254
+ * input is not mutated. If a baseline already exists at this index, it is
255
+ * replaced (matches MS Project "Save Baseline" behavior).
256
+ */ function captureBaseline(project, index, options) {
257
+ const tasks = new Map();
258
+ for (const task of project.tasks){
259
+ tasks.set(task.id, {
260
+ start: new Date(task.start),
261
+ end: new Date(task.end),
262
+ duration: task.duration
263
+ });
264
+ }
265
+ const baseline = {
266
+ index,
267
+ name: options?.name,
268
+ capturedAt: options?.capturedAt ? new Date(options.capturedAt) : new Date(),
269
+ tasks
270
+ };
271
+ const existing = project.baselines.findIndex((b)=>b.index === index);
272
+ const newBaselines = [
273
+ ...project.baselines
274
+ ];
275
+ if (existing >= 0) {
276
+ newBaselines[existing] = baseline;
277
+ } else {
278
+ newBaselines.push(baseline);
279
+ }
280
+ return {
281
+ ...project,
282
+ baselines: newBaselines
283
+ };
284
+ }
285
+ /**
286
+ * Compute baseline variance for every task in `project`. Returns a Map
287
+ * keyed by TaskId. Tasks not present in the named baseline are omitted
288
+ * from the returned map.
289
+ *
290
+ * `baselineIndex` selects the baseline on `project.baselines`. If the
291
+ * project has no baseline at that index, returns an empty Map (caller
292
+ * decides whether to treat this as a soft error or hard error).
293
+ *
294
+ * The calendar used for working-time variance arithmetic is the project
295
+ * default calendar, looked up by `project.defaultCalendarId`. If that
296
+ * lookup fails, throw an Error (consistent with `schedule()`'s behavior
297
+ * on a missing default calendar).
298
+ */ function getTaskBaselineVarianceAll(project, baselineIndex) {
299
+ const baseline = project.baselines.find((b)=>b.index === baselineIndex);
300
+ if (!baseline) return new Map();
301
+ const calendar = project.calendars.find((c)=>c.id === project.defaultCalendarId);
302
+ if (!calendar) {
303
+ throw new Error(`Default calendar '${project.defaultCalendarId}' not found. Cannot compute baseline variance.`);
304
+ }
305
+ const result = new Map();
306
+ for (const task of project.tasks){
307
+ const variance = getTaskBaselineVariance(task, baseline, calendar);
308
+ if (variance !== undefined) {
309
+ result.set(task.id, variance);
310
+ }
311
+ }
312
+ return result;
313
+ }
314
+ /**
315
+ * Compute working-time variance between a task's current dates and its
316
+ * snapshot in a baseline. Returns undefined if the task isn't in the
317
+ * baseline (added after the baseline was captured).
318
+ */ function getTaskBaselineVariance(task, baseline, calendar) {
319
+ const snap = baseline.tasks.get(task.id);
320
+ if (!snap) return undefined;
321
+ return {
322
+ startVariance: workingMinutesBetween(snap.start, task.start, calendar),
323
+ finishVariance: workingMinutesBetween(snap.end, task.end, calendar),
324
+ durationVariance: task.duration - snap.duration
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Canterbury Anniversary Day observance dates. Unlike Matariki, this
330
+ * holiday is set annually by the Canterbury A&P Association — the Friday
331
+ * of Show Week — and doesn't reduce to a closed-form rule.
332
+ *
333
+ * Source: https://www.employment.govt.nz/leave-and-holidays/public-holidays/public-holidays-and-anniversary-dates/
334
+ *
335
+ * The governing rule (from date-holidays NZ.yaml and employment.govt.nz):
336
+ * "the Friday after the 2nd Tuesday in November" (Christchurch Show Day).
337
+ * This rule was used to derive and verify every entry below. 2026 was
338
+ * additionally confirmed directly from employment.govt.nz. 2027–2028 are
339
+ * rule-derived only (not yet published by employment.govt.nz at time of
340
+ * implementation).
341
+ *
342
+ * Every entry verified against the official listing on implementation.
343
+ * A typo or transcription error produces a silent wrong-date bug for
344
+ * every Canterbury-region consumer in the affected year.
345
+ *
346
+ * Years past `CANTERBURY_RANGE.maxYear` are unpublished and produce a
347
+ * specific error from the public API rather than a fabricated date.
348
+ */ // month is 1-indexed for readability against employment.govt.nz; converted
349
+ // to JS's 0-indexed month when constructing the Date.
350
+ const RAW$1 = [
351
+ [
352
+ 2022,
353
+ 11,
354
+ 11
355
+ ],
356
+ [
357
+ 2023,
358
+ 11,
359
+ 17
360
+ ],
361
+ [
362
+ 2024,
363
+ 11,
364
+ 15
365
+ ],
366
+ [
367
+ 2025,
368
+ 11,
369
+ 14
370
+ ],
371
+ [
372
+ 2026,
373
+ 11,
374
+ 13
375
+ ],
376
+ [
377
+ 2027,
378
+ 11,
379
+ 12
380
+ ],
381
+ [
382
+ 2028,
383
+ 11,
384
+ 17
385
+ ]
386
+ ];
387
+ const CANTERBURY_DATES = Object.freeze(Object.fromEntries(RAW$1.map(([y, m, d])=>[
388
+ y,
389
+ new Date(y, m - 1, d)
390
+ ])));
391
+ const years = RAW$1.map(([y])=>y);
392
+ const CANTERBURY_RANGE = Object.freeze({
393
+ minYear: Math.min(...years),
394
+ maxYear: Math.max(...years)
395
+ });
396
+
397
+ /**
398
+ * Returns the Date for Easter Sunday in the given year, using the
399
+ * Anonymous Gregorian algorithm (Meeus/Jones/Butcher). Pure-integer
400
+ * arithmetic; valid for any year in the Gregorian calendar (1583+).
401
+ *
402
+ * Reference: Astronomical Algorithms (Meeus, 1991), §8.
403
+ */ function computeEasterSunday(year) {
404
+ const a = year % 19;
405
+ const b = Math.floor(year / 100);
406
+ const c = year % 100;
407
+ const d = Math.floor(b / 4);
408
+ const e = b % 4;
409
+ const f = Math.floor((b + 8) / 25);
410
+ const g = Math.floor((b - f + 1) / 3);
411
+ const h = (19 * a + b - d - g + 15) % 30;
412
+ const i = Math.floor(c / 4);
413
+ const k = c % 4;
414
+ const l = (32 + 2 * e + 2 * i - h - k) % 7;
415
+ const m = Math.floor((a + 11 * h + 22 * l) / 451);
416
+ const monthZeroIndexed = Math.floor((h + l - 7 * m + 114) / 31) - 1; // March=2, April=3
417
+ const day = (h + l - 7 * m + 114) % 31 + 1;
418
+ return new Date(year, monthZeroIndexed, day);
419
+ }
420
+
421
+ /**
422
+ * Tiny shared date-arithmetic helpers used across the calendars module.
423
+ * Kept module-private (not re-exported from the package entry).
424
+ */ function dayOfWeek(date) {
425
+ // 0 = Sun … 6 = Sat
426
+ return date.getDay();
427
+ }
428
+ function addDays(date, days) {
429
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
430
+ }
431
+
432
+ function mondayiseSingle(date) {
433
+ const dow = dayOfWeek(date);
434
+ if (dow === 6) return addDays(date, 2); // Sat → Mon
435
+ if (dow === 0) return addDays(date, 1); // Sun → Mon
436
+ return new Date(date);
437
+ }
438
+ /**
439
+ * Apply the pair rule to a [first, second] pair where `second` is exactly
440
+ * one calendar day after `first` (Jan 1 / 2 Jan or Dec 25 / 26). Returns
441
+ * the observed [first, second] pair.
442
+ */ function mondayisePair(first, second) {
443
+ const firstDow = dayOfWeek(first);
444
+ // First on Sat → first observed Mon (+2), second observed Tue (+2)
445
+ if (firstDow === 6) {
446
+ return [
447
+ addDays(first, 2),
448
+ addDays(second, 2)
449
+ ];
450
+ }
451
+ // First on Sun → first observed Mon (+1), second observed Tue (+1).
452
+ // (Second's natural day is Mon, which is now taken by first; bump to Tue.)
453
+ if (firstDow === 0) {
454
+ return [
455
+ addDays(first, 1),
456
+ addDays(second, 1)
457
+ ];
458
+ }
459
+ // First on Fri → first stays Fri; second (originally Sat) shifts to Mon (+2).
460
+ if (firstDow === 5) {
461
+ return [
462
+ new Date(first),
463
+ addDays(second, 2)
464
+ ];
465
+ }
466
+ // First on Mon-Thu → no shift; second is naturally Tue–Fri.
467
+ return [
468
+ new Date(first),
469
+ new Date(second)
470
+ ];
471
+ }
472
+
473
+ /**
474
+ * The N-th Monday of the given month. `month` is zero-indexed (Jan = 0).
475
+ * `n` is 1-indexed (1 = first Monday).
476
+ */ function nthMondayOfMonth(year, month, n) {
477
+ const firstOfMonth = new Date(year, month, 1);
478
+ const firstDow = dayOfWeek(firstOfMonth);
479
+ // Days to add to reach the first Monday of the month.
480
+ // (8 - dow) % 7: Sun(0)→1, Mon(1)→0, Tue(2)→6, Wed(3)→5, Thu(4)→4, Fri(5)→3, Sat(6)→2
481
+ const offsetToFirstMonday = (8 - firstDow) % 7;
482
+ return new Date(year, month, 1 + offsetToFirstMonday + (n - 1) * 7);
483
+ }
484
+ /**
485
+ * The first Monday strictly after the given date.
486
+ */ function firstMondayAfter(date) {
487
+ const dow = dayOfWeek(date);
488
+ // Days from `date` to the next Monday strictly after it.
489
+ // Sun(0): +1, Mon(1): +7, Tue(2): +6, Wed(3): +5, Thu(4): +4, Fri(5): +3, Sat(6): +2
490
+ const delta = dow === 0 ? 1 : (8 - dow) % 7 || 7;
491
+ return addDays(date, delta);
492
+ }
493
+ /**
494
+ * The Friday immediately before the given date. Used for Hawke's Bay
495
+ * (Friday before Labour Day).
496
+ */ function fridayBefore(date) {
497
+ const dow = dayOfWeek(date);
498
+ // Days BACK from `date` to the most recent Friday strictly before it.
499
+ // Sun(0): -2, Mon(1): -3, Tue(2): -4, Wed(3): -5, Thu(4): -6, Fri(5): -7, Sat(6): -1
500
+ const delta = -((dow + 2) % 7 || 7);
501
+ return addDays(date, delta);
502
+ }
503
+ /**
504
+ * The Monday nearest the given calendar date. No tie-break logic needed:
505
+ * no day-of-week produces equidistant prev/next Monday — the function is
506
+ * well-defined for all 7 inputs.
507
+ *
508
+ * `month` is zero-indexed.
509
+ */ function nearestMondayTo(year, month, day) {
510
+ const target = new Date(year, month, day);
511
+ const dow = dayOfWeek(target);
512
+ if (dow === 1) return target; // Already Monday
513
+ // Distance to previous Monday: dow - 1 days back if dow ≥ 1 else 6.
514
+ // Distance to next Monday: 8 - dow if dow ≥ 1 else 1.
515
+ const distPrev = dow === 0 ? 6 : dow - 1;
516
+ const distNext = dow === 0 ? 1 : 8 - dow;
517
+ return distPrev <= distNext ? addDays(target, -distPrev) : addDays(target, distNext);
518
+ }
519
+
520
+ /**
521
+ * Matariki observance dates per the Te Kāhui o Matariki Public Holiday Act
522
+ * 2022, Schedule 1. Pre-announced for 30 years (2022–2052); the Act sets
523
+ * each date explicitly rather than via formula.
524
+ *
525
+ * Primary source: Te Kāhui o Matariki Public Holiday Act 2022, Schedule 1
526
+ * https://www.legislation.govt.nz/act/public/2022/0014/latest/whole.html
527
+ * (legislation.govt.nz returned HTTP 403 to WebFetch; cross-referenced via:)
528
+ *
529
+ * Verified sources used during implementation:
530
+ * 1. https://raw.githubusercontent.com/commenthol/date-holidays/master/data/countries/NZ.yaml
531
+ * (date-holidays library, tracks official NZ public holiday data)
532
+ * 2. https://github.com/commenthol/date-holidays/blob/master/data/countries/NZ.yaml
533
+ * (same data, HTML view — identical 31-entry list)
534
+ * 3. Wikipedia "Matariki" article (2022–2035 confirmed matching)
535
+ * 4. Local JS Date validation: all 31 entries confirmed as Friday, June/July
536
+ *
537
+ * Every entry was verified to be a Friday falling in June or July. A typo
538
+ * here is invisible to typecheck and rule-tests — it produces a silent
539
+ * wrong-date bug for every consumer in the affected year.
540
+ */ const MATARIKI_RANGE = {
541
+ minYear: 2022,
542
+ maxYear: 2052
543
+ };
544
+ // month is 1-indexed for readability against the Act text; converted to
545
+ // JS's 0-indexed month when constructing the Date.
546
+ const RAW = [
547
+ [
548
+ 2022,
549
+ 6,
550
+ 24
551
+ ],
552
+ [
553
+ 2023,
554
+ 7,
555
+ 14
556
+ ],
557
+ [
558
+ 2024,
559
+ 6,
560
+ 28
561
+ ],
562
+ [
563
+ 2025,
564
+ 6,
565
+ 20
566
+ ],
567
+ [
568
+ 2026,
569
+ 7,
570
+ 10
571
+ ],
572
+ [
573
+ 2027,
574
+ 6,
575
+ 25
576
+ ],
577
+ [
578
+ 2028,
579
+ 7,
580
+ 14
581
+ ],
582
+ [
583
+ 2029,
584
+ 7,
585
+ 6
586
+ ],
587
+ [
588
+ 2030,
589
+ 6,
590
+ 21
591
+ ],
592
+ [
593
+ 2031,
594
+ 7,
595
+ 11
596
+ ],
597
+ [
598
+ 2032,
599
+ 7,
600
+ 2
601
+ ],
602
+ [
603
+ 2033,
604
+ 6,
605
+ 24
606
+ ],
607
+ [
608
+ 2034,
609
+ 7,
610
+ 7
611
+ ],
612
+ [
613
+ 2035,
614
+ 6,
615
+ 29
616
+ ],
617
+ [
618
+ 2036,
619
+ 7,
620
+ 18
621
+ ],
622
+ [
623
+ 2037,
624
+ 7,
625
+ 10
626
+ ],
627
+ [
628
+ 2038,
629
+ 6,
630
+ 25
631
+ ],
632
+ [
633
+ 2039,
634
+ 7,
635
+ 15
636
+ ],
637
+ [
638
+ 2040,
639
+ 7,
640
+ 6
641
+ ],
642
+ [
643
+ 2041,
644
+ 7,
645
+ 19
646
+ ],
647
+ [
648
+ 2042,
649
+ 7,
650
+ 11
651
+ ],
652
+ [
653
+ 2043,
654
+ 7,
655
+ 3
656
+ ],
657
+ [
658
+ 2044,
659
+ 6,
660
+ 24
661
+ ],
662
+ [
663
+ 2045,
664
+ 7,
665
+ 7
666
+ ],
667
+ [
668
+ 2046,
669
+ 6,
670
+ 29
671
+ ],
672
+ [
673
+ 2047,
674
+ 7,
675
+ 19
676
+ ],
677
+ [
678
+ 2048,
679
+ 7,
680
+ 3
681
+ ],
682
+ [
683
+ 2049,
684
+ 6,
685
+ 25
686
+ ],
687
+ [
688
+ 2050,
689
+ 7,
690
+ 15
691
+ ],
692
+ [
693
+ 2051,
694
+ 6,
695
+ 30
696
+ ],
697
+ [
698
+ 2052,
699
+ 6,
700
+ 21
701
+ ]
702
+ ];
703
+ const MATARIKI_DATES = Object.freeze(Object.fromEntries(RAW.map(([y, m, d])=>[
704
+ y,
705
+ new Date(y, m - 1, d)
706
+ ])));
707
+
708
+ // NZ public-holiday + regional-anniversary pre-seed for the working-time
709
+ // calendar engine. Hybrid generation: pure-TS rules for the holidays whose
710
+ // observed date reduces to a formula; small static tables for the ones
711
+ // that don't (Matariki — set by Act; Canterbury Show Day — set annually).
712
+ //
713
+ // Range supported: 2022 (Matariki Act adoption) through 2052 (last year
714
+ // the Act pre-announces). Calls outside that range throw RangeError.
715
+ const MIN_YEAR = MATARIKI_RANGE.minYear; // 2022
716
+ const MAX_YEAR = MATARIKI_RANGE.maxYear; // 2052
717
+ const DEFAULT_INTERVAL = {
718
+ startMinutes: 8 * 60,
719
+ endMinutes: 17 * 60
720
+ };
721
+ const DEFAULT_WORK_WEEK = [
722
+ [],
723
+ [
724
+ DEFAULT_INTERVAL
725
+ ],
726
+ [
727
+ DEFAULT_INTERVAL
728
+ ],
729
+ [
730
+ DEFAULT_INTERVAL
731
+ ],
732
+ [
733
+ DEFAULT_INTERVAL
734
+ ],
735
+ [
736
+ DEFAULT_INTERVAL
737
+ ],
738
+ []
739
+ ];
740
+ function nzPublicHolidays(years, region) {
741
+ const yearList = normaliseYears(years);
742
+ const all = [];
743
+ for (const year of yearList){
744
+ all.push(...nationalHolidays(year));
745
+ if (region) {
746
+ const regional = regionalAnniversary(year, region);
747
+ if (regional) all.push(regional);
748
+ }
749
+ }
750
+ return all.sort((a, b)=>a.date.getTime() - b.date.getTime());
751
+ }
752
+ function normaliseYears(input) {
753
+ const list = Array.isArray(input) ? [
754
+ ...new Set(input)
755
+ ] : [
756
+ input
757
+ ];
758
+ for (const y of list){
759
+ if (y < MIN_YEAR || y > MAX_YEAR) {
760
+ throw new RangeError(`nzPublicHolidays: year ${y} out of supported range ${MIN_YEAR}-${MAX_YEAR}. ` + `Range is bounded by the Te Kāhui o Matariki Public Holiday Act 2022 Schedule 1.`);
761
+ }
762
+ }
763
+ return list.sort((a, b)=>a - b);
764
+ }
765
+ function nationalHolidays(year) {
766
+ const out = [];
767
+ // New Year + 2 January (pair rule)
768
+ const [ny, jan2] = mondayisePair(new Date(year, 0, 1), new Date(year, 0, 2));
769
+ out.push(makeException(ny, "New Year's Day", new Date(year, 0, 1)));
770
+ out.push(makeException(jan2, '2 January', new Date(year, 0, 2)));
771
+ // Waitangi Day (single-day mondayisation)
772
+ const waitangi = mondayiseSingle(new Date(year, 1, 6));
773
+ out.push(makeException(waitangi, 'Waitangi Day', new Date(year, 1, 6)));
774
+ // Good Friday + Easter Monday (Easter Sunday ± 2 / +1)
775
+ const easterSun = computeEasterSunday(year);
776
+ const goodFri = new Date(year, easterSun.getMonth(), easterSun.getDate() - 2);
777
+ const easterMon = new Date(year, easterSun.getMonth(), easterSun.getDate() + 1);
778
+ out.push(makeException(goodFri, 'Good Friday'));
779
+ out.push(makeException(easterMon, 'Easter Monday'));
780
+ // ANZAC Day (single-day mondayisation)
781
+ const anzac = mondayiseSingle(new Date(year, 3, 25));
782
+ out.push(makeException(anzac, 'ANZAC Day', new Date(year, 3, 25)));
783
+ // King's Birthday (first Monday of June)
784
+ out.push(makeException(nthMondayOfMonth(year, 5, 1), "King's Birthday"));
785
+ // Matariki (static table — guaranteed in range by the gate above)
786
+ // biome-ignore lint/style/noNonNullAssertion: year is range-gated 2022-2052; entry always present
787
+ out.push(makeException(MATARIKI_DATES[year], 'Matariki'));
788
+ // Labour Day (fourth Monday of October)
789
+ out.push(makeException(nthMondayOfMonth(year, 9, 4), 'Labour Day'));
790
+ // Christmas + Boxing Day (pair rule)
791
+ const [christmas, boxing] = mondayisePair(new Date(year, 11, 25), new Date(year, 11, 26));
792
+ out.push(makeException(christmas, 'Christmas Day', new Date(year, 11, 25)));
793
+ out.push(makeException(boxing, 'Boxing Day', new Date(year, 11, 26)));
794
+ return out;
795
+ }
796
+ function regionalAnniversary(year, region) {
797
+ switch(region){
798
+ case 'auckland':
799
+ return makeException(nearestMondayTo(year, 0, 29), 'Auckland Anniversary');
800
+ case 'wellington':
801
+ return makeException(nearestMondayTo(year, 0, 22), 'Wellington Anniversary');
802
+ case 'nelson':
803
+ return makeException(nearestMondayTo(year, 1, 1), 'Nelson Anniversary');
804
+ case 'otago':
805
+ return makeException(nearestMondayTo(year, 2, 23), 'Otago Anniversary');
806
+ case 'taranaki':
807
+ return makeException(nthMondayOfMonth(year, 2, 2), 'Taranaki Anniversary');
808
+ case 'southland':
809
+ {
810
+ const easterSun = computeEasterSunday(year);
811
+ const easterTue = new Date(year, easterSun.getMonth(), easterSun.getDate() + 2);
812
+ return makeException(easterTue, 'Southland Anniversary');
813
+ }
814
+ case 'south-canterbury':
815
+ return makeException(nthMondayOfMonth(year, 8, 4), 'South Canterbury Anniversary');
816
+ case 'hawkes-bay':
817
+ {
818
+ const labourDay = nthMondayOfMonth(year, 9, 4);
819
+ return makeException(fridayBefore(labourDay), "Hawke's Bay Anniversary");
820
+ }
821
+ case 'marlborough':
822
+ {
823
+ const labourDay = nthMondayOfMonth(year, 9, 4);
824
+ return makeException(firstMondayAfter(labourDay), 'Marlborough Anniversary');
825
+ }
826
+ case 'canterbury':
827
+ {
828
+ const date = CANTERBURY_DATES[year];
829
+ if (!date) {
830
+ throw new RangeError(`nzPublicHolidays: Canterbury Anniversary Day for ${year} is not in the verified ` + `static table (range ${CANTERBURY_RANGE.minYear}-${CANTERBURY_RANGE.maxYear}). ` + `Show Day is set annually by the Canterbury A&P Association; ` + `add the verified date from employment.govt.nz to canterbury-table.ts to extend coverage.`);
831
+ }
832
+ return makeException(date, 'Canterbury Anniversary');
833
+ }
834
+ case 'westland':
835
+ return makeException(nthMondayOfMonth(year, 11, 1), 'Westland Anniversary');
836
+ case 'chatham-islands':
837
+ return makeException(nearestMondayTo(year, 10, 30), 'Chatham Islands Anniversary');
838
+ case 'northland':
839
+ // Per Holidays Act 2003: Northland observes Waitangi Day as its
840
+ // anniversary. No extra exception is added.
841
+ return null;
842
+ }
843
+ }
844
+ function makeException(observedDate, baseName, statutoryDate) {
845
+ const moved = statutoryDate && (observedDate.getFullYear() !== statutoryDate.getFullYear() || observedDate.getMonth() !== statutoryDate.getMonth() || observedDate.getDate() !== statutoryDate.getDate());
846
+ return {
847
+ date: new Date(observedDate.getFullYear(), observedDate.getMonth(), observedDate.getDate()),
848
+ isWorking: false,
849
+ name: moved ? `${baseName} (observed)` : baseName
850
+ };
851
+ }
852
+ function nzDefaultCalendar(options) {
853
+ const { years, region, workWeek, id, name } = options;
854
+ return {
855
+ id: id ?? 'nz-default',
856
+ name: name ?? 'New Zealand Standard',
857
+ workWeek: workWeek ?? DEFAULT_WORK_WEEK,
858
+ exceptions: nzPublicHolidays(years, region)
859
+ };
860
+ }
861
+
862
+ /**
863
+ * Thrown when an EditCommand's pre-conditions fail (e.g. target task
864
+ * doesn't exist, FS-link-to-self, malformed patch). Caught at the hook
865
+ * boundary so consumers see a typed error rather than a generic Error.
866
+ */ class EditError extends Error {
867
+ constructor(message, commandKind){
868
+ super(message);
869
+ this.name = 'EditError';
870
+ this.commandKind = commandKind;
871
+ }
872
+ }
873
+
874
+ class CreateTaskCommand {
875
+ constructor(task, insertAt){
876
+ this.task = task;
877
+ this.insertAt = insertAt;
878
+ this.kind = 'create-task';
879
+ this.label = `Create task "${task.text}"`;
880
+ }
881
+ apply(project) {
882
+ if (project.tasks.some((t)=>t.id === this.task.id)) {
883
+ throw new EditError(`duplicate task id ${String(this.task.id)}`, this.kind);
884
+ }
885
+ const tasks = [
886
+ ...project.tasks
887
+ ];
888
+ if (this.insertAt === undefined) {
889
+ tasks.push(this.task);
890
+ } else {
891
+ tasks.splice(this.insertAt, 0, this.task);
892
+ }
893
+ return {
894
+ ...project,
895
+ tasks
896
+ };
897
+ }
898
+ inverse(_project) {
899
+ return new DeleteTaskCommand(this.task.id);
900
+ }
901
+ }
902
+ class UpdateTaskCommand {
903
+ constructor(taskId, patch, customLabel){
904
+ this.taskId = taskId;
905
+ this.patch = patch;
906
+ this.kind = 'update-task';
907
+ if (customLabel !== undefined) {
908
+ this.label = customLabel;
909
+ } else {
910
+ const keys = Object.keys(patch);
911
+ this.label = keys.length === 1 ? `Update task "${String(taskId)}" (${keys[0]})` : `Update task "${String(taskId)}"`;
912
+ }
913
+ }
914
+ apply(project) {
915
+ const idx = project.tasks.findIndex((t)=>t.id === this.taskId);
916
+ if (idx === -1) {
917
+ throw new EditError(`missing task ${String(this.taskId)}`, this.kind);
918
+ }
919
+ const tasks = [
920
+ ...project.tasks
921
+ ];
922
+ this.originalTask = tasks[idx];
923
+ tasks[idx] = Object.assign({}, tasks[idx], this.patch);
924
+ return {
925
+ ...project,
926
+ tasks
927
+ };
928
+ }
929
+ inverse(_project) {
930
+ if (!this.originalTask) {
931
+ throw new EditError(`inverse: apply() was not called on this command`, this.kind);
932
+ }
933
+ // Capture the value of each patched key from the original task.
934
+ const previousPatch = {};
935
+ for (const key of Object.keys(this.patch)){
936
+ const k = key;
937
+ previousPatch[k] = this.originalTask[k];
938
+ }
939
+ return new UpdateTaskCommand(this.taskId, previousPatch);
940
+ }
941
+ }
942
+ class DeleteTaskCommand {
943
+ constructor(taskId){
944
+ this.taskId = taskId;
945
+ this.kind = 'delete-task';
946
+ this.label = `Delete task "${String(taskId)}"`;
947
+ }
948
+ apply(project) {
949
+ const target = project.tasks.find((t)=>t.id === this.taskId);
950
+ if (!target) {
951
+ throw new EditError(`missing task ${String(this.taskId)}`, this.kind);
952
+ }
953
+ const taskIndex = project.tasks.indexOf(target);
954
+ // Capture incident links with their original indices.
955
+ const incidentLinkPositions = [];
956
+ for(let i = 0; i < project.links.length; i++){
957
+ const link = project.links[i];
958
+ if (link && (link.source === this.taskId || link.target === this.taskId)) {
959
+ incidentLinkPositions.push([
960
+ i,
961
+ link
962
+ ]);
963
+ }
964
+ }
965
+ this.snapshot = {
966
+ task: target,
967
+ taskIndex,
968
+ incidentLinkPositions
969
+ };
970
+ const tasks = project.tasks.filter((t)=>t.id !== this.taskId);
971
+ const links = project.links.filter((l)=>l.source !== this.taskId && l.target !== this.taskId);
972
+ return {
973
+ ...project,
974
+ tasks,
975
+ links
976
+ };
977
+ }
978
+ inverse(_project) {
979
+ if (!this.snapshot) {
980
+ throw new EditError(`inverse: apply() was not called on this command`, this.kind);
981
+ }
982
+ const snapshotData = this.snapshot;
983
+ const { task: snapshotTask, taskIndex, incidentLinkPositions } = snapshotData;
984
+ // Return an ad-hoc EditCommand that restores both task + links atomically.
985
+ // Not exported — only reachable as the inverse of DeleteTaskCommand.
986
+ const restoreCommand = {
987
+ kind: 'restore-task',
988
+ label: `Restore task "${String(this.taskId)}"`,
989
+ apply (p) {
990
+ if (p.tasks.some((t)=>t.id === snapshotTask.id)) {
991
+ throw new EditError(`duplicate task id ${String(snapshotTask.id)}`, 'restore-task');
992
+ }
993
+ // Restore task at original index (clamped to current length).
994
+ const tasks = [
995
+ ...p.tasks
996
+ ];
997
+ const insertTaskAt = Math.min(taskIndex, tasks.length);
998
+ tasks.splice(insertTaskAt, 0, snapshotTask);
999
+ // Restore links at their original indices, adjusting for deletions.
1000
+ // INVARIANT: incidentLinkPositions is guaranteed ascending-by-index
1001
+ // because the capture loop in apply() walks 0..N. Splice-in-place
1002
+ // only produces the correct final order when fed sorted-ascending
1003
+ // positions — if a future refactor breaks the capture order, this
1004
+ // restoration breaks silently. If insertion order ever becomes
1005
+ // unsortable, switch to a single-pass rebuild keyed on original idx.
1006
+ const links = [
1007
+ ...p.links
1008
+ ];
1009
+ for (const [originalIdx, link] of incidentLinkPositions){
1010
+ links.splice(originalIdx, 0, link);
1011
+ }
1012
+ return {
1013
+ ...p,
1014
+ tasks,
1015
+ links
1016
+ };
1017
+ },
1018
+ inverse (_p) {
1019
+ return new DeleteTaskCommand(snapshotTask.id);
1020
+ }
1021
+ };
1022
+ return restoreCommand;
1023
+ }
1024
+ }
1025
+ class CreateLinkCommand {
1026
+ constructor(link){
1027
+ this.link = link;
1028
+ this.kind = 'create-link';
1029
+ this.label = `Link ${String(link.source)} → ${String(link.target)}`;
1030
+ }
1031
+ apply(project) {
1032
+ if (this.link.source === this.link.target) {
1033
+ throw new EditError(`self-link not allowed (${String(this.link.source)})`, this.kind);
1034
+ }
1035
+ if (project.links.some((l)=>l.id === this.link.id)) {
1036
+ throw new EditError(`duplicate link id ${String(this.link.id)}`, this.kind);
1037
+ }
1038
+ if (!project.tasks.some((t)=>t.id === this.link.source)) {
1039
+ throw new EditError(`source task ${String(this.link.source)} not found`, this.kind);
1040
+ }
1041
+ if (!project.tasks.some((t)=>t.id === this.link.target)) {
1042
+ throw new EditError(`target task ${String(this.link.target)} not found`, this.kind);
1043
+ }
1044
+ return {
1045
+ ...project,
1046
+ links: [
1047
+ ...project.links,
1048
+ this.link
1049
+ ]
1050
+ };
1051
+ }
1052
+ inverse(_project) {
1053
+ return new DeleteLinkCommand(this.link.id);
1054
+ }
1055
+ }
1056
+ class UpdateLinkCommand {
1057
+ constructor(linkId, patch, customLabel){
1058
+ this.linkId = linkId;
1059
+ this.patch = patch;
1060
+ this.kind = 'update-link';
1061
+ this.label = customLabel ?? `Update link "${String(linkId)}"`;
1062
+ }
1063
+ apply(project) {
1064
+ const target = project.links.find((l)=>l.id === this.linkId);
1065
+ if (!target) {
1066
+ throw new EditError(`missing link ${String(this.linkId)}`, this.kind);
1067
+ }
1068
+ const idx = project.links.indexOf(target);
1069
+ const links = [
1070
+ ...project.links
1071
+ ];
1072
+ this.originalLink = target;
1073
+ links[idx] = Object.assign({}, target, this.patch);
1074
+ return {
1075
+ ...project,
1076
+ links
1077
+ };
1078
+ }
1079
+ inverse(_project) {
1080
+ if (!this.originalLink) {
1081
+ throw new EditError(`inverse: apply() was not called on this command`, this.kind);
1082
+ }
1083
+ const previousPatch = {};
1084
+ for (const key of Object.keys(this.patch)){
1085
+ const k = key;
1086
+ previousPatch[k] = this.originalLink[k];
1087
+ }
1088
+ return new UpdateLinkCommand(this.linkId, previousPatch);
1089
+ }
1090
+ }
1091
+ class DeleteLinkCommand {
1092
+ constructor(linkId){
1093
+ this.linkId = linkId;
1094
+ this.kind = 'delete-link';
1095
+ this.label = `Delete link "${String(linkId)}"`;
1096
+ }
1097
+ apply(project) {
1098
+ const target = project.links.find((l)=>l.id === this.linkId);
1099
+ if (!target) {
1100
+ throw new EditError(`missing link ${String(this.linkId)}`, this.kind);
1101
+ }
1102
+ const linkIndex = project.links.indexOf(target);
1103
+ this.snapshot = {
1104
+ link: target,
1105
+ linkIndex
1106
+ };
1107
+ return {
1108
+ ...project,
1109
+ links: project.links.filter((l)=>l.id !== this.linkId)
1110
+ };
1111
+ }
1112
+ inverse(_project) {
1113
+ if (!this.snapshot) {
1114
+ throw new EditError(`inverse: apply() was not called on this command`, this.kind);
1115
+ }
1116
+ const { link: snapshotLink, linkIndex } = this.snapshot;
1117
+ const linkId = this.linkId;
1118
+ // Ad-hoc 'restore-link' command — not exported. Unlike DeleteTaskCommand's
1119
+ // 'restore-task' (which also restores incident links), a link has no
1120
+ // incident dependencies — just re-insert it at its original index.
1121
+ const restoreCommand = {
1122
+ kind: 'restore-link',
1123
+ label: `Restore link "${String(linkId)}"`,
1124
+ apply (p) {
1125
+ if (p.links.some((l)=>l.id === snapshotLink.id)) {
1126
+ throw new EditError(`duplicate link id ${String(snapshotLink.id)}`, 'restore-link');
1127
+ }
1128
+ const links = [
1129
+ ...p.links
1130
+ ];
1131
+ const insertAt = Math.min(linkIndex, links.length);
1132
+ links.splice(insertAt, 0, snapshotLink);
1133
+ return {
1134
+ ...p,
1135
+ links
1136
+ };
1137
+ },
1138
+ inverse (_p) {
1139
+ return new DeleteLinkCommand(snapshotLink.id);
1140
+ }
1141
+ };
1142
+ return restoreCommand;
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * Ergonomic factory functions for constructing edit commands with
1148
+ * descriptive labels. Consumers call these instead of `new
1149
+ * UpdateTaskCommand(...)` so undo UI shows "Rename task to 'Foundation'"
1150
+ * rather than the generic "Update task 'a' (text)".
1151
+ */ function renameTask(id, text) {
1152
+ return new UpdateTaskCommand(id, {
1153
+ text
1154
+ }, `Rename task to "${text}"`);
1155
+ }
1156
+ function setTaskStart(id, start) {
1157
+ return new UpdateTaskCommand(id, {
1158
+ start
1159
+ }, `Move task "${String(id)}" (Start)`);
1160
+ }
1161
+ function setTaskDuration(id, minutes) {
1162
+ return new UpdateTaskCommand(id, {
1163
+ duration: minutes
1164
+ }, `Change duration of task "${String(id)}"`);
1165
+ }
1166
+ function setTaskProgress(id, percent) {
1167
+ return new UpdateTaskCommand(id, {
1168
+ progress: percent
1169
+ }, `Update progress of task "${String(id)}" to ${percent}%`);
1170
+ }
1171
+ function updateTask(id, patch) {
1172
+ return new UpdateTaskCommand(id, patch);
1173
+ }
1174
+ function createTask(task, _parent, _insertAfter) {
1175
+ // _parent and _insertAfter are accepted at the public surface but not
1176
+ // wired into the underlying CreateTaskCommand in v0.4 foundation —
1177
+ // hierarchy reordering and parent-changes are downstream concerns.
1178
+ return new CreateTaskCommand(task);
1179
+ }
1180
+ function deleteTask(id) {
1181
+ return new DeleteTaskCommand(id);
1182
+ }
1183
+ /**
1184
+ * Creates a CreateLinkCommand with a **deterministic** link id derived
1185
+ * from `${source}->${target}`. This means calling `linkTasks('a', 'b')`
1186
+ * twice produces the same id — and the second `apply()` will throw
1187
+ * `EditError: duplicate link id`. By design: duplicate enqueues are a
1188
+ * consumer bug we surface loudly rather than silently coalesce.
1189
+ *
1190
+ * Consumers who legitimately need to re-add a previously-deleted link
1191
+ * (e.g. delete A→B, then re-create it without bringing the deleted one
1192
+ * back via undo) should use `new CreateLinkCommand({ id: customId, … })`
1193
+ * directly with a fresh id.
1194
+ */ function linkTasks(source, target, type = 'FS', lag = 0) {
1195
+ const link = {
1196
+ id: `${String(source)}->${String(target)}`,
1197
+ source,
1198
+ target,
1199
+ type,
1200
+ lag
1201
+ };
1202
+ return new CreateLinkCommand(link);
1203
+ }
1204
+ function updateLink(id, patch) {
1205
+ return new UpdateLinkCommand(id, patch);
1206
+ }
1207
+ function deleteLink(id) {
1208
+ return new DeleteLinkCommand(id);
1209
+ }
1210
+
1211
+ /**
1212
+ * Order tasks so that every predecessor appears before its successors.
1213
+ *
1214
+ * Operates on link-based ordering only (all dependency types FS/SS/FF/SF
1215
+ * establish "source must be visited before target" for scheduling-pass
1216
+ * purposes). Hierarchy (parent/summary) is not considered here — summary
1217
+ * task dates are derived from children after the forward/backward pass.
1218
+ *
1219
+ * Throws if the link graph contains a cycle.
1220
+ */ function topologicalSort(tasks, links) {
1221
+ const taskById = new Map(tasks.map((t)=>[
1222
+ t.id,
1223
+ t
1224
+ ]));
1225
+ const successors = new Map();
1226
+ const inDegree = new Map();
1227
+ for (const t of tasks){
1228
+ successors.set(t.id, []);
1229
+ inDegree.set(t.id, 0);
1230
+ }
1231
+ for (const link of links){
1232
+ if (!taskById.has(link.source) || !taskById.has(link.target)) continue;
1233
+ successors.get(link.source)?.push(link.target);
1234
+ inDegree.set(link.target, (inDegree.get(link.target) ?? 0) + 1);
1235
+ }
1236
+ const queue = [];
1237
+ for (const t of tasks){
1238
+ if ((inDegree.get(t.id) ?? 0) === 0) queue.push(t.id);
1239
+ }
1240
+ const ordered = [];
1241
+ while(queue.length > 0){
1242
+ const id = queue.shift();
1243
+ const t = taskById.get(id);
1244
+ if (t) ordered.push(t);
1245
+ for (const succId of successors.get(id) ?? []){
1246
+ const newDegree = (inDegree.get(succId) ?? 0) - 1;
1247
+ inDegree.set(succId, newDegree);
1248
+ if (newDegree === 0) queue.push(succId);
1249
+ }
1250
+ }
1251
+ if (ordered.length !== tasks.length) {
1252
+ throw new Error('Link graph contains a cycle; topological sort is not possible');
1253
+ }
1254
+ return ordered;
1255
+ }
1256
+
1257
+ // Forward + backward pass + critical path + constraint resolution.
1258
+ //
1259
+ // Algorithm:
1260
+ // 1. Kahn-ordered topological sort over the link graph.
1261
+ // 2. Forward pass: compute earlyStart/earlyFinish per task. After
1262
+ // predecessor-based candidates, apply forward-direction constraints
1263
+ // (ASAP/MSO/MFO/SNET/FNET).
1264
+ // 3. Backward pass: compute lateStart/lateFinish per task working right to
1265
+ // left. After successor-based candidates, apply backward-direction
1266
+ // constraints (MSO/MFO/SNLT/FNLT). MSO/MFO are hard pins — they lock
1267
+ // both early and late dates to the constraint, so predecessors that
1268
+ // can't deliver in time get negative slack on themselves.
1269
+ // 4. Slack: totalSlack = workingMinutesBetween(earlyStart, lateStart) in
1270
+ // working time. freeSlack = min working gap to earliest successor's
1271
+ // required start. isCritical = totalSlack <= 0.
1272
+ //
1273
+ // Per ADR-003, negative slack is preserved, not clipped to zero. A task
1274
+ // with negative slack is already late against a downstream constraint;
1275
+ // surfacing that is the differentiator vs every existing alternative.
1276
+ //
1277
+ // ALAP semantics (consume slack to push the task to its latest position)
1278
+ // are deferred — full ALAP requires a second forward pass after the
1279
+ // backward pass to re-flow downstream dates. For now ALAP is parsed but
1280
+ // behaves like ASAP.
1281
+ function schedule(project) {
1282
+ const calendar = getDefaultCalendar(project);
1283
+ const sorted = topologicalSort(project.tasks, project.links);
1284
+ const taskById = new Map(sorted.map((t)=>[
1285
+ t.id,
1286
+ t
1287
+ ]));
1288
+ const childrenByParent = groupChildrenByParent(project.tasks);
1289
+ const summariesByDepthDesc = summariesDeepestFirst(project.tasks);
1290
+ // Forward pass — leaves first, summaries bottom-up after.
1291
+ const forwardById = new Map();
1292
+ const projectFloor = snapToNextWorkingMoment(project.start, calendar);
1293
+ for (const task of sorted){
1294
+ if (task.type === 'summary') continue; // aggregated below
1295
+ if (task.scheduleMode === 'manual') {
1296
+ // Manual: user-set dates are authoritative. Skip predecessor logic and
1297
+ // constraint application — MS Project semantics. We still populate
1298
+ // forwardById so the backward pass can compute slack against the
1299
+ // network the user has drawn.
1300
+ forwardById.set(task.id, {
1301
+ earlyStart: new Date(task.start),
1302
+ earlyFinish: new Date(task.end)
1303
+ });
1304
+ continue;
1305
+ }
1306
+ let earliest = projectFloor;
1307
+ for (const link of incomingLinks(task.id, project.links)){
1308
+ const source = taskById.get(link.source);
1309
+ const sourceFwd = forwardById.get(link.source);
1310
+ if (!source || !sourceFwd) continue;
1311
+ const fromLink = earliestStartFromLink(link, source, task, sourceFwd, calendar);
1312
+ if (fromLink > earliest) earliest = fromLink;
1313
+ }
1314
+ forwardById.set(task.id, applyForwardConstraint(task, earliest, calendar));
1315
+ }
1316
+ // Summary forward aggregation: min(child earlyStart), max(child earlyFinish).
1317
+ // Deepest-first so a summary that contains other summaries sees its
1318
+ // descendants already aggregated.
1319
+ for (const summary of summariesByDepthDesc){
1320
+ const aggregated = aggregateFromChildren(summary, childrenByParent, forwardById);
1321
+ if (aggregated) forwardById.set(summary.id, aggregated);
1322
+ }
1323
+ // Backward pass — leaves first (reverse-sorted), summaries bottom-up after.
1324
+ const projectCeiling = projectFinishAnchor(project, forwardById, calendar);
1325
+ const backwardById = new Map();
1326
+ for (const task of [
1327
+ ...sorted
1328
+ ].reverse()){
1329
+ if (task.type === 'summary') continue;
1330
+ let latest = projectCeiling;
1331
+ for (const link of outgoingLinks(task.id, project.links)){
1332
+ const target = taskById.get(link.target);
1333
+ const targetBwd = backwardById.get(link.target);
1334
+ if (!target || !targetBwd) continue;
1335
+ const fromLink = latestFinishFromLink(link, task, target, targetBwd, calendar);
1336
+ if (fromLink < latest) latest = fromLink;
1337
+ }
1338
+ const fwd = forwardById.get(task.id);
1339
+ if (!fwd) continue;
1340
+ backwardById.set(task.id, applyBackwardConstraint(task, latest, fwd, calendar));
1341
+ }
1342
+ // Summary backward aggregation: min(child lateStart), max(child lateFinish).
1343
+ for (const summary of summariesByDepthDesc){
1344
+ const aggregated = aggregateBackwardFromChildren(summary, childrenByParent, backwardById);
1345
+ if (aggregated) backwardById.set(summary.id, aggregated);
1346
+ }
1347
+ // Assemble final tasks with computed slack + critical.
1348
+ // For auto-mode tasks, write the scheduled dates back to task.start/end
1349
+ // (MS Project default behavior). Manual-mode tasks keep their user-set
1350
+ // dates regardless.
1351
+ const newTasks = project.tasks.map((t)=>{
1352
+ const f = forwardById.get(t.id);
1353
+ const b = backwardById.get(t.id);
1354
+ if (!f || !b) return t;
1355
+ const totalSlack = workingMinutesBetween(f.earlyStart, b.lateStart, calendar);
1356
+ const freeSlack = computeFreeSlack(t, f, forwardById, project.links, calendar);
1357
+ const computed = {
1358
+ earlyStart: f.earlyStart,
1359
+ earlyFinish: f.earlyFinish,
1360
+ lateStart: b.lateStart,
1361
+ lateFinish: b.lateFinish,
1362
+ totalSlack,
1363
+ freeSlack,
1364
+ isCritical: totalSlack <= 0
1365
+ };
1366
+ if (t.type === 'summary') {
1367
+ // Summary: dates + duration derived from child span; always overwritten.
1368
+ return {
1369
+ ...t,
1370
+ start: new Date(f.earlyStart),
1371
+ end: new Date(f.earlyFinish),
1372
+ duration: workingMinutesBetween(f.earlyStart, f.earlyFinish, calendar),
1373
+ computed
1374
+ };
1375
+ }
1376
+ if (t.scheduleMode === 'auto') {
1377
+ return {
1378
+ ...t,
1379
+ start: new Date(f.earlyStart),
1380
+ end: new Date(f.earlyFinish),
1381
+ computed
1382
+ };
1383
+ }
1384
+ return {
1385
+ ...t,
1386
+ computed
1387
+ };
1388
+ });
1389
+ return {
1390
+ ...project,
1391
+ tasks: newTasks
1392
+ };
1393
+ }
1394
+ // ---------------------------------------------------------------------------
1395
+ // Constraint application
1396
+ // ---------------------------------------------------------------------------
1397
+ function applyForwardConstraint(task, predecessorEarliestStart, calendar) {
1398
+ const baseStart = snapToNextWorkingMoment(predecessorEarliestStart, calendar);
1399
+ const baseFinish = addWorkingMinutes(baseStart, task.duration, calendar);
1400
+ const base = {
1401
+ earlyStart: baseStart,
1402
+ earlyFinish: baseFinish
1403
+ };
1404
+ const c = task.constraint;
1405
+ if (!c) return base;
1406
+ switch(c.type){
1407
+ case 'ASAP':
1408
+ case 'ALAP':
1409
+ return base;
1410
+ // For constraint dates we trust the user-supplied moment as-is. Snapping
1411
+ // gets fiddly for finish-end-of-interval boundaries (5pm is a valid
1412
+ // finish but not a valid start). Document: constraint dates should be
1413
+ // working-time moments; weird inputs produce weird outputs.
1414
+ case 'MSO':
1415
+ {
1416
+ if (!c.date) return base;
1417
+ const earlyStart = new Date(c.date);
1418
+ const earlyFinish = addWorkingMinutes(earlyStart, task.duration, calendar);
1419
+ return {
1420
+ earlyStart,
1421
+ earlyFinish
1422
+ };
1423
+ }
1424
+ case 'MFO':
1425
+ {
1426
+ if (!c.date) return base;
1427
+ const earlyFinish = new Date(c.date);
1428
+ const earlyStart = subtractWorkingMinutes(earlyFinish, task.duration, calendar);
1429
+ return {
1430
+ earlyStart,
1431
+ earlyFinish
1432
+ };
1433
+ }
1434
+ case 'SNET':
1435
+ {
1436
+ if (!c.date) return base;
1437
+ const earlyStart = c.date > baseStart ? new Date(c.date) : baseStart;
1438
+ const earlyFinish = addWorkingMinutes(earlyStart, task.duration, calendar);
1439
+ return {
1440
+ earlyStart,
1441
+ earlyFinish
1442
+ };
1443
+ }
1444
+ case 'FNET':
1445
+ {
1446
+ if (!c.date) return base;
1447
+ if (c.date <= baseFinish) return base;
1448
+ const earlyFinish = new Date(c.date);
1449
+ const earlyStart = subtractWorkingMinutes(earlyFinish, task.duration, calendar);
1450
+ return {
1451
+ earlyStart,
1452
+ earlyFinish
1453
+ };
1454
+ }
1455
+ case 'SNLT':
1456
+ case 'FNLT':
1457
+ // Backward-direction constraints; no forward-pass effect.
1458
+ return base;
1459
+ }
1460
+ }
1461
+ function applyBackwardConstraint(task, successorLatestFinish, forward, calendar) {
1462
+ const baseLateFinish = snapToPreviousWorkingMoment(successorLatestFinish, calendar);
1463
+ const baseLateStart = subtractWorkingMinutes(baseLateFinish, task.duration, calendar);
1464
+ const base = {
1465
+ lateStart: baseLateStart,
1466
+ lateFinish: baseLateFinish
1467
+ };
1468
+ const c = task.constraint;
1469
+ if (!c) return base;
1470
+ switch(c.type){
1471
+ case 'ASAP':
1472
+ case 'ALAP':
1473
+ case 'SNET':
1474
+ case 'FNET':
1475
+ return base;
1476
+ case 'MSO':
1477
+ case 'MFO':
1478
+ // Hard pin: task is locked at the forward-pass date. Slack on the
1479
+ // task itself is zero; impossibility propagates back to predecessors.
1480
+ return {
1481
+ lateStart: forward.earlyStart,
1482
+ lateFinish: forward.earlyFinish
1483
+ };
1484
+ case 'SNLT':
1485
+ {
1486
+ if (!c.date) return base;
1487
+ const lateStart = c.date < baseLateStart ? new Date(c.date) : baseLateStart;
1488
+ const lateFinish = addWorkingMinutes(lateStart, task.duration, calendar);
1489
+ return {
1490
+ lateStart,
1491
+ lateFinish
1492
+ };
1493
+ }
1494
+ case 'FNLT':
1495
+ {
1496
+ if (!c.date) return base;
1497
+ const lateFinish = c.date < baseLateFinish ? new Date(c.date) : baseLateFinish;
1498
+ const lateStart = subtractWorkingMinutes(lateFinish, task.duration, calendar);
1499
+ return {
1500
+ lateStart,
1501
+ lateFinish
1502
+ };
1503
+ }
1504
+ }
1505
+ }
1506
+ // ---------------------------------------------------------------------------
1507
+ // Link semantics
1508
+ // ---------------------------------------------------------------------------
1509
+ function earliestStartFromLink(link, _source, target, sourceFwd, calendar) {
1510
+ switch(link.type){
1511
+ case 'FS':
1512
+ return addWorkingTime(sourceFwd.earlyFinish, link.lag, calendar);
1513
+ case 'SS':
1514
+ return addWorkingTime(sourceFwd.earlyStart, link.lag, calendar);
1515
+ case 'FF':
1516
+ {
1517
+ const finishConstraint = addWorkingTime(sourceFwd.earlyFinish, link.lag, calendar);
1518
+ return subtractWorkingMinutes(finishConstraint, target.duration, calendar);
1519
+ }
1520
+ case 'SF':
1521
+ {
1522
+ const finishConstraint = addWorkingTime(sourceFwd.earlyStart, link.lag, calendar);
1523
+ return subtractWorkingMinutes(finishConstraint, target.duration, calendar);
1524
+ }
1525
+ }
1526
+ }
1527
+ function latestFinishFromLink(link, source, _target, targetBwd, calendar) {
1528
+ switch(link.type){
1529
+ case 'FS':
1530
+ return subtractWorkingMinutes(targetBwd.lateStart, link.lag, calendar);
1531
+ case 'SS':
1532
+ {
1533
+ const sourceLateStart = subtractWorkingMinutes(targetBwd.lateStart, link.lag, calendar);
1534
+ return addWorkingMinutes(sourceLateStart, source.duration, calendar);
1535
+ }
1536
+ case 'FF':
1537
+ return subtractWorkingMinutes(targetBwd.lateFinish, link.lag, calendar);
1538
+ case 'SF':
1539
+ {
1540
+ const sourceLateStart = subtractWorkingMinutes(targetBwd.lateFinish, link.lag, calendar);
1541
+ return addWorkingMinutes(sourceLateStart, source.duration, calendar);
1542
+ }
1543
+ }
1544
+ }
1545
+ // ---------------------------------------------------------------------------
1546
+ // Helpers
1547
+ // ---------------------------------------------------------------------------
1548
+ function getDefaultCalendar(project) {
1549
+ const calendar = project.calendars.find((c)=>c.id === project.defaultCalendarId);
1550
+ if (!calendar) {
1551
+ throw new Error(`Project default calendar "${project.defaultCalendarId}" not found`);
1552
+ }
1553
+ return calendar;
1554
+ }
1555
+ function incomingLinks(taskId, links) {
1556
+ return links.filter((l)=>l.target === taskId);
1557
+ }
1558
+ function outgoingLinks(taskId, links) {
1559
+ return links.filter((l)=>l.source === taskId);
1560
+ }
1561
+ function projectFinishAnchor(project, forwardById, calendar) {
1562
+ if (project.end) return snapToPreviousWorkingMoment(project.end, calendar);
1563
+ let latest;
1564
+ for (const f of forwardById.values()){
1565
+ if (!latest || f.earlyFinish > latest) latest = f.earlyFinish;
1566
+ }
1567
+ return latest ?? snapToNextWorkingMoment(project.start, calendar);
1568
+ }
1569
+ function addWorkingTime(date, minutes, calendar) {
1570
+ if (minutes > 0) return addWorkingMinutes(date, minutes, calendar);
1571
+ if (minutes < 0) return subtractWorkingMinutes(date, -minutes, calendar);
1572
+ return new Date(date);
1573
+ }
1574
+ // ---------------------------------------------------------------------------
1575
+ // Summary task hierarchy
1576
+ // ---------------------------------------------------------------------------
1577
+ function groupChildrenByParent(tasks) {
1578
+ const map = new Map();
1579
+ for (const t of tasks){
1580
+ if (t.parent === undefined) continue;
1581
+ const list = map.get(t.parent) ?? [];
1582
+ list.push(t);
1583
+ map.set(t.parent, list);
1584
+ }
1585
+ return map;
1586
+ }
1587
+ function summariesDeepestFirst(tasks) {
1588
+ const parentById = new Map();
1589
+ for (const t of tasks)parentById.set(t.id, t.parent);
1590
+ const depthCache = new Map();
1591
+ function depthOf(id) {
1592
+ const cached = depthCache.get(id);
1593
+ if (cached !== undefined) return cached;
1594
+ const parent = parentById.get(id);
1595
+ const d = parent === undefined ? 0 : depthOf(parent) + 1;
1596
+ depthCache.set(id, d);
1597
+ return d;
1598
+ }
1599
+ return tasks.filter((t)=>t.type === 'summary').map((t)=>({
1600
+ task: t,
1601
+ depth: depthOf(t.id)
1602
+ })).sort((a, b)=>b.depth - a.depth).map((x)=>x.task);
1603
+ }
1604
+ function aggregateFromChildren(summary, childrenByParent, forwardById) {
1605
+ const children = childrenByParent.get(summary.id) ?? [];
1606
+ const dates = children.map((c)=>forwardById.get(c.id)).filter((d)=>d !== undefined);
1607
+ if (dates.length === 0) return undefined;
1608
+ let earlyStartMs = Number.POSITIVE_INFINITY;
1609
+ let earlyFinishMs = Number.NEGATIVE_INFINITY;
1610
+ for (const d of dates){
1611
+ if (d.earlyStart.getTime() < earlyStartMs) earlyStartMs = d.earlyStart.getTime();
1612
+ if (d.earlyFinish.getTime() > earlyFinishMs) earlyFinishMs = d.earlyFinish.getTime();
1613
+ }
1614
+ return {
1615
+ earlyStart: new Date(earlyStartMs),
1616
+ earlyFinish: new Date(earlyFinishMs)
1617
+ };
1618
+ }
1619
+ function aggregateBackwardFromChildren(summary, childrenByParent, backwardById) {
1620
+ const children = childrenByParent.get(summary.id) ?? [];
1621
+ const dates = children.map((c)=>backwardById.get(c.id)).filter((d)=>d !== undefined);
1622
+ if (dates.length === 0) return undefined;
1623
+ let lateStartMs = Number.POSITIVE_INFINITY;
1624
+ let lateFinishMs = Number.NEGATIVE_INFINITY;
1625
+ for (const d of dates){
1626
+ if (d.lateStart.getTime() < lateStartMs) lateStartMs = d.lateStart.getTime();
1627
+ if (d.lateFinish.getTime() > lateFinishMs) lateFinishMs = d.lateFinish.getTime();
1628
+ }
1629
+ return {
1630
+ lateStart: new Date(lateStartMs),
1631
+ lateFinish: new Date(lateFinishMs)
1632
+ };
1633
+ }
1634
+ function computeFreeSlack(task, taskFwd, forwardById, links, calendar) {
1635
+ const outgoing = links.filter((l)=>l.source === task.id);
1636
+ if (outgoing.length === 0) return 0;
1637
+ let minGap = Number.POSITIVE_INFINITY;
1638
+ for (const link of outgoing){
1639
+ const targetFwd = forwardById.get(link.target);
1640
+ if (!targetFwd) continue;
1641
+ let requiredEnd;
1642
+ switch(link.type){
1643
+ case 'FS':
1644
+ requiredEnd = subtractWorkingMinutes(targetFwd.earlyStart, link.lag, calendar);
1645
+ break;
1646
+ case 'SS':
1647
+ requiredEnd = addWorkingMinutes(subtractWorkingMinutes(targetFwd.earlyStart, link.lag, calendar), task.duration, calendar);
1648
+ break;
1649
+ case 'FF':
1650
+ requiredEnd = subtractWorkingMinutes(targetFwd.earlyFinish, link.lag, calendar);
1651
+ break;
1652
+ case 'SF':
1653
+ requiredEnd = addWorkingMinutes(subtractWorkingMinutes(targetFwd.earlyFinish, link.lag, calendar), task.duration, calendar);
1654
+ break;
1655
+ }
1656
+ const gap = workingMinutesBetween(taskFwd.earlyFinish, requiredEnd, calendar);
1657
+ if (gap < minGap) minGap = gap;
1658
+ }
1659
+ return Number.isFinite(minGap) ? minGap : 0;
1660
+ }
1661
+
1662
+ function newHistory() {
1663
+ return {
1664
+ past: [],
1665
+ future: [],
1666
+ canUndo: false,
1667
+ canRedo: false
1668
+ };
1669
+ }
1670
+ function pushCommand(h, command) {
1671
+ return {
1672
+ past: [
1673
+ ...h.past,
1674
+ command
1675
+ ],
1676
+ future: [],
1677
+ canUndo: true,
1678
+ canRedo: false
1679
+ };
1680
+ }
1681
+ function undo(h, project) {
1682
+ if (h.past.length === 0) return null;
1683
+ const cmd = h.past[h.past.length - 1];
1684
+ if (!cmd) return null; // unreachable under noUncheckedIndexedAccess; preserves type narrowing
1685
+ const inverse = cmd.inverse(project);
1686
+ const nextProject = inverse.apply(project);
1687
+ const nextPast = h.past.slice(0, -1);
1688
+ return {
1689
+ nextHistory: {
1690
+ past: nextPast,
1691
+ future: [
1692
+ ...h.future,
1693
+ cmd
1694
+ ],
1695
+ canUndo: nextPast.length > 0,
1696
+ canRedo: true
1697
+ },
1698
+ nextProject,
1699
+ undoneCommand: cmd
1700
+ };
1701
+ }
1702
+ function redo(h, project) {
1703
+ if (h.future.length === 0) return null;
1704
+ const cmd = h.future[h.future.length - 1];
1705
+ if (!cmd) return null; // unreachable under noUncheckedIndexedAccess; preserves type narrowing
1706
+ const nextProject = cmd.apply(project);
1707
+ const nextFuture = h.future.slice(0, -1);
1708
+ return {
1709
+ nextHistory: {
1710
+ past: [
1711
+ ...h.past,
1712
+ cmd
1713
+ ],
1714
+ future: nextFuture,
1715
+ canUndo: true,
1716
+ canRedo: nextFuture.length > 0
1717
+ },
1718
+ nextProject,
1719
+ redoneCommand: cmd
1720
+ };
1721
+ }
1722
+
1723
+ /**
1724
+ * Wraps N EditCommands into a single history entry. Apply runs each
1725
+ * member in order; if any throws, the whole composite throws (caller
1726
+ * sees a single error — the no-partial-output guarantee). Inverse walks
1727
+ * members in REVERSE order, collecting each member's inverse — which
1728
+ * reads from the snapshot captured during the member's apply.
1729
+ *
1730
+ * Single-use semantics: composite.apply() MUST be called before
1731
+ * composite.inverse(), because the stateful member commands
1732
+ * (Update*Command, Delete*Command) only have their snapshots after
1733
+ * apply(). Calling inverse() without apply() throws the underlying
1734
+ * member's "apply() was not called" EditError.
1735
+ *
1736
+ * **Contract caveat for members.** This implementation assumes every
1737
+ * member is either stateless (Create*Command — its inverse derives
1738
+ * from constructor args) or uses the snapshot-at-apply pattern (the
1739
+ * built-in Update*Command and Delete*Command). The `_project` arg
1740
+ * passed to each member's `inverse()` here is the composite's `_project`
1741
+ * (the post-composite-apply state), NOT the post-member-apply state
1742
+ * for that particular member. Built-in stateful commands ignore the
1743
+ * arg and read their own snapshot, so this works correctly.
1744
+ *
1745
+ * If a future EditCommand implementation needs the actual post-member-
1746
+ * apply state inside a composite, the implementation must either:
1747
+ * (a) adopt snapshot-at-apply itself (recommended — matches built-ins),
1748
+ * (b) extend CompositeCommand to walk the apply chain and pass each
1749
+ * member its specific post-state to inverse().
1750
+ *
1751
+ * Produced by DraftProject.commit() when N>1 pending commands need to
1752
+ * land as one history entry. Single-pending-command commits return
1753
+ * the member directly without wrapping (per DraftProject contract).
1754
+ */ class CompositeCommand {
1755
+ constructor(members, label){
1756
+ this.kind = 'composite';
1757
+ this.members = members;
1758
+ this.label = label;
1759
+ }
1760
+ apply(project) {
1761
+ let cur = project;
1762
+ for (const cmd of this.members){
1763
+ cur = cmd.apply(cur);
1764
+ }
1765
+ return cur;
1766
+ }
1767
+ inverse(_project) {
1768
+ // Members store their own pre-state via snapshot-at-apply. We just
1769
+ // collect each member's inverse in reverse order.
1770
+ const inverses = [];
1771
+ const reversed = [
1772
+ ...this.members
1773
+ ].reverse();
1774
+ for (const cmd of reversed){
1775
+ inverses.push(cmd.inverse(_project));
1776
+ }
1777
+ return new CompositeCommand(inverses, `Undo: ${this.label}`);
1778
+ }
1779
+ }
1780
+
1781
+ function newDraft(base) {
1782
+ return {
1783
+ base,
1784
+ pending: [],
1785
+ effective: base,
1786
+ isDirty: false
1787
+ };
1788
+ }
1789
+ function enqueue(draft, command) {
1790
+ const effective = command.apply(draft.effective);
1791
+ return {
1792
+ base: draft.base,
1793
+ pending: [
1794
+ ...draft.pending,
1795
+ command
1796
+ ],
1797
+ effective,
1798
+ isDirty: true
1799
+ };
1800
+ }
1801
+ function commit(draft, label = 'Edit') {
1802
+ if (draft.pending.length === 0) {
1803
+ throw new EditError('cannot commit an empty draft', 'commit');
1804
+ }
1805
+ const compound = draft.pending.length === 1 ? draft.pending[0] : new CompositeCommand(draft.pending, label);
1806
+ if (!compound) {
1807
+ throw new EditError('unreachable: empty pending', 'commit');
1808
+ }
1809
+ return {
1810
+ newBase: draft.effective,
1811
+ compound
1812
+ };
1813
+ }
1814
+ function cancel(draft) {
1815
+ return newDraft(draft.base);
1816
+ }
1817
+
1818
+ function reducer(state, action) {
1819
+ switch(action.type){
1820
+ case 'enqueue':
1821
+ return {
1822
+ ...state,
1823
+ draft: enqueue(state.draft, action.command)
1824
+ };
1825
+ case 'commit':
1826
+ {
1827
+ if (state.draft.pending.length === 0) return state;
1828
+ const { newBase, compound } = commit(state.draft, action.label);
1829
+ return {
1830
+ draft: newDraft(newBase),
1831
+ history: pushCommand(state.history, compound)
1832
+ };
1833
+ }
1834
+ case 'cancel':
1835
+ return {
1836
+ ...state,
1837
+ draft: cancel(state.draft)
1838
+ };
1839
+ case 'undo':
1840
+ {
1841
+ // If dirty, cancel pending first (matches VS Code / Figma).
1842
+ const cleaned = state.draft.isDirty ? cancel(state.draft) : state.draft;
1843
+ const result = undo(state.history, cleaned.base);
1844
+ if (!result) return {
1845
+ ...state,
1846
+ draft: cleaned
1847
+ };
1848
+ return {
1849
+ draft: newDraft(result.nextProject),
1850
+ history: result.nextHistory
1851
+ };
1852
+ }
1853
+ case 'redo':
1854
+ {
1855
+ const cleaned = state.draft.isDirty ? cancel(state.draft) : state.draft;
1856
+ const result = redo(state.history, cleaned.base);
1857
+ if (!result) return {
1858
+ ...state,
1859
+ draft: cleaned
1860
+ };
1861
+ return {
1862
+ draft: newDraft(result.nextProject),
1863
+ history: result.nextHistory
1864
+ };
1865
+ }
1866
+ }
1867
+ }
1868
+ /**
1869
+ * The single hook entry point for v0.4 editing. Captures `initial` once
1870
+ * on first mount (subsequent renders with a different `initial` are
1871
+ * ignored — consumers reset by remounting via `key={projectId}`).
1872
+ *
1873
+ * Every effective state change is run through `schedule()` so the
1874
+ * returned `project` always has fresh CPM data. Per ADR-005 (engine-first).
1875
+ */ function useEditableProject(initial) {
1876
+ // Capture initial once. useRef freezes the value across re-renders.
1877
+ const initialRef = useRef(initial);
1878
+ const [state, dispatch] = useReducer(reducer, undefined, ()=>({
1879
+ draft: newDraft(initialRef.current),
1880
+ history: newHistory()
1881
+ }));
1882
+ const scheduled = useMemo(()=>schedule(state.draft.effective), [
1883
+ state.draft.effective
1884
+ ]);
1885
+ return {
1886
+ project: scheduled,
1887
+ isDirty: state.draft.isDirty,
1888
+ canUndo: state.history.canUndo,
1889
+ canRedo: state.history.canRedo,
1890
+ enqueue: (command)=>dispatch({
1891
+ type: 'enqueue',
1892
+ command
1893
+ }),
1894
+ commit: (label = 'Edit')=>dispatch({
1895
+ type: 'commit',
1896
+ label
1897
+ }),
1898
+ cancel: ()=>dispatch({
1899
+ type: 'cancel'
1900
+ }),
1901
+ undo: ()=>dispatch({
1902
+ type: 'undo'
1903
+ }),
1904
+ redo: ()=>dispatch({
1905
+ type: 'redo'
1906
+ })
1907
+ };
1908
+ }
1909
+
1910
+ __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.construction-gantt-marker-today{background:#ef4444;color:#fff;font-weight:600}.construction-gantt-marker-milestone{background:#2563eb;color:#fff;font-weight:500}");
1911
+
1912
+ // Render-only visibility filter for <Gantt> tasks.
1913
+ //
1914
+ // Per ADR-005 (engine-first; no BYO-CPM mode), consumer domain rules around
1915
+ // hiding tasks must surface as render-time props, never as engine bypasses.
1916
+ // This is the canonical example: the engine always runs on the full task
1917
+ // set; the visibility filter applies only to the rendered output.
1918
+ //
1919
+ // Direct lift from the CM domain rule (FrappeGanttView.tsx:366-371 in
1920
+ // Bode-Builds): "Hiding a predecessor would otherwise make a dependent
1921
+ // task's early-start collapse to 0, which is wrong." We honour the rule
1922
+ // by structure — the filter sits AFTER schedule() in the pipeline, so
1923
+ // computed fields on the visible tasks reflect the full schedule.
1924
+ /**
1925
+ * Return the subset of `tasks` whose ids appear in `visibleTaskIds`.
1926
+ *
1927
+ * - `undefined` → no filter; the original array reference is returned
1928
+ * so React's `useMemo` identity stays stable on the noop path.
1929
+ * - empty set → returns an empty array (hide everything).
1930
+ * - set containing ids not present in `tasks` → unknown ids ignored;
1931
+ * only matching tasks survive.
1932
+ *
1933
+ * Crucially: this is a pure projection. `task.computed` is preserved
1934
+ * exactly — no recomputation, no clipping, no slack adjustment. The
1935
+ * engine already ran on the full set; we trust its result.
1936
+ */ function filterTasksByVisibility(tasks, visibleTaskIds) {
1937
+ if (visibleTaskIds === undefined) return tasks;
1938
+ return tasks.filter((t)=>visibleTaskIds.has(t.id));
1939
+ }
1940
+
1941
+ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, cellWidth = 48, cellHeight = 42, preScheduled = false, markers, baselineIndex, showBaselineBars, columns, visibleTaskIds }, ref) {
1942
+ const containerRef = useRef(null);
1943
+ const scheduled = useMemo(()=>preScheduled ? project : schedule(project), [
1944
+ project,
1945
+ preScheduled
1946
+ ]);
1947
+ // Visibility filter is render-only — applied AFTER schedule() has run so
1948
+ // computed fields on visible tasks reflect the full project. ADR-005.
1949
+ const renderableTasks = useMemo(()=>filterTasksByVisibility(scheduled.tasks, visibleTaskIds), [
1950
+ scheduled.tasks,
1951
+ visibleTaskIds
1952
+ ]);
1953
+ const calendar = useMemo(()=>scheduled.calendars.find((c)=>c.id === scheduled.defaultCalendarId), [
1954
+ scheduled.calendars,
1955
+ scheduled.defaultCalendarId
1956
+ ]);
1957
+ const baseline = useMemo(()=>{
1958
+ if (baselineIndex === undefined) return undefined;
1959
+ return scheduled.baselines.find((b)=>b.index === baselineIndex);
1960
+ }, [
1961
+ scheduled.baselines,
1962
+ baselineIndex
1963
+ ]);
1964
+ const ghostBarsEnabled = baseline !== undefined && (showBaselineBars ?? true);
1965
+ const svarTasks = useMemo(()=>{
1966
+ if (!ghostBarsEnabled || !baseline) {
1967
+ return renderableTasks.map((t)=>toSvarTask(t, baseline, calendar));
1968
+ }
1969
+ // Interleave each real task with its baseline-snapshot ghost row.
1970
+ // Phantoms share the real task's `parent` so they stay grouped under
1971
+ // the same summary in hierarchy views.
1972
+ const out = [];
1973
+ for (const t of renderableTasks){
1974
+ out.push(toSvarTask(t, baseline, calendar));
1975
+ if (t.type === 'summary') continue;
1976
+ const phantom = makeBaselinePhantom(t, baseline);
1977
+ if (phantom) out.push(phantom);
1978
+ }
1979
+ return out;
1980
+ }, [
1981
+ renderableTasks,
1982
+ baseline,
1983
+ calendar,
1984
+ ghostBarsEnabled
1985
+ ]);
1986
+ const svarLinks = useMemo(()=>scheduled.links.map(toSvarLink), [
1987
+ scheduled.links
1988
+ ]);
1989
+ const projectEnd = useMemo(()=>getProjectEnd(scheduled), [
1990
+ scheduled
1991
+ ]);
1992
+ const svarMarkers = useMemo(()=>resolveMarkers(markers, scheduled.start, projectEnd), [
1993
+ markers,
1994
+ scheduled.start,
1995
+ projectEnd
1996
+ ]);
1997
+ const highlightTime = useMemo(()=>buildHighlightTime(calendar), [
1998
+ calendar
1999
+ ]);
2000
+ // Convert our SVAR-agnostic GanttColumn[] to SVAR's IColumnConfig[].
2001
+ // undefined → don't pass columns to SVAR (use SVAR defaults).
2002
+ // [] → pass false to SVAR (hide grid entirely).
2003
+ // [...] → convert each column.
2004
+ const svarColumns = useMemo(()=>{
2005
+ if (columns === undefined) return undefined;
2006
+ if (columns.length === 0) return false;
2007
+ return columns.map(toSvarColumn);
2008
+ }, [
2009
+ columns
2010
+ ]);
2011
+ useImperativeHandle(ref, ()=>({
2012
+ async exportPNG (options) {
2013
+ const { exportPNG } = await import('./png-DKZeKnRh.js');
2014
+ return exportPNG({
2015
+ scheduled,
2016
+ ganttProps: {
2017
+ cellWidth,
2018
+ cellHeight,
2019
+ markers,
2020
+ baselineIndex,
2021
+ showBaselineBars,
2022
+ columns,
2023
+ height,
2024
+ visibleTaskIds
2025
+ },
2026
+ options: options ?? {}
2027
+ });
2028
+ },
2029
+ async exportPDF (options) {
2030
+ const { exportPDF } = await import('./pdf-CBaoJRTI.js');
2031
+ return exportPDF({
2032
+ scheduled,
2033
+ ganttProps: {
2034
+ cellWidth,
2035
+ cellHeight,
2036
+ markers,
2037
+ baselineIndex,
2038
+ showBaselineBars,
2039
+ columns,
2040
+ height,
2041
+ visibleTaskIds
2042
+ },
2043
+ options: options ?? {}
2044
+ });
2045
+ },
2046
+ async exportXLSX (options) {
2047
+ const { exportXLSX } = await import('./xlsx-5FRPFck7.js');
2048
+ return exportXLSX({
2049
+ scheduled,
2050
+ options: options ?? {}
2051
+ });
2052
+ }
2053
+ }), [
2054
+ scheduled,
2055
+ cellWidth,
2056
+ cellHeight,
2057
+ markers,
2058
+ baselineIndex,
2059
+ showBaselineBars,
2060
+ columns,
2061
+ height,
2062
+ visibleTaskIds
2063
+ ]);
2064
+ return /*#__PURE__*/ jsx("div", {
2065
+ ref: containerRef,
2066
+ style: {
2067
+ height
2068
+ },
2069
+ children: /*#__PURE__*/ jsx(Gantt$1, {
2070
+ tasks: svarTasks,
2071
+ links: svarLinks,
2072
+ start: scheduled.start,
2073
+ end: projectEnd,
2074
+ cellWidth: cellWidth,
2075
+ cellHeight: cellHeight,
2076
+ markers: svarMarkers,
2077
+ highlightTime: highlightTime,
2078
+ taskTemplate: ConstructionBar,
2079
+ ...svarColumns !== undefined ? {
2080
+ columns: svarColumns
2081
+ } : {}
2082
+ })
2083
+ });
2084
+ });
2085
+ const ConstructionBar = ({ data })=>{
2086
+ // Phantom baseline row — render a slim outlined ghost bar.
2087
+ if (data.is_baseline_ghost) {
2088
+ return /*#__PURE__*/ jsx("div", {
2089
+ style: {
2090
+ height: '60%',
2091
+ marginTop: '15%',
2092
+ border: '1.5px dashed #94a3b8',
2093
+ background: 'transparent',
2094
+ borderRadius: 3,
2095
+ fontSize: 9,
2096
+ color: '#64748b',
2097
+ display: 'flex',
2098
+ alignItems: 'center',
2099
+ padding: '0 6px',
2100
+ fontStyle: 'italic',
2101
+ whiteSpace: 'nowrap'
2102
+ },
2103
+ title: "Baseline position — where this task was when the baseline was captured",
2104
+ children: "baseline"
2105
+ });
2106
+ }
2107
+ const isCritical = data.is_critical ?? false;
2108
+ const isLate = data.is_late ?? false;
2109
+ const isSummary = data.type === 'summary';
2110
+ const isMilestone = data.type === 'milestone';
2111
+ if (isMilestone) {
2112
+ return /*#__PURE__*/ jsxs("div", {
2113
+ style: {
2114
+ width: '100%',
2115
+ height: '100%',
2116
+ display: 'flex',
2117
+ alignItems: 'center',
2118
+ justifyContent: 'center',
2119
+ color: isCritical ? '#fff' : '#1f2937',
2120
+ fontWeight: 600,
2121
+ fontSize: 11
2122
+ },
2123
+ children: [
2124
+ "◆ ",
2125
+ data.text
2126
+ ]
2127
+ });
2128
+ }
2129
+ const isSlipped = data.is_slipped ?? false;
2130
+ const isAhead = data.is_ahead ?? false;
2131
+ // Show slack indicator for non-critical, non-summary, non-milestone tasks
2132
+ // with at least half a working day of total float. Skips the noise of "+5m"
2133
+ // pills on the visually-critical path tasks.
2134
+ const totalSlack = data.total_slack ?? 0;
2135
+ const showSlackIndicator = !isSummary && !isCritical && totalSlack >= 270; // >= 30 min more than half a day
2136
+ return /*#__PURE__*/ jsxs("div", {
2137
+ style: {
2138
+ display: 'flex',
2139
+ alignItems: 'center',
2140
+ gap: 6,
2141
+ height: '100%',
2142
+ padding: '0 8px',
2143
+ fontSize: 12,
2144
+ fontWeight: isSummary ? 600 : 500,
2145
+ color: isCritical ? '#fff' : '#1f2937',
2146
+ background: isSummary ? 'transparent' : isCritical ? '#dc2626' : '#cbd5e1',
2147
+ borderRadius: 4,
2148
+ border: isSummary ? '2px solid #1e293b' : undefined
2149
+ },
2150
+ children: [
2151
+ /*#__PURE__*/ jsx("span", {
2152
+ style: {
2153
+ flex: 1,
2154
+ overflow: 'hidden',
2155
+ textOverflow: 'ellipsis',
2156
+ whiteSpace: 'nowrap'
2157
+ },
2158
+ children: data.text
2159
+ }),
2160
+ isSlipped && /*#__PURE__*/ jsxs("span", {
2161
+ style: {
2162
+ padding: '0 6px',
2163
+ background: '#fed7aa',
2164
+ color: '#7c2d12',
2165
+ borderRadius: 3,
2166
+ fontSize: 10,
2167
+ fontWeight: 700,
2168
+ lineHeight: '16px',
2169
+ whiteSpace: 'nowrap'
2170
+ },
2171
+ title: "Drifted later than the baseline",
2172
+ children: [
2173
+ "+",
2174
+ workingMinutesToShortLabel(data.start_variance ?? 0)
2175
+ ]
2176
+ }),
2177
+ isAhead && /*#__PURE__*/ jsxs("span", {
2178
+ style: {
2179
+ padding: '0 6px',
2180
+ background: '#bbf7d0',
2181
+ color: '#14532d',
2182
+ borderRadius: 3,
2183
+ fontSize: 10,
2184
+ fontWeight: 700,
2185
+ lineHeight: '16px',
2186
+ whiteSpace: 'nowrap'
2187
+ },
2188
+ title: "Ahead of the baseline",
2189
+ children: [
2190
+ "−",
2191
+ workingMinutesToShortLabel(data.start_variance ?? 0)
2192
+ ]
2193
+ }),
2194
+ showSlackIndicator && /*#__PURE__*/ jsxs("span", {
2195
+ style: {
2196
+ padding: '0 6px',
2197
+ background: '#dbeafe',
2198
+ color: '#1e3a8a',
2199
+ borderRadius: 3,
2200
+ fontSize: 10,
2201
+ fontWeight: 600,
2202
+ lineHeight: '16px',
2203
+ whiteSpace: 'nowrap'
2204
+ },
2205
+ title: "Total float — how much this task can slip before becoming critical",
2206
+ children: [
2207
+ workingMinutesToShortLabel(totalSlack),
2208
+ " float"
2209
+ ]
2210
+ }),
2211
+ isLate && /*#__PURE__*/ jsxs("span", {
2212
+ style: {
2213
+ padding: '0 6px',
2214
+ background: '#fde68a',
2215
+ color: '#78350f',
2216
+ borderRadius: 3,
2217
+ fontSize: 10,
2218
+ fontWeight: 700,
2219
+ lineHeight: '16px',
2220
+ whiteSpace: 'nowrap'
2221
+ },
2222
+ title: "Negative slack — contract trouble",
2223
+ children: [
2224
+ workingMinutesToShortLabel(data.total_slack ?? 0),
2225
+ " late"
2226
+ ]
2227
+ })
2228
+ ]
2229
+ });
2230
+ };
2231
+ function toSvarTask(t, baseline, calendar) {
2232
+ const variance = baseline && calendar ? getTaskBaselineVariance(t, baseline, calendar) : undefined;
2233
+ const startVariance = variance?.startVariance ?? 0;
2234
+ const base = {
2235
+ id: t.id,
2236
+ text: t.text,
2237
+ start: t.start,
2238
+ end: t.end,
2239
+ duration: t.duration,
2240
+ progress: t.progress,
2241
+ type: t.type,
2242
+ parent: t.parent,
2243
+ is_critical: t.computed?.isCritical ?? false,
2244
+ is_late: (t.computed?.totalSlack ?? 0) < 0,
2245
+ total_slack: t.computed?.totalSlack ?? 0,
2246
+ start_variance: startVariance,
2247
+ is_slipped: startVariance >= 30,
2248
+ is_ahead: startVariance <= -30
2249
+ };
2250
+ // `open` only meaningful on summary tasks. Setting it on leaves trips
2251
+ // SVAR's child-iteration path (null forEach).
2252
+ if (t.type === 'summary') base.open = t.open ?? true;
2253
+ return base;
2254
+ }
2255
+ function makeBaselinePhantom(t, baseline) {
2256
+ const snap = baseline.tasks.get(t.id);
2257
+ if (!snap) return null;
2258
+ return {
2259
+ id: `${t.id}-baseline-${baseline.index}`,
2260
+ text: '(baseline)',
2261
+ start: snap.start,
2262
+ end: snap.end,
2263
+ duration: snap.duration,
2264
+ progress: 0,
2265
+ type: 'task',
2266
+ parent: t.parent,
2267
+ is_baseline_ghost: true
2268
+ };
2269
+ }
2270
+ function toSvarLink(l) {
2271
+ return {
2272
+ id: l.id,
2273
+ source: l.source,
2274
+ target: l.target,
2275
+ type: dependencyTypeToSvar(l.type),
2276
+ lag: l.lag
2277
+ };
2278
+ }
2279
+ function dependencyTypeToSvar(t) {
2280
+ switch(t){
2281
+ case 'FS':
2282
+ return 'e2s';
2283
+ case 'SS':
2284
+ return 's2s';
2285
+ case 'FF':
2286
+ return 'e2e';
2287
+ case 'SF':
2288
+ return 's2e';
2289
+ }
2290
+ }
2291
+ function getProjectEnd(p) {
2292
+ if (p.end) return p.end;
2293
+ let latestMs = Number.NEGATIVE_INFINITY;
2294
+ for (const t of p.tasks){
2295
+ if (t.end.getTime() > latestMs) latestMs = t.end.getTime();
2296
+ }
2297
+ // Pad by one cell so the last bar isn't clipped to the right edge.
2298
+ const cushion = 24 * 60 * 60 * 1000; // 1 day
2299
+ return Number.isFinite(latestMs) ? new Date(latestMs + cushion) : new Date(p.start);
2300
+ }
2301
+ function resolveMarkers(userMarkers, projectStart, projectEnd) {
2302
+ if (userMarkers) return userMarkers.map(toSvarMarker);
2303
+ // Default: today line, only if today falls within the project window.
2304
+ const today = new Date();
2305
+ if (today >= projectStart && today <= projectEnd) {
2306
+ return [
2307
+ {
2308
+ start: today,
2309
+ text: 'Today',
2310
+ css: 'construction-gantt-marker-today'
2311
+ }
2312
+ ];
2313
+ }
2314
+ return [];
2315
+ }
2316
+ function toSvarMarker(m) {
2317
+ const css = m.css ?? (m.variant === 'milestone' ? 'construction-gantt-marker-milestone' : m.variant === 'today' ? 'construction-gantt-marker-today' : undefined);
2318
+ return {
2319
+ start: m.start,
2320
+ text: m.text,
2321
+ css
2322
+ };
2323
+ }
2324
+ /**
2325
+ * Convert a public GanttColumn to SVAR's IColumnConfig.
2326
+ *
2327
+ * render takes priority over field. When only field is set we emit a default
2328
+ * cell that formats the value as a string (Date → ISO date, undefined → "").
2329
+ * We cast row to our Task type directly — the relevant fields (id, text,
2330
+ * start, end, duration, progress, type, parent, computed, constraint)
2331
+ * all overlap. SVAR's internal $x/$y/$w computed fields are never passed
2332
+ * through to the consumer's render prop.
2333
+ */ function toSvarColumn(c) {
2334
+ let cell;
2335
+ if (c.render) {
2336
+ const Render = c.render;
2337
+ cell = (props)=>/*#__PURE__*/ jsx(Render, {
2338
+ task: props.row
2339
+ });
2340
+ } else if (c.field) {
2341
+ const field = c.field;
2342
+ cell = (props)=>{
2343
+ const task = props.row;
2344
+ const value = task[field];
2345
+ if (value === undefined || value === null) return /*#__PURE__*/ jsx("span", {});
2346
+ if (value instanceof Date) return /*#__PURE__*/ jsx("span", {
2347
+ children: value.toISOString().slice(0, 10)
2348
+ });
2349
+ return /*#__PURE__*/ jsx("span", {
2350
+ children: String(value)
2351
+ });
2352
+ };
2353
+ }
2354
+ const config = {
2355
+ id: c.id,
2356
+ header: c.header,
2357
+ ...c.width !== undefined ? {
2358
+ width: c.width
2359
+ } : {},
2360
+ ...c.align !== undefined ? {
2361
+ align: c.align
2362
+ } : {},
2363
+ ...cell !== undefined ? {
2364
+ cell
2365
+ } : {}
2366
+ };
2367
+ return config;
2368
+ }
2369
+ function buildHighlightTime(calendar) {
2370
+ if (!calendar) return undefined;
2371
+ return (date, unit)=>{
2372
+ if (unit !== 'day') return '';
2373
+ if (!isWorkingDay(date, calendar)) return 'construction-gantt-non-working';
2374
+ return '';
2375
+ };
2376
+ }
2377
+ function workingMinutesToShortLabel(minutes) {
2378
+ const abs = Math.abs(minutes);
2379
+ if (abs >= 540) {
2380
+ // Approximate working-days from 9h-per-day. Display is "best-effort"
2381
+ // since real durations depend on each task's calendar — good enough
2382
+ // for an in-bar pill.
2383
+ const days = Math.round(abs / 540);
2384
+ return `${days}d`;
2385
+ }
2386
+ if (abs >= 60) {
2387
+ const hours = Math.round(abs / 60);
2388
+ return `${hours}h`;
2389
+ }
2390
+ return `${Math.round(abs)}m`;
2391
+ }
2392
+
2393
+ // MSPDI XML → internal Project. Hand-mapped, supported-subset only.
2394
+ // Unrecognised MSPDI elements surface in `droppedFields` rather than
2395
+ // being silently discarded.
2396
+ // Field names we know about and either map or intentionally ignore on parse.
2397
+ // Everything outside this set lands in `droppedFields`.
2398
+ //
2399
+ // The list is intentionally broad: real MS Project exports emit ~50 fields
2400
+ // per Task, most of which are MS-Project-computed state (CPM results, EV,
2401
+ // rates, costs, leveling). We don't preserve these on round-trip — our
2402
+ // engine recomputes the equivalent fields. Listing them here keeps
2403
+ // `droppedFields` focused on genuinely-unknown elements rather than
2404
+ // recompute-able noise.
2405
+ const KNOWN_TASK_FIELDS = new Set([
2406
+ // Mapped — read into the internal Task shape
2407
+ 'UID',
2408
+ 'ID',
2409
+ 'Name',
2410
+ 'Start',
2411
+ 'Finish',
2412
+ 'Duration',
2413
+ 'ConstraintType',
2414
+ 'Milestone',
2415
+ 'Summary',
2416
+ 'OutlineLevel',
2417
+ 'PredecessorLink',
2418
+ 'Baseline',
2419
+ // Allowed but ignored (default-bearing structure or recompute-able state)
2420
+ 'Type',
2421
+ 'IsNull',
2422
+ 'CreateDate',
2423
+ 'WBS',
2424
+ 'OutlineNumber',
2425
+ 'Priority',
2426
+ 'PercentComplete',
2427
+ 'PercentWorkComplete',
2428
+ 'PhysicalPercentComplete',
2429
+ 'EarnedValueMethod',
2430
+ 'DurationFormat',
2431
+ 'Work',
2432
+ 'ResumeValid',
2433
+ 'EffortDriven',
2434
+ 'Recurring',
2435
+ 'OverAllocated',
2436
+ 'Estimated',
2437
+ 'Critical',
2438
+ 'IsSubproject',
2439
+ 'IsSubprojectReadOnly',
2440
+ 'ExternalTask',
2441
+ // CPM results — recomputed by our engine
2442
+ 'EarlyStart',
2443
+ 'EarlyFinish',
2444
+ 'LateStart',
2445
+ 'LateFinish',
2446
+ 'StartVariance',
2447
+ 'FinishVariance',
2448
+ 'WorkVariance',
2449
+ 'FreeSlack',
2450
+ 'TotalSlack',
2451
+ // Cost + work tracking — outside our v0.2 scope
2452
+ 'FixedCost',
2453
+ 'FixedCostAccrual',
2454
+ 'Cost',
2455
+ 'OvertimeCost',
2456
+ 'OvertimeWork',
2457
+ 'ActualDuration',
2458
+ 'ActualCost',
2459
+ 'ActualOvertimeCost',
2460
+ 'ActualWork',
2461
+ 'ActualOvertimeWork',
2462
+ 'RegularWork',
2463
+ 'RemainingDuration',
2464
+ 'RemainingCost',
2465
+ 'RemainingWork',
2466
+ 'RemainingOvertimeCost',
2467
+ 'RemainingOvertimeWork',
2468
+ 'ACWP',
2469
+ 'CV',
2470
+ 'BCWS',
2471
+ 'BCWP',
2472
+ // Calendar override per task + leveling — not in our v0.2 scope
2473
+ 'CalendarUID',
2474
+ 'LevelAssignments',
2475
+ 'LevelingCanSplit',
2476
+ 'LevelingDelay',
2477
+ 'LevelingDelayFormat',
2478
+ 'IgnoreResourceCalendar',
2479
+ 'HideBar',
2480
+ 'Rollup',
2481
+ // Server/publishing — meaningless outside MS Project Server context
2482
+ 'IsPublished',
2483
+ 'CommitmentType'
2484
+ ]);
2485
+ const KNOWN_PROJECT_FIELDS = new Set([
2486
+ 'Name',
2487
+ 'Title',
2488
+ 'Author',
2489
+ 'StartDate',
2490
+ 'Tasks',
2491
+ // ignored without dropping for v0.2 first cut (will be supported in
2492
+ // future commits — listed here so we don't noise up droppedFields)
2493
+ 'Calendars',
2494
+ 'Resources',
2495
+ 'Assignments',
2496
+ 'WBSMasks',
2497
+ 'OutlineCodes',
2498
+ 'ExtendedAttributes',
2499
+ // pure metadata that consumers should preserve via meta-roundtrip but
2500
+ // doesn't enter our Project shape
2501
+ 'Manager',
2502
+ 'Company',
2503
+ 'Subject',
2504
+ 'Category',
2505
+ 'Keywords',
2506
+ 'Comments',
2507
+ 'CreationDate',
2508
+ 'LastSaved',
2509
+ 'FinishDate',
2510
+ 'CurrencyCode',
2511
+ 'ScheduleFromStart',
2512
+ 'FYStartDate',
2513
+ 'CriticalSlackLimit',
2514
+ 'CurrencyDigits',
2515
+ 'CurrencySymbol',
2516
+ 'CurrencySymbolPosition',
2517
+ 'CalendarUID',
2518
+ 'DefaultStartTime',
2519
+ 'DefaultFinishTime',
2520
+ 'MinutesPerDay',
2521
+ 'MinutesPerWeek',
2522
+ 'DaysPerMonth',
2523
+ 'DefaultTaskType',
2524
+ 'DefaultFixedCostAccrual',
2525
+ 'DefaultStandardRate',
2526
+ 'DefaultOvertimeRate',
2527
+ 'DurationFormat',
2528
+ 'WorkFormat',
2529
+ 'EditableActualCosts',
2530
+ 'HonorConstraints',
2531
+ 'EarnedValueMethod',
2532
+ 'InsertedProjectsLikeSummary',
2533
+ 'MultipleCriticalPaths',
2534
+ 'NewTasksEffortDriven',
2535
+ 'NewTasksEstimated',
2536
+ 'SplitsInProgressTasks',
2537
+ 'SpreadActualCost',
2538
+ 'SpreadPercentComplete',
2539
+ 'TaskUpdatesResource',
2540
+ 'FiscalYearStart',
2541
+ 'WeekStartDay',
2542
+ 'MoveCompletedEndsBack',
2543
+ 'MoveRemainingStartsBack',
2544
+ 'MoveRemainingStartsForward',
2545
+ 'MoveCompletedEndsForward',
2546
+ 'BaselineForEarnedValue',
2547
+ 'AutoAddNewResourcesAndTasks',
2548
+ 'StatusDate',
2549
+ 'CurrentDate',
2550
+ 'MicrosoftProjectServerURL',
2551
+ 'Autolink',
2552
+ 'NewTaskStartDate',
2553
+ 'DefaultTaskEVMethod',
2554
+ 'ProjectExternallyEdited',
2555
+ 'ExtendedCreationDate',
2556
+ 'ActualsInSync',
2557
+ 'AdminProject',
2558
+ 'RemoveFileProperties',
2559
+ 'SaveVersion',
2560
+ 'UID'
2561
+ ]);
2562
+ const KNOWN_PREDECESSOR_FIELDS = new Set([
2563
+ 'PredecessorUID',
2564
+ 'Type',
2565
+ 'LinkLag',
2566
+ 'CrossProject',
2567
+ 'CrossProjectName',
2568
+ // LagFormat is a magic number (7=minutes, 5=hours, 39=days) describing
2569
+ // how the consumer should *display* LinkLag — we always normalize to
2570
+ // minutes internally, so it's allowed-but-ignored.
2571
+ 'LagFormat'
2572
+ ]);
2573
+ function parseMspdi(xml) {
2574
+ const parser = new XMLParser({
2575
+ ignoreAttributes: true,
2576
+ isArray: (_name, jpath)=>jpath === 'Project.Tasks.Task' || jpath.endsWith('.PredecessorLink') || jpath === 'Project.Calendars.Calendar' || jpath === 'Project.Resources.Resource' || jpath === 'Project.Assignments.Assignment' || jpath.endsWith('.WeekDays.WeekDay') || jpath.endsWith('.WorkingTimes.WorkingTime') || jpath === 'Project.Tasks.Task.Baseline',
2577
+ parseTagValue: false,
2578
+ trimValues: true
2579
+ });
2580
+ const doc = parser.parse(xml);
2581
+ const root = doc.Project;
2582
+ if (!root) {
2583
+ throw new Error('parseMspdi: <Project> root element missing');
2584
+ }
2585
+ const droppedFields = [];
2586
+ // Scan the project-level fields we don't know about.
2587
+ for (const [key, value] of Object.entries(root)){
2588
+ if (KNOWN_PROJECT_FIELDS.has(key)) continue;
2589
+ droppedFields.push({
2590
+ path: `Project.${key}`,
2591
+ value: stringifyForDiag(value),
2592
+ reason: 'unsupported-element'
2593
+ });
2594
+ }
2595
+ const tasks = [];
2596
+ const links = [];
2597
+ // Per-task baseline snapshots, keyed by baseline Number (BaselineIndex).
2598
+ // Flattened into project.baselines below.
2599
+ const baselineAccum = new Map();
2600
+ const rawTasks = root.Tasks?.Task ?? [];
2601
+ if (!Array.isArray(rawTasks)) {
2602
+ throw new Error('parseMspdi: <Tasks> contained a non-array Task collection (malformed)');
2603
+ }
2604
+ for(let i = 0; i < rawTasks.length; i++){
2605
+ const raw = rawTasks[i];
2606
+ const taskPath = `Project.Tasks.Task[${i}]`;
2607
+ // Map the supported fields.
2608
+ const uid = String(raw.UID ?? raw.ID ?? '');
2609
+ if (!uid) throw new Error(`parseMspdi: ${taskPath} missing UID and ID`);
2610
+ const name = String(raw.Name ?? '');
2611
+ const start = parseMspdiDate(String(raw.Start ?? ''));
2612
+ const end = parseMspdiDate(String(raw.Finish ?? ''));
2613
+ const duration = parseMspdiDuration(String(raw.Duration ?? 'PT0H0M0S'));
2614
+ const isMilestone = String(raw.Milestone ?? '0') === '1';
2615
+ const isSummary = String(raw.Summary ?? '0') === '1';
2616
+ const taskType = isSummary ? 'summary' : isMilestone ? 'milestone' : 'task';
2617
+ tasks.push({
2618
+ id: uid,
2619
+ text: name,
2620
+ type: taskType,
2621
+ scheduleMode: 'auto',
2622
+ duration,
2623
+ start,
2624
+ end,
2625
+ progress: 0
2626
+ });
2627
+ // Walk predecessor links nested inside the task.
2628
+ const preds = Array.isArray(raw.PredecessorLink) ? raw.PredecessorLink : raw.PredecessorLink !== undefined ? [
2629
+ raw.PredecessorLink
2630
+ ] : [];
2631
+ for(let p = 0; p < preds.length; p++){
2632
+ const link = preds[p];
2633
+ const srcUid = String(link.PredecessorUID ?? '');
2634
+ if (!srcUid) continue;
2635
+ const mspdiType = Number(link.Type ?? '1');
2636
+ const linkType = mspdiTypeToDependencyType(mspdiType);
2637
+ const lagTenthsOfMinute = Number(link.LinkLag ?? '0');
2638
+ const lagMinutes = Math.round(lagTenthsOfMinute / 10);
2639
+ links.push({
2640
+ id: `${srcUid}-${uid}-${p}`,
2641
+ source: srcUid,
2642
+ target: uid,
2643
+ type: linkType,
2644
+ lag: lagMinutes
2645
+ });
2646
+ // Capture any unknown fields on the predecessor link.
2647
+ for (const k of Object.keys(link)){
2648
+ if (KNOWN_PREDECESSOR_FIELDS.has(k)) continue;
2649
+ droppedFields.push({
2650
+ path: `${taskPath}.PredecessorLink[${p}].${k}`,
2651
+ value: stringifyForDiag(link[k]),
2652
+ reason: 'unsupported-element'
2653
+ });
2654
+ }
2655
+ }
2656
+ // Walk per-task <Baseline> children and accumulate into baselineAccum.
2657
+ // MSPDI Baseline lives task-level (each task has up to 11 Baseline
2658
+ // children with Number=0..10); our internal Baseline is project-level
2659
+ // with a Map<TaskId, snapshot>. Pivot during parse.
2660
+ const taskBaselines = Array.isArray(raw.Baseline) ? raw.Baseline : raw.Baseline !== undefined ? [
2661
+ raw.Baseline
2662
+ ] : [];
2663
+ for(let b = 0; b < taskBaselines.length; b++){
2664
+ const baselineRaw = taskBaselines[b];
2665
+ const numberStr = String(baselineRaw.Number ?? '');
2666
+ const number = Number(numberStr);
2667
+ if (!Number.isFinite(number) || number < 0 || number > 10) {
2668
+ droppedFields.push({
2669
+ path: `${taskPath}.Baseline[${b}].Number`,
2670
+ value: numberStr,
2671
+ reason: 'unsupported-element'
2672
+ });
2673
+ continue;
2674
+ }
2675
+ const index = number;
2676
+ const snap = {
2677
+ start: parseMspdiDate(String(baselineRaw.Start ?? '')),
2678
+ end: parseMspdiDate(String(baselineRaw.Finish ?? '')),
2679
+ duration: parseMspdiDuration(String(baselineRaw.Duration ?? 'PT0H0M0S'))
2680
+ };
2681
+ let acc = baselineAccum.get(index);
2682
+ if (!acc) {
2683
+ acc = new Map();
2684
+ baselineAccum.set(index, acc);
2685
+ }
2686
+ acc.set(uid, snap);
2687
+ }
2688
+ // Scan the task-level fields we don't know about.
2689
+ for (const [key, value] of Object.entries(raw)){
2690
+ if (KNOWN_TASK_FIELDS.has(key)) continue;
2691
+ droppedFields.push({
2692
+ path: `${taskPath}.${key}`,
2693
+ value: stringifyForDiag(value),
2694
+ reason: 'unsupported-element'
2695
+ });
2696
+ }
2697
+ }
2698
+ const projectStart = root.StartDate ? parseMspdiDate(String(root.StartDate)) : tasks[0]?.start ?? new Date();
2699
+ // Calendars. MSPDI optionally nests <Calendars><Calendar>+. Each Calendar's
2700
+ // <WeekDays> contains DayType 1-7 entries (the recurring pattern) and
2701
+ // DayType=0 entries with TimePeriod (exceptions). See toMspdiCalendar in
2702
+ // serialize.ts for the inverse mapping.
2703
+ const calendars = [];
2704
+ const rawCalendars = root.Calendars?.Calendar ?? [];
2705
+ if (Array.isArray(rawCalendars)) {
2706
+ for(let i = 0; i < rawCalendars.length; i++){
2707
+ const raw = rawCalendars[i];
2708
+ calendars.push(parseMspdiCalendar(raw, `Project.Calendars.Calendar[${i}]`, droppedFields));
2709
+ }
2710
+ }
2711
+ // Resources. v0.2 first cut maps only UID + Name + CalendarUID; rates,
2712
+ // types, units, cost, and other MS Project resource fields are
2713
+ // intentionally ignored without dropping (we don't yet model them).
2714
+ const resources = [];
2715
+ const rawResources = root.Resources?.Resource ?? [];
2716
+ if (Array.isArray(rawResources)) {
2717
+ for(let i = 0; i < rawResources.length; i++){
2718
+ const raw = rawResources[i];
2719
+ resources.push(parseMspdiResource(raw, `Project.Resources.Resource[${i}]`, droppedFields));
2720
+ }
2721
+ }
2722
+ // Assignments. v0.2 first cut maps only UID + TaskUID + ResourceUID + Units
2723
+ // (the resource-to-task allocation triple). Per-day timephased data, cost
2724
+ // tracking, and EV fields don't enter our model — they appear in
2725
+ // droppedFields if present.
2726
+ const assignments = [];
2727
+ const rawAssignments = root.Assignments?.Assignment ?? [];
2728
+ if (Array.isArray(rawAssignments)) {
2729
+ for(let i = 0; i < rawAssignments.length; i++){
2730
+ const raw = rawAssignments[i];
2731
+ assignments.push(parseMspdiAssignment(raw, `Project.Assignments.Assignment[${i}]`, droppedFields));
2732
+ }
2733
+ }
2734
+ // Pick a sensible defaultCalendarId. Prefer the first calendar with
2735
+ // `IsBaseCalendar` 1; fall back to the first calendar; fall back to 'std'.
2736
+ let defaultCalendarId = 'std';
2737
+ const firstCalendar = calendars[0];
2738
+ if (firstCalendar) {
2739
+ const firstBase = calendars.find((c)=>c.baseCalendarId === undefined);
2740
+ defaultCalendarId = String((firstBase ?? firstCalendar).id);
2741
+ }
2742
+ // Flatten the per-task baseline accumulator into our project-level
2743
+ // Baseline[] shape. Sort by index for stable order.
2744
+ const baselines = [];
2745
+ for (const [index, taskMap] of baselineAccum){
2746
+ baselines.push({
2747
+ index,
2748
+ // MSPDI doesn't carry baseline name or capturedAt on individual snapshots;
2749
+ // synthesize defaults. Consumers who want named baselines can populate
2750
+ // these after parse.
2751
+ capturedAt: new Date(0),
2752
+ tasks: taskMap
2753
+ });
2754
+ }
2755
+ baselines.sort((a, b)=>a.index - b.index);
2756
+ const project = {
2757
+ start: projectStart,
2758
+ defaultCalendarId,
2759
+ tasks,
2760
+ links,
2761
+ resources,
2762
+ calendars,
2763
+ baselines,
2764
+ assignments
2765
+ };
2766
+ return {
2767
+ project,
2768
+ droppedFields
2769
+ };
2770
+ }
2771
+ const KNOWN_CALENDAR_FIELDS = new Set([
2772
+ 'UID',
2773
+ 'Name',
2774
+ 'IsBaseCalendar',
2775
+ 'BaseCalendarUID',
2776
+ 'WeekDays'
2777
+ ]);
2778
+ const KNOWN_WEEKDAY_FIELDS = new Set([
2779
+ 'DayType',
2780
+ 'DayWorking',
2781
+ 'WorkingTimes',
2782
+ 'TimePeriod'
2783
+ ]);
2784
+ // v0.2 first cut: only UID + Name + CalendarUID enter our Resource shape.
2785
+ // Other fields exist in real MS Project exports — listed here as
2786
+ // allowed-but-ignored so they don't noise up droppedFields.
2787
+ const KNOWN_RESOURCE_FIELDS = new Set([
2788
+ // Mapped
2789
+ 'UID',
2790
+ 'ID',
2791
+ 'Name',
2792
+ 'CalendarUID',
2793
+ // Allowed but ignored (no internal model yet)
2794
+ 'IsNull',
2795
+ 'Initials',
2796
+ 'Group',
2797
+ 'Code',
2798
+ 'EmailAddress',
2799
+ 'WindowsUserAccount',
2800
+ 'Type',
2801
+ 'IsGeneric',
2802
+ 'IsInactive',
2803
+ 'IsEnterprise',
2804
+ 'BookingType',
2805
+ 'MaterialLabel',
2806
+ 'AccrueAt',
2807
+ 'MaxUnits',
2808
+ 'PeakUnits',
2809
+ 'OverAllocated',
2810
+ 'AvailableFrom',
2811
+ 'AvailableTo',
2812
+ 'StandardRate',
2813
+ 'StandardRateFormat',
2814
+ 'OvertimeRate',
2815
+ 'OvertimeRateFormat',
2816
+ 'CostPerUse',
2817
+ 'Cost',
2818
+ 'CostVariance',
2819
+ 'OvertimeCost',
2820
+ 'ActualCost',
2821
+ 'ActualOvertimeCost',
2822
+ 'RemainingCost',
2823
+ 'RemainingOvertimeCost',
2824
+ 'CostCenter',
2825
+ 'BudgetCost',
2826
+ 'BaselineCost',
2827
+ 'Work',
2828
+ 'RegularWork',
2829
+ 'OvertimeWork',
2830
+ 'ActualWork',
2831
+ 'RemainingWork',
2832
+ 'ActualOvertimeWork',
2833
+ 'RemainingOvertimeWork',
2834
+ 'PercentWorkComplete',
2835
+ 'WorkVariance',
2836
+ 'StartVariance',
2837
+ 'FinishVariance',
2838
+ 'BudgetWork',
2839
+ 'BaselineWork',
2840
+ 'ACWP',
2841
+ 'CV',
2842
+ 'BCWS',
2843
+ 'BCWP',
2844
+ 'Start',
2845
+ 'Finish',
2846
+ 'CanLevel',
2847
+ 'NotesText',
2848
+ 'NotesRTF',
2849
+ 'CreationDate',
2850
+ 'Hyperlink',
2851
+ 'HyperlinkAddress',
2852
+ 'HyperlinkSubAddress',
2853
+ 'PhoneticAlias',
2854
+ 'ExtendedAttribute',
2855
+ 'Baseline',
2856
+ 'OutlineCode',
2857
+ 'TimephasedData'
2858
+ ]);
2859
+ // v0.2 first cut: only UID + TaskUID + ResourceUID + Units enter our
2860
+ // Assignment shape. Per-day timephased data, cost tracking, EV fields,
2861
+ // and confirmed/leveled times are all allowed-but-ignored.
2862
+ const KNOWN_ASSIGNMENT_FIELDS = new Set([
2863
+ // Mapped
2864
+ 'UID',
2865
+ 'TaskUID',
2866
+ 'ResourceUID',
2867
+ 'Units',
2868
+ // Allowed but ignored (no internal model yet)
2869
+ 'PercentWorkComplete',
2870
+ 'ActualCost',
2871
+ 'ActualWork',
2872
+ 'Cost',
2873
+ 'CostVariance',
2874
+ 'Work',
2875
+ 'WorkVariance',
2876
+ 'StartVariance',
2877
+ 'FinishVariance',
2878
+ 'OvertimeCost',
2879
+ 'OvertimeWork',
2880
+ 'ActualOvertimeCost',
2881
+ 'ActualOvertimeWork',
2882
+ 'RegularWork',
2883
+ 'RemainingCost',
2884
+ 'RemainingWork',
2885
+ 'RemainingOvertimeCost',
2886
+ 'RemainingOvertimeWork',
2887
+ 'ConfirmedFinish',
2888
+ 'ConfirmedStart',
2889
+ 'Start',
2890
+ 'Finish',
2891
+ 'Stop',
2892
+ 'Resume',
2893
+ 'ResumeValid',
2894
+ 'LevelingDelay',
2895
+ 'LevelingDelayFormat',
2896
+ 'Delay',
2897
+ 'NotesText',
2898
+ 'NotesRTF',
2899
+ 'Hyperlink',
2900
+ 'HyperlinkAddress',
2901
+ 'HyperlinkSubAddress',
2902
+ 'CostRateTable',
2903
+ 'BookingType',
2904
+ 'ActualStart',
2905
+ 'ActualFinish',
2906
+ 'WorkContour',
2907
+ 'BudgetCost',
2908
+ 'BudgetWork',
2909
+ 'BaselineCost',
2910
+ 'BaselineWork',
2911
+ 'BaselineStart',
2912
+ 'BaselineFinish',
2913
+ 'BaselineBudgetCost',
2914
+ 'BaselineBudgetWork',
2915
+ 'ACWP',
2916
+ 'CV',
2917
+ 'BCWS',
2918
+ 'BCWP',
2919
+ 'Baseline',
2920
+ 'ExtendedAttribute',
2921
+ 'TimephasedData',
2922
+ 'CreationDate'
2923
+ ]);
2924
+ function parseMspdiCalendar(raw, path, droppedFields) {
2925
+ const id = String(raw.UID ?? raw.Name ?? 'std');
2926
+ const name = String(raw.Name ?? id);
2927
+ const workWeek = [
2928
+ [],
2929
+ [],
2930
+ [],
2931
+ [],
2932
+ [],
2933
+ [],
2934
+ []
2935
+ ];
2936
+ const exceptions = [];
2937
+ const weekDays = raw.WeekDays?.WeekDay ?? [];
2938
+ if (Array.isArray(weekDays)) {
2939
+ for(let i = 0; i < weekDays.length; i++){
2940
+ const wd = weekDays[i];
2941
+ const wdPath = `${path}.WeekDays.WeekDay[${i}]`;
2942
+ const dayType = Number(wd.DayType ?? '0');
2943
+ const dayWorking = String(wd.DayWorking ?? '0') === '1';
2944
+ if (dayType >= 1 && dayType <= 7) {
2945
+ // Recurring weekday — DayType 1=Sun ... 7=Sat → DayOfWeek 0=Sun ... 6=Sat
2946
+ const dayOfWeek = dayType - 1;
2947
+ workWeek[dayOfWeek] = dayWorking ? parseWorkingTimes(wd) : [];
2948
+ } else if (dayType === 0) {
2949
+ // Exception entry
2950
+ const timePeriod = wd.TimePeriod;
2951
+ if (!timePeriod) continue;
2952
+ const fromDate = parseMspdiDate(String(timePeriod.FromDate ?? ''));
2953
+ // Treat as single-day exception, anchored on FromDate's local-day boundary.
2954
+ const anchored = new Date(fromDate.getFullYear(), fromDate.getMonth(), fromDate.getDate());
2955
+ const ex = {
2956
+ date: anchored,
2957
+ isWorking: dayWorking
2958
+ };
2959
+ if (dayWorking) {
2960
+ const intervals = parseWorkingTimes(wd);
2961
+ if (intervals.length) ex.intervals = intervals;
2962
+ }
2963
+ exceptions.push(ex);
2964
+ }
2965
+ // Scan unknown WeekDay fields
2966
+ for (const k of Object.keys(wd)){
2967
+ if (KNOWN_WEEKDAY_FIELDS.has(k)) continue;
2968
+ droppedFields.push({
2969
+ path: `${wdPath}.${k}`,
2970
+ value: stringifyForDiag(wd[k]),
2971
+ reason: 'unsupported-element'
2972
+ });
2973
+ }
2974
+ }
2975
+ }
2976
+ // Scan unknown Calendar fields
2977
+ for (const k of Object.keys(raw)){
2978
+ if (KNOWN_CALENDAR_FIELDS.has(k)) continue;
2979
+ droppedFields.push({
2980
+ path: `${path}.${k}`,
2981
+ value: stringifyForDiag(raw[k]),
2982
+ reason: 'unsupported-element'
2983
+ });
2984
+ }
2985
+ const calendar = {
2986
+ id,
2987
+ name,
2988
+ workWeek,
2989
+ exceptions
2990
+ };
2991
+ // BaseCalendarUID — MS Project uses -1 for "no base". Treat -1 or absent
2992
+ // as top-level; otherwise carry through.
2993
+ const baseUid = raw.BaseCalendarUID !== undefined ? String(raw.BaseCalendarUID) : undefined;
2994
+ if (baseUid !== undefined && baseUid !== '-1') {
2995
+ calendar.baseCalendarId = baseUid;
2996
+ }
2997
+ return calendar;
2998
+ }
2999
+ function parseWorkingTimes(wd) {
3000
+ const wt = wd.WorkingTimes;
3001
+ if (!wt) return [];
3002
+ const arr = wt.WorkingTime;
3003
+ if (!Array.isArray(arr)) return [];
3004
+ const intervals = [];
3005
+ for (const w of arr){
3006
+ const wRec = w;
3007
+ const fromTime = String(wRec.FromTime ?? '');
3008
+ const toTime = String(wRec.ToTime ?? '');
3009
+ intervals.push({
3010
+ startMinutes: parseMspdiTime(fromTime),
3011
+ endMinutes: parseMspdiTime(toTime)
3012
+ });
3013
+ }
3014
+ return intervals;
3015
+ }
3016
+ function parseMspdiTime(s) {
3017
+ // `HH:MM:SS` → minutes-from-midnight. Seconds truncated.
3018
+ const match = s.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
3019
+ if (!match) return 0;
3020
+ return Number(match[1]) * 60 + Number(match[2]);
3021
+ }
3022
+ const MSPDI_TYPE_TO_DEPENDENCY = {
3023
+ 0: 'FF',
3024
+ 1: 'FS',
3025
+ 2: 'SF',
3026
+ 3: 'SS'
3027
+ };
3028
+ function mspdiTypeToDependencyType(t) {
3029
+ return MSPDI_TYPE_TO_DEPENDENCY[t] ?? 'FS';
3030
+ }
3031
+ function parseMspdiDate(s) {
3032
+ // MSPDI emits ISO 8601 like `2026-01-05T08:00:00` (no timezone in
3033
+ // practice; MS Project writes local time). We treat it as local.
3034
+ return new Date(s);
3035
+ }
3036
+ function parseMspdiDuration(s) {
3037
+ // MSPDI duration format: `PT{H}H{M}M{S}S` where each component is
3038
+ // optional (e.g. `PT24H0M0S`, `PT0H30M0S`). Returns total minutes
3039
+ // (seconds truncated).
3040
+ const match = s.match(/^PT(\d+)H(\d+)M(\d+)S$/);
3041
+ if (match) {
3042
+ return Number(match[1]) * 60 + Number(match[2]);
3043
+ }
3044
+ // Looser fallback — support `PT{N}M` shorthand.
3045
+ const looseMin = s.match(/^PT(\d+)M$/);
3046
+ if (looseMin) return Number(looseMin[1]);
3047
+ const looseHr = s.match(/^PT(\d+)H$/);
3048
+ if (looseHr) return Number(looseHr[1]) * 60;
3049
+ return 0;
3050
+ }
3051
+ function stringifyForDiag(v) {
3052
+ if (v === null || v === undefined) return '';
3053
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
3054
+ try {
3055
+ return JSON.stringify(v).slice(0, 200);
3056
+ } catch {
3057
+ return '<unstringifiable>';
3058
+ }
3059
+ }
3060
+ // ---------------------------------------------------------------------------
3061
+ // Resources
3062
+ // ---------------------------------------------------------------------------
3063
+ function parseMspdiResource(raw, path, droppedFields) {
3064
+ const id = String(raw.UID ?? raw.ID ?? '');
3065
+ const name = String(raw.Name ?? '');
3066
+ const resource = {
3067
+ id,
3068
+ name
3069
+ };
3070
+ // CalendarUID — MS Project uses -1 for "no override". Treat -1, absent,
3071
+ // or empty as "use project default"; otherwise carry through.
3072
+ const calUid = raw.CalendarUID !== undefined ? String(raw.CalendarUID) : undefined;
3073
+ if (calUid !== undefined && calUid !== '-1' && calUid !== '') {
3074
+ resource.calendarId = calUid;
3075
+ }
3076
+ // Scan unknown Resource fields
3077
+ for (const k of Object.keys(raw)){
3078
+ if (KNOWN_RESOURCE_FIELDS.has(k)) continue;
3079
+ droppedFields.push({
3080
+ path: `${path}.${k}`,
3081
+ value: stringifyForDiag(raw[k]),
3082
+ reason: 'unsupported-element'
3083
+ });
3084
+ }
3085
+ return resource;
3086
+ }
3087
+ // ---------------------------------------------------------------------------
3088
+ // Assignments
3089
+ // ---------------------------------------------------------------------------
3090
+ function parseMspdiAssignment(raw, path, droppedFields) {
3091
+ const id = String(raw.UID ?? '');
3092
+ const taskId = String(raw.TaskUID ?? '');
3093
+ const resourceId = String(raw.ResourceUID ?? '');
3094
+ const assignment = {
3095
+ id,
3096
+ taskId,
3097
+ resourceId
3098
+ };
3099
+ // Units default 1.0. MSPDI stores as a decimal string. Treat absent/empty
3100
+ // or unparseable as 1.0; only emit if it differs from the default.
3101
+ if (raw.Units !== undefined) {
3102
+ const units = Number(raw.Units);
3103
+ if (Number.isFinite(units) && units !== 1) {
3104
+ assignment.units = units;
3105
+ }
3106
+ }
3107
+ // Scan unknown Assignment fields. TimephasedData specifically is large +
3108
+ // commonly present in real MS Project exports — listing it as
3109
+ // allowed-but-ignored is the honest position until we add a per-day
3110
+ // allocation model (v0.4+).
3111
+ for (const k of Object.keys(raw)){
3112
+ if (KNOWN_ASSIGNMENT_FIELDS.has(k)) continue;
3113
+ droppedFields.push({
3114
+ path: `${path}.${k}`,
3115
+ value: stringifyForDiag(raw[k]),
3116
+ reason: 'unsupported-element'
3117
+ });
3118
+ }
3119
+ return assignment;
3120
+ }
3121
+
3122
+ // Internal Project → MSPDI XML. Hand-mapped, supported-subset only.
3123
+ // Inverse of `parseMspdi` for the fields enumerated in this module's
3124
+ // KNOWN_TASK_FIELDS + KNOWN_PROJECT_FIELDS sets.
3125
+ function serializeMspdi(project, options = {}) {
3126
+ const meta = options.meta ?? {};
3127
+ const name = meta.name ?? 'Untitled';
3128
+ const title = meta.title ?? name;
3129
+ // Group links by target so we can nest PredecessorLink elements inside Task.
3130
+ const linksByTarget = new Map();
3131
+ for (const link of project.links){
3132
+ const key = String(link.target);
3133
+ const arr = linksByTarget.get(key) ?? [];
3134
+ arr.push(link);
3135
+ linksByTarget.set(key, arr);
3136
+ }
3137
+ // Group baselines by taskId. Each task may have one snapshot per baseline
3138
+ // (0-10), emitted as <Baseline> children nested inside the task.
3139
+ const baselinesByTask = buildBaselinesByTask(project.baselines);
3140
+ const tasksOut = project.tasks.map((t, idx)=>{
3141
+ const taskOut = {
3142
+ UID: String(t.id),
3143
+ ID: String(idx + 1),
3144
+ Name: t.text,
3145
+ Start: formatMspdiDate(t.start),
3146
+ Finish: formatMspdiDate(t.end),
3147
+ Duration: formatMspdiDuration(t.duration),
3148
+ ConstraintType: 0,
3149
+ Milestone: t.type === 'milestone' ? 1 : 0,
3150
+ Summary: t.type === 'summary' ? 1 : 0,
3151
+ OutlineLevel: 1
3152
+ };
3153
+ const incoming = linksByTarget.get(String(t.id));
3154
+ if (incoming?.length) {
3155
+ taskOut.PredecessorLink = incoming.map(toMspdiLink);
3156
+ }
3157
+ const taskBaselines = baselinesByTask.get(String(t.id));
3158
+ if (taskBaselines?.length) {
3159
+ taskOut.Baseline = taskBaselines;
3160
+ }
3161
+ return taskOut;
3162
+ });
3163
+ const calendarsOut = project.calendars.map(toMspdiCalendar);
3164
+ const resourcesOut = project.resources.map(toMspdiResource);
3165
+ const assignmentsOut = project.assignments.map(toMspdiAssignment);
3166
+ const projectRoot = {
3167
+ Name: name,
3168
+ Title: title,
3169
+ ...meta.author !== undefined ? {
3170
+ Author: meta.author
3171
+ } : {},
3172
+ StartDate: formatMspdiDate(project.start),
3173
+ ...calendarsOut.length > 0 ? {
3174
+ Calendars: {
3175
+ Calendar: calendarsOut
3176
+ }
3177
+ } : {},
3178
+ ...resourcesOut.length > 0 ? {
3179
+ Resources: {
3180
+ Resource: resourcesOut
3181
+ }
3182
+ } : {},
3183
+ Tasks: {
3184
+ Task: tasksOut
3185
+ },
3186
+ ...assignmentsOut.length > 0 ? {
3187
+ Assignments: {
3188
+ Assignment: assignmentsOut
3189
+ }
3190
+ } : {}
3191
+ };
3192
+ const builder = new XMLBuilder({
3193
+ ignoreAttributes: true,
3194
+ format: true,
3195
+ indentBy: ' ',
3196
+ suppressEmptyNode: true,
3197
+ suppressBooleanAttributes: true,
3198
+ processEntities: true
3199
+ });
3200
+ const inner = builder.build({
3201
+ Project: projectRoot
3202
+ });
3203
+ // fast-xml-parser doesn't emit a namespace on the root, so we inject
3204
+ // the standard MSPDI namespace declaration. Trim leading whitespace
3205
+ // first so the regex anchor is reliable.
3206
+ const trimmed = inner.replace(/^\s+/, '');
3207
+ const withNamespace = trimmed.replace(/^<Project>/, '<Project xmlns="http://schemas.microsoft.com/project">');
3208
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${withNamespace}`;
3209
+ }
3210
+ function toMspdiLink(link) {
3211
+ return {
3212
+ PredecessorUID: String(link.source),
3213
+ Type: dependencyTypeToMspdi(link.type),
3214
+ LinkLag: (link.lag ?? 0) * 10
3215
+ };
3216
+ }
3217
+ function dependencyTypeToMspdi(t) {
3218
+ switch(t){
3219
+ case 'FF':
3220
+ return 0;
3221
+ case 'FS':
3222
+ return 1;
3223
+ case 'SF':
3224
+ return 2;
3225
+ case 'SS':
3226
+ return 3;
3227
+ }
3228
+ }
3229
+ function formatMspdiDate(d) {
3230
+ // MSPDI emits local time without timezone (e.g. `2026-01-05T08:00:00`).
3231
+ const pad = (n)=>String(n).padStart(2, '0');
3232
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
3233
+ }
3234
+ function formatMspdiDuration(totalMinutes) {
3235
+ const hours = Math.floor(totalMinutes / 60);
3236
+ const minutes = totalMinutes % 60;
3237
+ return `PT${hours}H${minutes}M0S`;
3238
+ }
3239
+ // ---------------------------------------------------------------------------
3240
+ // Calendars
3241
+ // ---------------------------------------------------------------------------
3242
+ function toMspdiCalendar(cal) {
3243
+ const weekDays = [];
3244
+ // 7 recurring entries — MSPDI DayType 1=Sun … 7=Sat; our DayOfWeek 0=Sun … 6=Sat
3245
+ for(let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++){
3246
+ const intervals = cal.workWeek[dayOfWeek] ?? [];
3247
+ weekDays.push(toRecurringWeekDay(dayOfWeek + 1, intervals));
3248
+ }
3249
+ // Exception entries — DayType=0 with TimePeriod
3250
+ for (const ex of cal.exceptions){
3251
+ weekDays.push(toExceptionWeekDay(ex));
3252
+ }
3253
+ return {
3254
+ UID: String(cal.id),
3255
+ Name: cal.name,
3256
+ IsBaseCalendar: cal.baseCalendarId === undefined ? 1 : 0,
3257
+ WeekDays: {
3258
+ WeekDay: weekDays
3259
+ }
3260
+ };
3261
+ }
3262
+ function toRecurringWeekDay(mspdiDayType, intervals) {
3263
+ const working = intervals.length > 0;
3264
+ const out = {
3265
+ DayType: mspdiDayType,
3266
+ DayWorking: working ? 1 : 0
3267
+ };
3268
+ if (working) {
3269
+ out.WorkingTimes = {
3270
+ WorkingTime: intervals.map(toMspdiWorkingTime)
3271
+ };
3272
+ }
3273
+ return out;
3274
+ }
3275
+ function toExceptionWeekDay(ex) {
3276
+ const dayStart = startOfDay(ex.date);
3277
+ const dayEnd = endOfDay(ex.date);
3278
+ const out = {
3279
+ DayType: 0,
3280
+ DayWorking: ex.isWorking ? 1 : 0,
3281
+ TimePeriod: {
3282
+ FromDate: formatMspdiDate(dayStart),
3283
+ ToDate: formatMspdiDate(dayEnd)
3284
+ }
3285
+ };
3286
+ if (ex.isWorking && ex.intervals?.length) {
3287
+ out.WorkingTimes = {
3288
+ WorkingTime: ex.intervals.map(toMspdiWorkingTime)
3289
+ };
3290
+ }
3291
+ return out;
3292
+ }
3293
+ function toMspdiWorkingTime(w) {
3294
+ return {
3295
+ FromTime: formatMspdiTime(w.startMinutes),
3296
+ ToTime: formatMspdiTime(w.endMinutes)
3297
+ };
3298
+ }
3299
+ function formatMspdiTime(minutesFromMidnight) {
3300
+ const h = Math.floor(minutesFromMidnight / 60);
3301
+ const m = minutesFromMidnight % 60;
3302
+ const pad = (n)=>String(n).padStart(2, '0');
3303
+ return `${pad(h)}:${pad(m)}:00`;
3304
+ }
3305
+ function startOfDay(d) {
3306
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0);
3307
+ }
3308
+ function endOfDay(d) {
3309
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 0);
3310
+ }
3311
+ // ---------------------------------------------------------------------------
3312
+ // Resources
3313
+ // ---------------------------------------------------------------------------
3314
+ function toMspdiResource(r, idx) {
3315
+ return {
3316
+ UID: String(r.id),
3317
+ ID: String(idx + 1),
3318
+ Name: r.name,
3319
+ Type: 1,
3320
+ CalendarUID: r.calendarId !== undefined ? String(r.calendarId) : '-1'
3321
+ };
3322
+ }
3323
+ // ---------------------------------------------------------------------------
3324
+ // Assignments
3325
+ // ---------------------------------------------------------------------------
3326
+ function toMspdiAssignment(a) {
3327
+ return {
3328
+ UID: String(a.id),
3329
+ TaskUID: String(a.taskId),
3330
+ ResourceUID: String(a.resourceId),
3331
+ Units: (a.units ?? 1).toString()
3332
+ };
3333
+ }
3334
+ // ---------------------------------------------------------------------------
3335
+ // Baselines
3336
+ // ---------------------------------------------------------------------------
3337
+ /**
3338
+ * Pivot project-level baselines into per-task entries for MSPDI emission.
3339
+ * Each task ends up with a sorted list of <Baseline> children (one per
3340
+ * baseline that has a snapshot for that task).
3341
+ */ function buildBaselinesByTask(baselines) {
3342
+ const out = new Map();
3343
+ // Sort by baseline index so emission order is stable.
3344
+ const sorted = [
3345
+ ...baselines
3346
+ ].sort((a, b)=>a.index - b.index);
3347
+ for (const baseline of sorted){
3348
+ for (const [taskId, snap] of baseline.tasks){
3349
+ const key = String(taskId);
3350
+ const entry = {
3351
+ Number: baseline.index,
3352
+ Start: formatMspdiDate(snap.start),
3353
+ Finish: formatMspdiDate(snap.end),
3354
+ Duration: formatMspdiDuration(snap.duration)
3355
+ };
3356
+ const existing = out.get(key);
3357
+ if (existing) {
3358
+ existing.push(entry);
3359
+ } else {
3360
+ out.set(key, [
3361
+ entry
3362
+ ]);
3363
+ }
3364
+ }
3365
+ }
3366
+ return out;
3367
+ }
3368
+
3369
+ function isWeekend(date) {
3370
+ const day = date.getDay();
3371
+ return day === 0 || day === 6;
3372
+ }
3373
+ const baseTasks = [
3374
+ {
3375
+ id: 1,
3376
+ text: 'Site preparation',
3377
+ start: new Date(2026, 0, 5),
3378
+ end: new Date(2026, 0, 9),
3379
+ duration: 5,
3380
+ type: 'task',
3381
+ progress: 100
3382
+ },
3383
+ {
3384
+ id: 2,
3385
+ text: 'Foundation pour',
3386
+ start: new Date(2026, 0, 10),
3387
+ end: new Date(2026, 0, 14),
3388
+ duration: 5,
3389
+ type: 'task',
3390
+ progress: 60
3391
+ },
3392
+ {
3393
+ id: 3,
3394
+ text: 'Framing',
3395
+ start: new Date(2026, 0, 15),
3396
+ end: new Date(2026, 0, 22),
3397
+ duration: 8,
3398
+ type: 'task',
3399
+ progress: 20
3400
+ },
3401
+ {
3402
+ id: 4,
3403
+ text: 'Council inspection',
3404
+ start: new Date(2026, 0, 23),
3405
+ end: new Date(2026, 0, 23),
3406
+ duration: 0,
3407
+ type: 'milestone',
3408
+ progress: 0
3409
+ }
3410
+ ];
3411
+ const spikeTasks = baseTasks.map((task)=>({
3412
+ ...task,
3413
+ is_weekend_start: task.start ? isWeekend(task.start) : false
3414
+ }));
3415
+ const SpikeTaskBar = ({ data })=>{
3416
+ return /*#__PURE__*/ jsxs("div", {
3417
+ style: {
3418
+ display: 'flex',
3419
+ alignItems: 'center',
3420
+ gap: 6,
3421
+ height: '100%',
3422
+ padding: '0 6px',
3423
+ fontSize: 12,
3424
+ color: '#1f2937'
3425
+ },
3426
+ children: [
3427
+ /*#__PURE__*/ jsx("span", {
3428
+ children: data.text
3429
+ }),
3430
+ data.is_weekend_start && /*#__PURE__*/ jsx("span", {
3431
+ style: {
3432
+ padding: '0 6px',
3433
+ background: '#fde68a',
3434
+ color: '#78350f',
3435
+ borderRadius: 4,
3436
+ fontSize: 10,
3437
+ fontWeight: 600,
3438
+ lineHeight: '16px'
3439
+ },
3440
+ children: "starts weekend"
3441
+ })
3442
+ ]
3443
+ });
3444
+ };
3445
+ function SpikeGantt() {
3446
+ return /*#__PURE__*/ jsx("div", {
3447
+ style: {
3448
+ height: 420
3449
+ },
3450
+ children: /*#__PURE__*/ jsx(Gantt$1, {
3451
+ tasks: spikeTasks,
3452
+ taskTemplate: SpikeTaskBar,
3453
+ start: new Date(2026, 0, 1),
3454
+ end: new Date(2026, 1, 1),
3455
+ cellWidth: 36,
3456
+ cellHeight: 42
3457
+ })
3458
+ });
3459
+ }
3460
+
3461
+ export { EditError, Gantt, SpikeGantt, addWorkingMinutes, captureBaseline, createTask, deleteLink, deleteTask, getCriticalPath, getDayWorkingMinutes, getProjectStats, getTaskBaselineVariance, getTaskBaselineVarianceAll, isWorkingDay, linkTasks, nzDefaultCalendar, nzPublicHolidays, parseMspdi, renameTask, schedule, serializeMspdi, setTaskDuration, setTaskProgress, setTaskStart, snapToNextWorkingMoment, snapToPreviousWorkingMoment, subtractWorkingMinutes, topologicalSort, updateLink, updateTask, useEditableProject, workingMinutesBetween };