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,352 @@
1
+ // Internal Project → MSPDI XML. Hand-mapped, supported-subset only.
2
+ // Inverse of `parseMspdi` for the fields enumerated in this module's
3
+ // KNOWN_TASK_FIELDS + KNOWN_PROJECT_FIELDS sets.
4
+
5
+ import { XMLBuilder } from 'fast-xml-parser';
6
+ import type {
7
+ Assignment,
8
+ Baseline,
9
+ Calendar,
10
+ CalendarException,
11
+ DependencyType,
12
+ Link,
13
+ Project,
14
+ Resource,
15
+ WorkInterval,
16
+ } from '../types.js';
17
+ import type { MspdiSerializeOptions } from './types.js';
18
+
19
+ interface MspdiPredecessorLinkOut {
20
+ PredecessorUID: string;
21
+ Type: number;
22
+ LinkLag: number;
23
+ }
24
+
25
+ interface MspdiTaskBaselineOut {
26
+ Number: number;
27
+ Start: string;
28
+ Finish: string;
29
+ Duration: string;
30
+ }
31
+
32
+ interface MspdiTaskOut {
33
+ UID: string;
34
+ ID: string;
35
+ Name: string;
36
+ Start: string;
37
+ Finish: string;
38
+ Duration: string;
39
+ ConstraintType: number;
40
+ Milestone: number;
41
+ Summary: number;
42
+ OutlineLevel: number;
43
+ PredecessorLink?: MspdiPredecessorLinkOut[];
44
+ Baseline?: MspdiTaskBaselineOut[];
45
+ }
46
+
47
+ interface MspdiWorkingTimeOut {
48
+ FromTime: string;
49
+ ToTime: string;
50
+ }
51
+
52
+ interface MspdiTimePeriodOut {
53
+ FromDate: string;
54
+ ToDate: string;
55
+ }
56
+
57
+ interface MspdiWeekDayOut {
58
+ /** 1=Sunday … 7=Saturday for recurring days; 0 for exceptions. */
59
+ DayType: number;
60
+ DayWorking: number;
61
+ TimePeriod?: MspdiTimePeriodOut;
62
+ WorkingTimes?: { WorkingTime: MspdiWorkingTimeOut[] };
63
+ }
64
+
65
+ interface MspdiCalendarOut {
66
+ UID: string;
67
+ Name: string;
68
+ IsBaseCalendar: number;
69
+ WeekDays: { WeekDay: MspdiWeekDayOut[] };
70
+ }
71
+
72
+ interface MspdiResourceOut {
73
+ UID: string;
74
+ ID: string;
75
+ Name: string;
76
+ Type: number; // 0=Material, 1=Work, 2=Cost. v0.2 first cut emits 1 always.
77
+ CalendarUID: string;
78
+ }
79
+
80
+ interface MspdiAssignmentOut {
81
+ UID: string;
82
+ TaskUID: string;
83
+ ResourceUID: string;
84
+ Units: string;
85
+ }
86
+
87
+ interface MspdiProjectRootOut {
88
+ Name: string;
89
+ Title: string;
90
+ Author?: string;
91
+ StartDate: string;
92
+ Calendars?: { Calendar: MspdiCalendarOut[] };
93
+ Resources?: { Resource: MspdiResourceOut[] };
94
+ Tasks: { Task: MspdiTaskOut[] };
95
+ Assignments?: { Assignment: MspdiAssignmentOut[] };
96
+ }
97
+
98
+ export function serializeMspdi(project: Project, options: MspdiSerializeOptions = {}): string {
99
+ const meta = options.meta ?? {};
100
+ const name = meta.name ?? 'Untitled';
101
+ const title = meta.title ?? name;
102
+
103
+ // Group links by target so we can nest PredecessorLink elements inside Task.
104
+ const linksByTarget = new Map<string, Link[]>();
105
+ for (const link of project.links) {
106
+ const key = String(link.target);
107
+ const arr = linksByTarget.get(key) ?? [];
108
+ arr.push(link);
109
+ linksByTarget.set(key, arr);
110
+ }
111
+
112
+ // Group baselines by taskId. Each task may have one snapshot per baseline
113
+ // (0-10), emitted as <Baseline> children nested inside the task.
114
+ const baselinesByTask = buildBaselinesByTask(project.baselines);
115
+
116
+ const tasksOut: MspdiTaskOut[] = project.tasks.map((t, idx) => {
117
+ const taskOut: MspdiTaskOut = {
118
+ UID: String(t.id),
119
+ ID: String(idx + 1),
120
+ Name: t.text,
121
+ Start: formatMspdiDate(t.start),
122
+ Finish: formatMspdiDate(t.end),
123
+ Duration: formatMspdiDuration(t.duration),
124
+ ConstraintType: 0, // ASAP, the MSPDI default
125
+ Milestone: t.type === 'milestone' ? 1 : 0,
126
+ Summary: t.type === 'summary' ? 1 : 0,
127
+ OutlineLevel: 1,
128
+ };
129
+
130
+ const incoming = linksByTarget.get(String(t.id));
131
+ if (incoming?.length) {
132
+ taskOut.PredecessorLink = incoming.map(toMspdiLink);
133
+ }
134
+
135
+ const taskBaselines = baselinesByTask.get(String(t.id));
136
+ if (taskBaselines?.length) {
137
+ taskOut.Baseline = taskBaselines;
138
+ }
139
+
140
+ return taskOut;
141
+ });
142
+
143
+ const calendarsOut = project.calendars.map(toMspdiCalendar);
144
+ const resourcesOut = project.resources.map(toMspdiResource);
145
+ const assignmentsOut = project.assignments.map(toMspdiAssignment);
146
+
147
+ const projectRoot: MspdiProjectRootOut = {
148
+ Name: name,
149
+ Title: title,
150
+ ...(meta.author !== undefined ? { Author: meta.author } : {}),
151
+ StartDate: formatMspdiDate(project.start),
152
+ ...(calendarsOut.length > 0 ? { Calendars: { Calendar: calendarsOut } } : {}),
153
+ ...(resourcesOut.length > 0 ? { Resources: { Resource: resourcesOut } } : {}),
154
+ Tasks: { Task: tasksOut },
155
+ ...(assignmentsOut.length > 0 ? { Assignments: { Assignment: assignmentsOut } } : {}),
156
+ };
157
+
158
+ const builder = new XMLBuilder({
159
+ ignoreAttributes: true,
160
+ format: true,
161
+ indentBy: ' ',
162
+ suppressEmptyNode: true,
163
+ suppressBooleanAttributes: true,
164
+ processEntities: true,
165
+ });
166
+
167
+ const inner = builder.build({ Project: projectRoot }) as string;
168
+
169
+ // fast-xml-parser doesn't emit a namespace on the root, so we inject
170
+ // the standard MSPDI namespace declaration. Trim leading whitespace
171
+ // first so the regex anchor is reliable.
172
+ const trimmed = inner.replace(/^\s+/, '');
173
+ const withNamespace = trimmed.replace(
174
+ /^<Project>/,
175
+ '<Project xmlns="http://schemas.microsoft.com/project">',
176
+ );
177
+
178
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${withNamespace}`;
179
+ }
180
+
181
+ function toMspdiLink(link: Link): MspdiPredecessorLinkOut {
182
+ return {
183
+ PredecessorUID: String(link.source),
184
+ Type: dependencyTypeToMspdi(link.type),
185
+ LinkLag: (link.lag ?? 0) * 10, // tenths of a minute on the MSPDI side
186
+ };
187
+ }
188
+
189
+ function dependencyTypeToMspdi(t: DependencyType): number {
190
+ switch (t) {
191
+ case 'FF':
192
+ return 0;
193
+ case 'FS':
194
+ return 1;
195
+ case 'SF':
196
+ return 2;
197
+ case 'SS':
198
+ return 3;
199
+ }
200
+ }
201
+
202
+ function formatMspdiDate(d: Date): string {
203
+ // MSPDI emits local time without timezone (e.g. `2026-01-05T08:00:00`).
204
+ const pad = (n: number): string => String(n).padStart(2, '0');
205
+ return (
206
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
207
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
208
+ );
209
+ }
210
+
211
+ function formatMspdiDuration(totalMinutes: number): string {
212
+ const hours = Math.floor(totalMinutes / 60);
213
+ const minutes = totalMinutes % 60;
214
+ return `PT${hours}H${minutes}M0S`;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Calendars
219
+ // ---------------------------------------------------------------------------
220
+
221
+ function toMspdiCalendar(cal: Calendar): MspdiCalendarOut {
222
+ const weekDays: MspdiWeekDayOut[] = [];
223
+
224
+ // 7 recurring entries — MSPDI DayType 1=Sun … 7=Sat; our DayOfWeek 0=Sun … 6=Sat
225
+ for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
226
+ const intervals = cal.workWeek[dayOfWeek] ?? [];
227
+ weekDays.push(toRecurringWeekDay(dayOfWeek + 1, intervals));
228
+ }
229
+
230
+ // Exception entries — DayType=0 with TimePeriod
231
+ for (const ex of cal.exceptions) {
232
+ weekDays.push(toExceptionWeekDay(ex));
233
+ }
234
+
235
+ return {
236
+ UID: String(cal.id),
237
+ Name: cal.name,
238
+ IsBaseCalendar: cal.baseCalendarId === undefined ? 1 : 0,
239
+ WeekDays: { WeekDay: weekDays },
240
+ };
241
+ }
242
+
243
+ function toRecurringWeekDay(mspdiDayType: number, intervals: WorkInterval[]): MspdiWeekDayOut {
244
+ const working = intervals.length > 0;
245
+ const out: MspdiWeekDayOut = {
246
+ DayType: mspdiDayType,
247
+ DayWorking: working ? 1 : 0,
248
+ };
249
+ if (working) {
250
+ out.WorkingTimes = { WorkingTime: intervals.map(toMspdiWorkingTime) };
251
+ }
252
+ return out;
253
+ }
254
+
255
+ function toExceptionWeekDay(ex: CalendarException): MspdiWeekDayOut {
256
+ const dayStart = startOfDay(ex.date);
257
+ const dayEnd = endOfDay(ex.date);
258
+ const out: MspdiWeekDayOut = {
259
+ DayType: 0,
260
+ DayWorking: ex.isWorking ? 1 : 0,
261
+ TimePeriod: {
262
+ FromDate: formatMspdiDate(dayStart),
263
+ ToDate: formatMspdiDate(dayEnd),
264
+ },
265
+ };
266
+ if (ex.isWorking && ex.intervals?.length) {
267
+ out.WorkingTimes = { WorkingTime: ex.intervals.map(toMspdiWorkingTime) };
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function toMspdiWorkingTime(w: WorkInterval): MspdiWorkingTimeOut {
273
+ return {
274
+ FromTime: formatMspdiTime(w.startMinutes),
275
+ ToTime: formatMspdiTime(w.endMinutes),
276
+ };
277
+ }
278
+
279
+ function formatMspdiTime(minutesFromMidnight: number): string {
280
+ const h = Math.floor(minutesFromMidnight / 60);
281
+ const m = minutesFromMidnight % 60;
282
+ const pad = (n: number): string => String(n).padStart(2, '0');
283
+ return `${pad(h)}:${pad(m)}:00`;
284
+ }
285
+
286
+ function startOfDay(d: Date): Date {
287
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0);
288
+ }
289
+
290
+ function endOfDay(d: Date): Date {
291
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 0);
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Resources
296
+ // ---------------------------------------------------------------------------
297
+
298
+ function toMspdiResource(r: Resource, idx: number): MspdiResourceOut {
299
+ return {
300
+ UID: String(r.id),
301
+ ID: String(idx + 1),
302
+ Name: r.name,
303
+ Type: 1, // Work — the v0.2 default. Material/Cost typing is future work.
304
+ CalendarUID: r.calendarId !== undefined ? String(r.calendarId) : '-1',
305
+ };
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Assignments
310
+ // ---------------------------------------------------------------------------
311
+
312
+ function toMspdiAssignment(a: Assignment): MspdiAssignmentOut {
313
+ return {
314
+ UID: String(a.id),
315
+ TaskUID: String(a.taskId),
316
+ ResourceUID: String(a.resourceId),
317
+ Units: (a.units ?? 1).toString(),
318
+ };
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Baselines
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /**
326
+ * Pivot project-level baselines into per-task entries for MSPDI emission.
327
+ * Each task ends up with a sorted list of <Baseline> children (one per
328
+ * baseline that has a snapshot for that task).
329
+ */
330
+ function buildBaselinesByTask(baselines: Baseline[]): Map<string, MspdiTaskBaselineOut[]> {
331
+ const out = new Map<string, MspdiTaskBaselineOut[]>();
332
+ // Sort by baseline index so emission order is stable.
333
+ const sorted = [...baselines].sort((a, b) => a.index - b.index);
334
+ for (const baseline of sorted) {
335
+ for (const [taskId, snap] of baseline.tasks) {
336
+ const key = String(taskId);
337
+ const entry: MspdiTaskBaselineOut = {
338
+ Number: baseline.index,
339
+ Start: formatMspdiDate(snap.start),
340
+ Finish: formatMspdiDate(snap.end),
341
+ Duration: formatMspdiDuration(snap.duration),
342
+ };
343
+ const existing = out.get(key);
344
+ if (existing) {
345
+ existing.push(entry);
346
+ } else {
347
+ out.set(key, [entry]);
348
+ }
349
+ }
350
+ }
351
+ return out;
352
+ }
@@ -0,0 +1,53 @@
1
+ // MSPDI (Microsoft Project Data Interchange) XML — internal types.
2
+ //
3
+ // MSPDI is MS Project's XML export format, schema-defined as `mspdi_pj12.xsd`.
4
+ // We support a documented subset of the schema; unsupported fields surface
5
+ // in the `droppedFields` accumulator on parse rather than being silent drops.
6
+ //
7
+ // Scope of v0.2 first cut: Tasks + Links (PredecessorLink). Calendars,
8
+ // Resources, Assignments, and the rich set of cost/work fields land in
9
+ // subsequent commits.
10
+
11
+ import type { Project } from '../types.js';
12
+
13
+ /**
14
+ * Result of parsing an MSPDI XML document.
15
+ *
16
+ * `project` is our internal SVAR-agnostic Project shape.
17
+ * `droppedFields` enumerates MSPDI fields we encountered but did not map,
18
+ * for consumer transparency. Each entry: the JSON-path-like location, the
19
+ * raw value, and a short reason (`'unsupported-element'`, `'unsupported-attribute'`,
20
+ * `'lossy-on-roundtrip'`).
21
+ */
22
+ export interface MspdiParseResult {
23
+ project: Project;
24
+ droppedFields: DroppedField[];
25
+ }
26
+
27
+ export interface DroppedField {
28
+ /** Dotted path to the location in the MSPDI document, e.g. `Project.Tasks.Task[2].Notes`. */
29
+ path: string;
30
+ /** The raw value we saw and chose not to map. Stringified for diagnostic only. */
31
+ value: string;
32
+ /** Why it wasn't carried into the internal Project. */
33
+ reason: 'unsupported-element' | 'unsupported-attribute' | 'lossy-on-roundtrip';
34
+ }
35
+
36
+ /**
37
+ * Options accepted by `serializeMspdi`.
38
+ */
39
+ export interface MspdiSerializeOptions {
40
+ /**
41
+ * Override the root project metadata. Defaults match MS Project 2016+
42
+ * defaults where applicable. Optional fields not set here are omitted
43
+ * from the output.
44
+ */
45
+ meta?: {
46
+ /** `<Name>` — defaults to 'Untitled'. */
47
+ name?: string;
48
+ /** `<Author>` — defaults to omitted. */
49
+ author?: string;
50
+ /** `<Title>` — defaults to the same as `name`. */
51
+ title?: string;
52
+ };
53
+ }