construction-gantt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +5 -0
  3. package/dist/export/index.cjs +1 -0
  4. package/dist/export/index.d.cts +112 -0
  5. package/dist/export/index.d.cts.map +1 -0
  6. package/dist/export/index.d.ts +112 -0
  7. package/dist/export/index.d.ts.map +1 -0
  8. package/dist/export/index.js +1 -0
  9. package/dist/index.cjs +3492 -0
  10. package/dist/index.d.cts +628 -0
  11. package/dist/index.d.cts.map +1 -0
  12. package/dist/index.d.ts +628 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +3461 -0
  15. package/dist/pdf-CAQDrX0w.cjs +120 -0
  16. package/dist/pdf-CBaoJRTI.js +120 -0
  17. package/dist/png-C8t74695.cjs +88 -0
  18. package/dist/png-DKZeKnRh.js +88 -0
  19. package/dist/xlsx-5FRPFck7.js +89 -0
  20. package/dist/xlsx-Gh5L_NL3.cjs +111 -0
  21. package/package.json +86 -0
  22. package/src/Gantt.css +23 -0
  23. package/src/Gantt.tsx +636 -0
  24. package/src/SpikeGantt.tsx +114 -0
  25. package/src/analysis.ts +83 -0
  26. package/src/baseline.ts +119 -0
  27. package/src/calendars/canterbury-table.ts +44 -0
  28. package/src/calendars/internal/computus.ts +25 -0
  29. package/src/calendars/internal/date-utils.ts +13 -0
  30. package/src/calendars/internal/mondayisation.ts +46 -0
  31. package/src/calendars/internal/month-rules.ts +65 -0
  32. package/src/calendars/matariki-table.ts +63 -0
  33. package/src/calendars/nz-holidays.ts +214 -0
  34. package/src/editing/command-history.ts +78 -0
  35. package/src/editing/commands.ts +327 -0
  36. package/src/editing/composite-command.ts +64 -0
  37. package/src/editing/draft-project.ts +59 -0
  38. package/src/editing/errors.ts +14 -0
  39. package/src/editing/factories.ts +92 -0
  40. package/src/editing/use-editable-project.ts +122 -0
  41. package/src/export/index.ts +12 -0
  42. package/src/export/offscreen.tsx +89 -0
  43. package/src/export/pdf-dimensions.ts +64 -0
  44. package/src/export/pdf.ts +68 -0
  45. package/src/export/png.ts +48 -0
  46. package/src/export/types.ts +42 -0
  47. package/src/export/xlsx.ts +70 -0
  48. package/src/index.ts +89 -0
  49. package/src/mspdi/parse.ts +820 -0
  50. package/src/mspdi/serialize.ts +352 -0
  51. package/src/mspdi/types.ts +53 -0
  52. package/src/schedule.ts +470 -0
  53. package/src/topological-sort.ts +51 -0
  54. package/src/types.ts +254 -0
  55. package/src/visibility.ts +35 -0
  56. package/src/working-time.ts +235 -0
@@ -0,0 +1,820 @@
1
+ // MSPDI XML → internal Project. Hand-mapped, supported-subset only.
2
+ // Unrecognised MSPDI elements surface in `droppedFields` rather than
3
+ // being silently discarded.
4
+
5
+ import { XMLParser } from 'fast-xml-parser';
6
+ import type {
7
+ Assignment,
8
+ Baseline,
9
+ BaselineIndex,
10
+ BaselineTaskSnapshot,
11
+ Calendar,
12
+ CalendarException,
13
+ DayOfWeek,
14
+ DependencyType,
15
+ Link,
16
+ Project,
17
+ Resource,
18
+ Task,
19
+ TaskId,
20
+ TaskType,
21
+ WorkInterval,
22
+ } from '../types.js';
23
+ import type { DroppedField, MspdiParseResult } from './types.js';
24
+
25
+ // Field names we know about and either map or intentionally ignore on parse.
26
+ // Everything outside this set lands in `droppedFields`.
27
+ //
28
+ // The list is intentionally broad: real MS Project exports emit ~50 fields
29
+ // per Task, most of which are MS-Project-computed state (CPM results, EV,
30
+ // rates, costs, leveling). We don't preserve these on round-trip — our
31
+ // engine recomputes the equivalent fields. Listing them here keeps
32
+ // `droppedFields` focused on genuinely-unknown elements rather than
33
+ // recompute-able noise.
34
+ const KNOWN_TASK_FIELDS = new Set([
35
+ // Mapped — read into the internal Task shape
36
+ 'UID',
37
+ 'ID',
38
+ 'Name',
39
+ 'Start',
40
+ 'Finish',
41
+ 'Duration',
42
+ 'ConstraintType',
43
+ 'Milestone',
44
+ 'Summary',
45
+ 'OutlineLevel',
46
+ 'PredecessorLink',
47
+ 'Baseline',
48
+ // Allowed but ignored (default-bearing structure or recompute-able state)
49
+ 'Type', // task type code; only Milestone + Summary flags affect us
50
+ 'IsNull',
51
+ 'CreateDate',
52
+ 'WBS',
53
+ 'OutlineNumber',
54
+ 'Priority',
55
+ 'PercentComplete',
56
+ 'PercentWorkComplete',
57
+ 'PhysicalPercentComplete',
58
+ 'EarnedValueMethod',
59
+ 'DurationFormat',
60
+ 'Work',
61
+ 'ResumeValid',
62
+ 'EffortDriven',
63
+ 'Recurring',
64
+ 'OverAllocated',
65
+ 'Estimated',
66
+ 'Critical',
67
+ 'IsSubproject',
68
+ 'IsSubprojectReadOnly',
69
+ 'ExternalTask',
70
+ // CPM results — recomputed by our engine
71
+ 'EarlyStart',
72
+ 'EarlyFinish',
73
+ 'LateStart',
74
+ 'LateFinish',
75
+ 'StartVariance',
76
+ 'FinishVariance',
77
+ 'WorkVariance',
78
+ 'FreeSlack',
79
+ 'TotalSlack',
80
+ // Cost + work tracking — outside our v0.2 scope
81
+ 'FixedCost',
82
+ 'FixedCostAccrual',
83
+ 'Cost',
84
+ 'OvertimeCost',
85
+ 'OvertimeWork',
86
+ 'ActualDuration',
87
+ 'ActualCost',
88
+ 'ActualOvertimeCost',
89
+ 'ActualWork',
90
+ 'ActualOvertimeWork',
91
+ 'RegularWork',
92
+ 'RemainingDuration',
93
+ 'RemainingCost',
94
+ 'RemainingWork',
95
+ 'RemainingOvertimeCost',
96
+ 'RemainingOvertimeWork',
97
+ 'ACWP',
98
+ 'CV',
99
+ 'BCWS',
100
+ 'BCWP',
101
+ // Calendar override per task + leveling — not in our v0.2 scope
102
+ 'CalendarUID',
103
+ 'LevelAssignments',
104
+ 'LevelingCanSplit',
105
+ 'LevelingDelay',
106
+ 'LevelingDelayFormat',
107
+ 'IgnoreResourceCalendar',
108
+ 'HideBar',
109
+ 'Rollup',
110
+ // Server/publishing — meaningless outside MS Project Server context
111
+ 'IsPublished',
112
+ 'CommitmentType',
113
+ ]);
114
+
115
+ const KNOWN_PROJECT_FIELDS = new Set([
116
+ 'Name',
117
+ 'Title',
118
+ 'Author',
119
+ 'StartDate',
120
+ 'Tasks',
121
+ // ignored without dropping for v0.2 first cut (will be supported in
122
+ // future commits — listed here so we don't noise up droppedFields)
123
+ 'Calendars',
124
+ 'Resources',
125
+ 'Assignments',
126
+ 'WBSMasks',
127
+ 'OutlineCodes',
128
+ 'ExtendedAttributes',
129
+ // pure metadata that consumers should preserve via meta-roundtrip but
130
+ // doesn't enter our Project shape
131
+ 'Manager',
132
+ 'Company',
133
+ 'Subject',
134
+ 'Category',
135
+ 'Keywords',
136
+ 'Comments',
137
+ 'CreationDate',
138
+ 'LastSaved',
139
+ 'FinishDate',
140
+ 'CurrencyCode',
141
+ 'ScheduleFromStart',
142
+ 'FYStartDate',
143
+ 'CriticalSlackLimit',
144
+ 'CurrencyDigits',
145
+ 'CurrencySymbol',
146
+ 'CurrencySymbolPosition',
147
+ 'CalendarUID',
148
+ 'DefaultStartTime',
149
+ 'DefaultFinishTime',
150
+ 'MinutesPerDay',
151
+ 'MinutesPerWeek',
152
+ 'DaysPerMonth',
153
+ 'DefaultTaskType',
154
+ 'DefaultFixedCostAccrual',
155
+ 'DefaultStandardRate',
156
+ 'DefaultOvertimeRate',
157
+ 'DurationFormat',
158
+ 'WorkFormat',
159
+ 'EditableActualCosts',
160
+ 'HonorConstraints',
161
+ 'EarnedValueMethod',
162
+ 'InsertedProjectsLikeSummary',
163
+ 'MultipleCriticalPaths',
164
+ 'NewTasksEffortDriven',
165
+ 'NewTasksEstimated',
166
+ 'SplitsInProgressTasks',
167
+ 'SpreadActualCost',
168
+ 'SpreadPercentComplete',
169
+ 'TaskUpdatesResource',
170
+ 'FiscalYearStart',
171
+ 'WeekStartDay',
172
+ 'MoveCompletedEndsBack',
173
+ 'MoveRemainingStartsBack',
174
+ 'MoveRemainingStartsForward',
175
+ 'MoveCompletedEndsForward',
176
+ 'BaselineForEarnedValue',
177
+ 'AutoAddNewResourcesAndTasks',
178
+ 'StatusDate',
179
+ 'CurrentDate',
180
+ 'MicrosoftProjectServerURL',
181
+ 'Autolink',
182
+ 'NewTaskStartDate',
183
+ 'DefaultTaskEVMethod',
184
+ 'ProjectExternallyEdited',
185
+ 'ExtendedCreationDate',
186
+ 'ActualsInSync',
187
+ 'AdminProject',
188
+ 'RemoveFileProperties',
189
+ 'SaveVersion',
190
+ 'UID',
191
+ ]);
192
+
193
+ const KNOWN_PREDECESSOR_FIELDS = new Set([
194
+ 'PredecessorUID',
195
+ 'Type',
196
+ 'LinkLag',
197
+ 'CrossProject',
198
+ 'CrossProjectName',
199
+ // LagFormat is a magic number (7=minutes, 5=hours, 39=days) describing
200
+ // how the consumer should *display* LinkLag — we always normalize to
201
+ // minutes internally, so it's allowed-but-ignored.
202
+ 'LagFormat',
203
+ ]);
204
+
205
+ export function parseMspdi(xml: string): MspdiParseResult {
206
+ const parser = new XMLParser({
207
+ ignoreAttributes: true,
208
+ isArray: (_name, jpath) =>
209
+ jpath === 'Project.Tasks.Task' ||
210
+ jpath.endsWith('.PredecessorLink') ||
211
+ jpath === 'Project.Calendars.Calendar' ||
212
+ jpath === 'Project.Resources.Resource' ||
213
+ jpath === 'Project.Assignments.Assignment' ||
214
+ jpath.endsWith('.WeekDays.WeekDay') ||
215
+ jpath.endsWith('.WorkingTimes.WorkingTime') ||
216
+ jpath === 'Project.Tasks.Task.Baseline',
217
+ parseTagValue: false, // keep everything as strings; we coerce per-field
218
+ trimValues: true,
219
+ });
220
+
221
+ const doc = parser.parse(xml);
222
+ const root = doc.Project;
223
+ if (!root) {
224
+ throw new Error('parseMspdi: <Project> root element missing');
225
+ }
226
+
227
+ const droppedFields: DroppedField[] = [];
228
+
229
+ // Scan the project-level fields we don't know about.
230
+ for (const [key, value] of Object.entries(root)) {
231
+ if (KNOWN_PROJECT_FIELDS.has(key)) continue;
232
+ droppedFields.push({
233
+ path: `Project.${key}`,
234
+ value: stringifyForDiag(value),
235
+ reason: 'unsupported-element',
236
+ });
237
+ }
238
+
239
+ const tasks: Task[] = [];
240
+ const links: Link[] = [];
241
+ // Per-task baseline snapshots, keyed by baseline Number (BaselineIndex).
242
+ // Flattened into project.baselines below.
243
+ const baselineAccum = new Map<BaselineIndex, Map<TaskId, BaselineTaskSnapshot>>();
244
+
245
+ const rawTasks: unknown[] = root.Tasks?.Task ?? [];
246
+ if (!Array.isArray(rawTasks)) {
247
+ throw new Error('parseMspdi: <Tasks> contained a non-array Task collection (malformed)');
248
+ }
249
+
250
+ for (let i = 0; i < rawTasks.length; i++) {
251
+ const raw = rawTasks[i] as Record<string, unknown>;
252
+ const taskPath = `Project.Tasks.Task[${i}]`;
253
+
254
+ // Map the supported fields.
255
+ const uid = String(raw.UID ?? raw.ID ?? '');
256
+ if (!uid) throw new Error(`parseMspdi: ${taskPath} missing UID and ID`);
257
+
258
+ const name = String(raw.Name ?? '');
259
+ const start = parseMspdiDate(String(raw.Start ?? ''));
260
+ const end = parseMspdiDate(String(raw.Finish ?? ''));
261
+ const duration = parseMspdiDuration(String(raw.Duration ?? 'PT0H0M0S'));
262
+
263
+ const isMilestone = String(raw.Milestone ?? '0') === '1';
264
+ const isSummary = String(raw.Summary ?? '0') === '1';
265
+ const taskType: TaskType = isSummary ? 'summary' : isMilestone ? 'milestone' : 'task';
266
+
267
+ tasks.push({
268
+ id: uid,
269
+ text: name,
270
+ type: taskType,
271
+ scheduleMode: 'auto',
272
+ duration,
273
+ start,
274
+ end,
275
+ progress: 0,
276
+ });
277
+
278
+ // Walk predecessor links nested inside the task.
279
+ const preds: unknown[] = Array.isArray(raw.PredecessorLink)
280
+ ? (raw.PredecessorLink as unknown[])
281
+ : raw.PredecessorLink !== undefined
282
+ ? [raw.PredecessorLink]
283
+ : [];
284
+
285
+ for (let p = 0; p < preds.length; p++) {
286
+ const link = preds[p] as Record<string, unknown>;
287
+ const srcUid = String(link.PredecessorUID ?? '');
288
+ if (!srcUid) continue;
289
+
290
+ const mspdiType = Number(link.Type ?? '1');
291
+ const linkType: DependencyType = mspdiTypeToDependencyType(mspdiType);
292
+ const lagTenthsOfMinute = Number(link.LinkLag ?? '0');
293
+ const lagMinutes = Math.round(lagTenthsOfMinute / 10);
294
+
295
+ links.push({
296
+ id: `${srcUid}-${uid}-${p}`,
297
+ source: srcUid,
298
+ target: uid,
299
+ type: linkType,
300
+ lag: lagMinutes,
301
+ });
302
+
303
+ // Capture any unknown fields on the predecessor link.
304
+ for (const k of Object.keys(link)) {
305
+ if (KNOWN_PREDECESSOR_FIELDS.has(k)) continue;
306
+ droppedFields.push({
307
+ path: `${taskPath}.PredecessorLink[${p}].${k}`,
308
+ value: stringifyForDiag(link[k]),
309
+ reason: 'unsupported-element',
310
+ });
311
+ }
312
+ }
313
+
314
+ // Walk per-task <Baseline> children and accumulate into baselineAccum.
315
+ // MSPDI Baseline lives task-level (each task has up to 11 Baseline
316
+ // children with Number=0..10); our internal Baseline is project-level
317
+ // with a Map<TaskId, snapshot>. Pivot during parse.
318
+ const taskBaselines: unknown[] = Array.isArray(raw.Baseline)
319
+ ? (raw.Baseline as unknown[])
320
+ : raw.Baseline !== undefined
321
+ ? [raw.Baseline]
322
+ : [];
323
+
324
+ for (let b = 0; b < taskBaselines.length; b++) {
325
+ const baselineRaw = taskBaselines[b] as Record<string, unknown>;
326
+ const numberStr = String(baselineRaw.Number ?? '');
327
+ const number = Number(numberStr);
328
+ if (!Number.isFinite(number) || number < 0 || number > 10) {
329
+ droppedFields.push({
330
+ path: `${taskPath}.Baseline[${b}].Number`,
331
+ value: numberStr,
332
+ reason: 'unsupported-element',
333
+ });
334
+ continue;
335
+ }
336
+ const index = number as BaselineIndex;
337
+ const snap: BaselineTaskSnapshot = {
338
+ start: parseMspdiDate(String(baselineRaw.Start ?? '')),
339
+ end: parseMspdiDate(String(baselineRaw.Finish ?? '')),
340
+ duration: parseMspdiDuration(String(baselineRaw.Duration ?? 'PT0H0M0S')),
341
+ };
342
+ let acc = baselineAccum.get(index);
343
+ if (!acc) {
344
+ acc = new Map<TaskId, BaselineTaskSnapshot>();
345
+ baselineAccum.set(index, acc);
346
+ }
347
+ acc.set(uid, snap);
348
+ }
349
+
350
+ // Scan the task-level fields we don't know about.
351
+ for (const [key, value] of Object.entries(raw)) {
352
+ if (KNOWN_TASK_FIELDS.has(key)) continue;
353
+ droppedFields.push({
354
+ path: `${taskPath}.${key}`,
355
+ value: stringifyForDiag(value),
356
+ reason: 'unsupported-element',
357
+ });
358
+ }
359
+ }
360
+
361
+ const projectStart = root.StartDate
362
+ ? parseMspdiDate(String(root.StartDate))
363
+ : (tasks[0]?.start ?? new Date());
364
+
365
+ // Calendars. MSPDI optionally nests <Calendars><Calendar>+. Each Calendar's
366
+ // <WeekDays> contains DayType 1-7 entries (the recurring pattern) and
367
+ // DayType=0 entries with TimePeriod (exceptions). See toMspdiCalendar in
368
+ // serialize.ts for the inverse mapping.
369
+ const calendars: Calendar[] = [];
370
+ const rawCalendars: unknown[] = root.Calendars?.Calendar ?? [];
371
+ if (Array.isArray(rawCalendars)) {
372
+ for (let i = 0; i < rawCalendars.length; i++) {
373
+ const raw = rawCalendars[i] as Record<string, unknown>;
374
+ calendars.push(parseMspdiCalendar(raw, `Project.Calendars.Calendar[${i}]`, droppedFields));
375
+ }
376
+ }
377
+
378
+ // Resources. v0.2 first cut maps only UID + Name + CalendarUID; rates,
379
+ // types, units, cost, and other MS Project resource fields are
380
+ // intentionally ignored without dropping (we don't yet model them).
381
+ const resources: Resource[] = [];
382
+ const rawResources: unknown[] = root.Resources?.Resource ?? [];
383
+ if (Array.isArray(rawResources)) {
384
+ for (let i = 0; i < rawResources.length; i++) {
385
+ const raw = rawResources[i] as Record<string, unknown>;
386
+ resources.push(parseMspdiResource(raw, `Project.Resources.Resource[${i}]`, droppedFields));
387
+ }
388
+ }
389
+
390
+ // Assignments. v0.2 first cut maps only UID + TaskUID + ResourceUID + Units
391
+ // (the resource-to-task allocation triple). Per-day timephased data, cost
392
+ // tracking, and EV fields don't enter our model — they appear in
393
+ // droppedFields if present.
394
+ const assignments: Assignment[] = [];
395
+ const rawAssignments: unknown[] = root.Assignments?.Assignment ?? [];
396
+ if (Array.isArray(rawAssignments)) {
397
+ for (let i = 0; i < rawAssignments.length; i++) {
398
+ const raw = rawAssignments[i] as Record<string, unknown>;
399
+ assignments.push(
400
+ parseMspdiAssignment(raw, `Project.Assignments.Assignment[${i}]`, droppedFields),
401
+ );
402
+ }
403
+ }
404
+
405
+ // Pick a sensible defaultCalendarId. Prefer the first calendar with
406
+ // `IsBaseCalendar` 1; fall back to the first calendar; fall back to 'std'.
407
+ let defaultCalendarId = 'std';
408
+ const firstCalendar = calendars[0];
409
+ if (firstCalendar) {
410
+ const firstBase = calendars.find((c) => c.baseCalendarId === undefined);
411
+ defaultCalendarId = String((firstBase ?? firstCalendar).id);
412
+ }
413
+
414
+ // Flatten the per-task baseline accumulator into our project-level
415
+ // Baseline[] shape. Sort by index for stable order.
416
+ const baselines: Baseline[] = [];
417
+ for (const [index, taskMap] of baselineAccum) {
418
+ baselines.push({
419
+ index,
420
+ // MSPDI doesn't carry baseline name or capturedAt on individual snapshots;
421
+ // synthesize defaults. Consumers who want named baselines can populate
422
+ // these after parse.
423
+ capturedAt: new Date(0),
424
+ tasks: taskMap,
425
+ });
426
+ }
427
+ baselines.sort((a, b) => a.index - b.index);
428
+
429
+ const project: Project = {
430
+ start: projectStart,
431
+ defaultCalendarId,
432
+ tasks,
433
+ links,
434
+ resources,
435
+ calendars,
436
+ baselines,
437
+ assignments,
438
+ };
439
+
440
+ return { project, droppedFields };
441
+ }
442
+
443
+ const KNOWN_CALENDAR_FIELDS = new Set([
444
+ 'UID',
445
+ 'Name',
446
+ 'IsBaseCalendar',
447
+ 'BaseCalendarUID',
448
+ 'WeekDays',
449
+ ]);
450
+ const KNOWN_WEEKDAY_FIELDS = new Set(['DayType', 'DayWorking', 'WorkingTimes', 'TimePeriod']);
451
+
452
+ // v0.2 first cut: only UID + Name + CalendarUID enter our Resource shape.
453
+ // Other fields exist in real MS Project exports — listed here as
454
+ // allowed-but-ignored so they don't noise up droppedFields.
455
+ const KNOWN_RESOURCE_FIELDS = new Set([
456
+ // Mapped
457
+ 'UID',
458
+ 'ID',
459
+ 'Name',
460
+ 'CalendarUID',
461
+ // Allowed but ignored (no internal model yet)
462
+ 'IsNull',
463
+ 'Initials',
464
+ 'Group',
465
+ 'Code',
466
+ 'EmailAddress',
467
+ 'WindowsUserAccount',
468
+ 'Type',
469
+ 'IsGeneric',
470
+ 'IsInactive',
471
+ 'IsEnterprise',
472
+ 'BookingType',
473
+ 'MaterialLabel',
474
+ 'AccrueAt',
475
+ 'MaxUnits',
476
+ 'PeakUnits',
477
+ 'OverAllocated',
478
+ 'AvailableFrom',
479
+ 'AvailableTo',
480
+ 'StandardRate',
481
+ 'StandardRateFormat',
482
+ 'OvertimeRate',
483
+ 'OvertimeRateFormat',
484
+ 'CostPerUse',
485
+ 'Cost',
486
+ 'CostVariance',
487
+ 'OvertimeCost',
488
+ 'ActualCost',
489
+ 'ActualOvertimeCost',
490
+ 'RemainingCost',
491
+ 'RemainingOvertimeCost',
492
+ 'CostCenter',
493
+ 'BudgetCost',
494
+ 'BaselineCost',
495
+ 'Work',
496
+ 'RegularWork',
497
+ 'OvertimeWork',
498
+ 'ActualWork',
499
+ 'RemainingWork',
500
+ 'ActualOvertimeWork',
501
+ 'RemainingOvertimeWork',
502
+ 'PercentWorkComplete',
503
+ 'WorkVariance',
504
+ 'StartVariance',
505
+ 'FinishVariance',
506
+ 'BudgetWork',
507
+ 'BaselineWork',
508
+ 'ACWP',
509
+ 'CV',
510
+ 'BCWS',
511
+ 'BCWP',
512
+ 'Start',
513
+ 'Finish',
514
+ 'CanLevel',
515
+ 'NotesText',
516
+ 'NotesRTF',
517
+ 'CreationDate',
518
+ 'Hyperlink',
519
+ 'HyperlinkAddress',
520
+ 'HyperlinkSubAddress',
521
+ 'PhoneticAlias',
522
+ 'ExtendedAttribute',
523
+ 'Baseline',
524
+ 'OutlineCode',
525
+ 'TimephasedData',
526
+ ]);
527
+
528
+ // v0.2 first cut: only UID + TaskUID + ResourceUID + Units enter our
529
+ // Assignment shape. Per-day timephased data, cost tracking, EV fields,
530
+ // and confirmed/leveled times are all allowed-but-ignored.
531
+ const KNOWN_ASSIGNMENT_FIELDS = new Set([
532
+ // Mapped
533
+ 'UID',
534
+ 'TaskUID',
535
+ 'ResourceUID',
536
+ 'Units',
537
+ // Allowed but ignored (no internal model yet)
538
+ 'PercentWorkComplete',
539
+ 'ActualCost',
540
+ 'ActualWork',
541
+ 'Cost',
542
+ 'CostVariance',
543
+ 'Work',
544
+ 'WorkVariance',
545
+ 'StartVariance',
546
+ 'FinishVariance',
547
+ 'OvertimeCost',
548
+ 'OvertimeWork',
549
+ 'ActualOvertimeCost',
550
+ 'ActualOvertimeWork',
551
+ 'RegularWork',
552
+ 'RemainingCost',
553
+ 'RemainingWork',
554
+ 'RemainingOvertimeCost',
555
+ 'RemainingOvertimeWork',
556
+ 'ConfirmedFinish',
557
+ 'ConfirmedStart',
558
+ 'Start',
559
+ 'Finish',
560
+ 'Stop',
561
+ 'Resume',
562
+ 'ResumeValid',
563
+ 'LevelingDelay',
564
+ 'LevelingDelayFormat',
565
+ 'Delay',
566
+ 'NotesText',
567
+ 'NotesRTF',
568
+ 'Hyperlink',
569
+ 'HyperlinkAddress',
570
+ 'HyperlinkSubAddress',
571
+ 'CostRateTable',
572
+ 'BookingType',
573
+ 'ActualStart',
574
+ 'ActualFinish',
575
+ 'WorkContour',
576
+ 'BudgetCost',
577
+ 'BudgetWork',
578
+ 'BaselineCost',
579
+ 'BaselineWork',
580
+ 'BaselineStart',
581
+ 'BaselineFinish',
582
+ 'BaselineBudgetCost',
583
+ 'BaselineBudgetWork',
584
+ 'ACWP',
585
+ 'CV',
586
+ 'BCWS',
587
+ 'BCWP',
588
+ 'Baseline',
589
+ 'ExtendedAttribute',
590
+ 'TimephasedData',
591
+ 'CreationDate',
592
+ ]);
593
+
594
+ function parseMspdiCalendar(
595
+ raw: Record<string, unknown>,
596
+ path: string,
597
+ droppedFields: DroppedField[],
598
+ ): Calendar {
599
+ const id = String(raw.UID ?? raw.Name ?? 'std');
600
+ const name = String(raw.Name ?? id);
601
+
602
+ const workWeek: WorkInterval[][] = [[], [], [], [], [], [], []];
603
+ const exceptions: CalendarException[] = [];
604
+
605
+ const weekDays: unknown[] =
606
+ ((raw.WeekDays as Record<string, unknown> | undefined)?.WeekDay as unknown[] | undefined) ?? [];
607
+
608
+ if (Array.isArray(weekDays)) {
609
+ for (let i = 0; i < weekDays.length; i++) {
610
+ const wd = weekDays[i] as Record<string, unknown>;
611
+ const wdPath = `${path}.WeekDays.WeekDay[${i}]`;
612
+ const dayType = Number(wd.DayType ?? '0');
613
+ const dayWorking = String(wd.DayWorking ?? '0') === '1';
614
+
615
+ if (dayType >= 1 && dayType <= 7) {
616
+ // Recurring weekday — DayType 1=Sun ... 7=Sat → DayOfWeek 0=Sun ... 6=Sat
617
+ const dayOfWeek = (dayType - 1) as DayOfWeek;
618
+ workWeek[dayOfWeek] = dayWorking ? parseWorkingTimes(wd) : [];
619
+ } else if (dayType === 0) {
620
+ // Exception entry
621
+ const timePeriod = wd.TimePeriod as Record<string, unknown> | undefined;
622
+ if (!timePeriod) continue;
623
+ const fromDate = parseMspdiDate(String(timePeriod.FromDate ?? ''));
624
+ // Treat as single-day exception, anchored on FromDate's local-day boundary.
625
+ const anchored = new Date(fromDate.getFullYear(), fromDate.getMonth(), fromDate.getDate());
626
+ const ex: CalendarException = {
627
+ date: anchored,
628
+ isWorking: dayWorking,
629
+ };
630
+ if (dayWorking) {
631
+ const intervals = parseWorkingTimes(wd);
632
+ if (intervals.length) ex.intervals = intervals;
633
+ }
634
+ exceptions.push(ex);
635
+ }
636
+
637
+ // Scan unknown WeekDay fields
638
+ for (const k of Object.keys(wd)) {
639
+ if (KNOWN_WEEKDAY_FIELDS.has(k)) continue;
640
+ droppedFields.push({
641
+ path: `${wdPath}.${k}`,
642
+ value: stringifyForDiag(wd[k]),
643
+ reason: 'unsupported-element',
644
+ });
645
+ }
646
+ }
647
+ }
648
+
649
+ // Scan unknown Calendar fields
650
+ for (const k of Object.keys(raw)) {
651
+ if (KNOWN_CALENDAR_FIELDS.has(k)) continue;
652
+ droppedFields.push({
653
+ path: `${path}.${k}`,
654
+ value: stringifyForDiag(raw[k]),
655
+ reason: 'unsupported-element',
656
+ });
657
+ }
658
+
659
+ const calendar: Calendar = {
660
+ id,
661
+ name,
662
+ workWeek,
663
+ exceptions,
664
+ };
665
+
666
+ // BaseCalendarUID — MS Project uses -1 for "no base". Treat -1 or absent
667
+ // as top-level; otherwise carry through.
668
+ const baseUid = raw.BaseCalendarUID !== undefined ? String(raw.BaseCalendarUID) : undefined;
669
+ if (baseUid !== undefined && baseUid !== '-1') {
670
+ calendar.baseCalendarId = baseUid;
671
+ }
672
+
673
+ return calendar;
674
+ }
675
+
676
+ function parseWorkingTimes(wd: Record<string, unknown>): WorkInterval[] {
677
+ const wt = wd.WorkingTimes as Record<string, unknown> | undefined;
678
+ if (!wt) return [];
679
+ const arr = wt.WorkingTime as unknown[] | undefined;
680
+ if (!Array.isArray(arr)) return [];
681
+ const intervals: WorkInterval[] = [];
682
+ for (const w of arr) {
683
+ const wRec = w as Record<string, unknown>;
684
+ const fromTime = String(wRec.FromTime ?? '');
685
+ const toTime = String(wRec.ToTime ?? '');
686
+ intervals.push({
687
+ startMinutes: parseMspdiTime(fromTime),
688
+ endMinutes: parseMspdiTime(toTime),
689
+ });
690
+ }
691
+ return intervals;
692
+ }
693
+
694
+ function parseMspdiTime(s: string): number {
695
+ // `HH:MM:SS` → minutes-from-midnight. Seconds truncated.
696
+ const match = s.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
697
+ if (!match) return 0;
698
+ return Number(match[1]) * 60 + Number(match[2]);
699
+ }
700
+
701
+ const MSPDI_TYPE_TO_DEPENDENCY: Record<number, DependencyType> = {
702
+ 0: 'FF',
703
+ 1: 'FS',
704
+ 2: 'SF',
705
+ 3: 'SS',
706
+ };
707
+
708
+ function mspdiTypeToDependencyType(t: number): DependencyType {
709
+ return MSPDI_TYPE_TO_DEPENDENCY[t] ?? 'FS';
710
+ }
711
+
712
+ function parseMspdiDate(s: string): Date {
713
+ // MSPDI emits ISO 8601 like `2026-01-05T08:00:00` (no timezone in
714
+ // practice; MS Project writes local time). We treat it as local.
715
+ return new Date(s);
716
+ }
717
+
718
+ function parseMspdiDuration(s: string): number {
719
+ // MSPDI duration format: `PT{H}H{M}M{S}S` where each component is
720
+ // optional (e.g. `PT24H0M0S`, `PT0H30M0S`). Returns total minutes
721
+ // (seconds truncated).
722
+ const match = s.match(/^PT(\d+)H(\d+)M(\d+)S$/);
723
+ if (match) {
724
+ return Number(match[1]) * 60 + Number(match[2]);
725
+ }
726
+ // Looser fallback — support `PT{N}M` shorthand.
727
+ const looseMin = s.match(/^PT(\d+)M$/);
728
+ if (looseMin) return Number(looseMin[1]);
729
+ const looseHr = s.match(/^PT(\d+)H$/);
730
+ if (looseHr) return Number(looseHr[1]) * 60;
731
+ return 0;
732
+ }
733
+
734
+ function stringifyForDiag(v: unknown): string {
735
+ if (v === null || v === undefined) return '';
736
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
737
+ try {
738
+ return JSON.stringify(v).slice(0, 200);
739
+ } catch {
740
+ return '<unstringifiable>';
741
+ }
742
+ }
743
+
744
+ // ---------------------------------------------------------------------------
745
+ // Resources
746
+ // ---------------------------------------------------------------------------
747
+
748
+ function parseMspdiResource(
749
+ raw: Record<string, unknown>,
750
+ path: string,
751
+ droppedFields: DroppedField[],
752
+ ): Resource {
753
+ const id = String(raw.UID ?? raw.ID ?? '');
754
+ const name = String(raw.Name ?? '');
755
+
756
+ const resource: Resource = { id, name };
757
+
758
+ // CalendarUID — MS Project uses -1 for "no override". Treat -1, absent,
759
+ // or empty as "use project default"; otherwise carry through.
760
+ const calUid = raw.CalendarUID !== undefined ? String(raw.CalendarUID) : undefined;
761
+ if (calUid !== undefined && calUid !== '-1' && calUid !== '') {
762
+ resource.calendarId = calUid;
763
+ }
764
+
765
+ // Scan unknown Resource fields
766
+ for (const k of Object.keys(raw)) {
767
+ if (KNOWN_RESOURCE_FIELDS.has(k)) continue;
768
+ droppedFields.push({
769
+ path: `${path}.${k}`,
770
+ value: stringifyForDiag(raw[k]),
771
+ reason: 'unsupported-element',
772
+ });
773
+ }
774
+
775
+ return resource;
776
+ }
777
+
778
+ // ---------------------------------------------------------------------------
779
+ // Assignments
780
+ // ---------------------------------------------------------------------------
781
+
782
+ function parseMspdiAssignment(
783
+ raw: Record<string, unknown>,
784
+ path: string,
785
+ droppedFields: DroppedField[],
786
+ ): Assignment {
787
+ const id = String(raw.UID ?? '');
788
+ const taskId = String(raw.TaskUID ?? '');
789
+ const resourceId = String(raw.ResourceUID ?? '');
790
+
791
+ const assignment: Assignment = { id, taskId, resourceId };
792
+
793
+ // Units default 1.0. MSPDI stores as a decimal string. Treat absent/empty
794
+ // or unparseable as 1.0; only emit if it differs from the default.
795
+ if (raw.Units !== undefined) {
796
+ const units = Number(raw.Units);
797
+ if (Number.isFinite(units) && units !== 1) {
798
+ assignment.units = units;
799
+ }
800
+ }
801
+
802
+ // Scan unknown Assignment fields. TimephasedData specifically is large +
803
+ // commonly present in real MS Project exports — listing it as
804
+ // allowed-but-ignored is the honest position until we add a per-day
805
+ // allocation model (v0.4+).
806
+ for (const k of Object.keys(raw)) {
807
+ if (KNOWN_ASSIGNMENT_FIELDS.has(k)) continue;
808
+ droppedFields.push({
809
+ path: `${path}.${k}`,
810
+ value: stringifyForDiag(raw[k]),
811
+ reason: 'unsupported-element',
812
+ });
813
+ }
814
+
815
+ return assignment;
816
+ }
817
+
818
+ // Re-export from this entry so consumers reach it without learning the
819
+ // internal split.
820
+ export type { DroppedField, MspdiParseResult, TaskId };