construction-gantt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +5 -0
- package/dist/export/index.cjs +1 -0
- package/dist/export/index.d.cts +112 -0
- package/dist/export/index.d.cts.map +1 -0
- package/dist/export/index.d.ts +112 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +1 -0
- package/dist/index.cjs +3492 -0
- package/dist/index.d.cts +628 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +628 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3461 -0
- package/dist/pdf-CAQDrX0w.cjs +120 -0
- package/dist/pdf-CBaoJRTI.js +120 -0
- package/dist/png-C8t74695.cjs +88 -0
- package/dist/png-DKZeKnRh.js +88 -0
- package/dist/xlsx-5FRPFck7.js +89 -0
- package/dist/xlsx-Gh5L_NL3.cjs +111 -0
- package/package.json +86 -0
- package/src/Gantt.css +23 -0
- package/src/Gantt.tsx +636 -0
- package/src/SpikeGantt.tsx +114 -0
- package/src/analysis.ts +83 -0
- package/src/baseline.ts +119 -0
- package/src/calendars/canterbury-table.ts +44 -0
- package/src/calendars/internal/computus.ts +25 -0
- package/src/calendars/internal/date-utils.ts +13 -0
- package/src/calendars/internal/mondayisation.ts +46 -0
- package/src/calendars/internal/month-rules.ts +65 -0
- package/src/calendars/matariki-table.ts +63 -0
- package/src/calendars/nz-holidays.ts +214 -0
- package/src/editing/command-history.ts +78 -0
- package/src/editing/commands.ts +327 -0
- package/src/editing/composite-command.ts +64 -0
- package/src/editing/draft-project.ts +59 -0
- package/src/editing/errors.ts +14 -0
- package/src/editing/factories.ts +92 -0
- package/src/editing/use-editable-project.ts +122 -0
- package/src/export/index.ts +12 -0
- package/src/export/offscreen.tsx +89 -0
- package/src/export/pdf-dimensions.ts +64 -0
- package/src/export/pdf.ts +68 -0
- package/src/export/png.ts +48 -0
- package/src/export/types.ts +42 -0
- package/src/export/xlsx.ts +70 -0
- package/src/index.ts +89 -0
- package/src/mspdi/parse.ts +820 -0
- package/src/mspdi/serialize.ts +352 -0
- package/src/mspdi/types.ts +53 -0
- package/src/schedule.ts +470 -0
- package/src/topological-sort.ts +51 -0
- package/src/types.ts +254 -0
- package/src/visibility.ts +35 -0
- package/src/working-time.ts +235 -0
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 };
|