construction-gantt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +5 -0
  3. package/dist/export/index.cjs +1 -0
  4. package/dist/export/index.d.cts +112 -0
  5. package/dist/export/index.d.cts.map +1 -0
  6. package/dist/export/index.d.ts +112 -0
  7. package/dist/export/index.d.ts.map +1 -0
  8. package/dist/export/index.js +1 -0
  9. package/dist/index.cjs +3492 -0
  10. package/dist/index.d.cts +628 -0
  11. package/dist/index.d.cts.map +1 -0
  12. package/dist/index.d.ts +628 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +3461 -0
  15. package/dist/pdf-CAQDrX0w.cjs +120 -0
  16. package/dist/pdf-CBaoJRTI.js +120 -0
  17. package/dist/png-C8t74695.cjs +88 -0
  18. package/dist/png-DKZeKnRh.js +88 -0
  19. package/dist/xlsx-5FRPFck7.js +89 -0
  20. package/dist/xlsx-Gh5L_NL3.cjs +111 -0
  21. package/package.json +86 -0
  22. package/src/Gantt.css +23 -0
  23. package/src/Gantt.tsx +636 -0
  24. package/src/SpikeGantt.tsx +114 -0
  25. package/src/analysis.ts +83 -0
  26. package/src/baseline.ts +119 -0
  27. package/src/calendars/canterbury-table.ts +44 -0
  28. package/src/calendars/internal/computus.ts +25 -0
  29. package/src/calendars/internal/date-utils.ts +13 -0
  30. package/src/calendars/internal/mondayisation.ts +46 -0
  31. package/src/calendars/internal/month-rules.ts +65 -0
  32. package/src/calendars/matariki-table.ts +63 -0
  33. package/src/calendars/nz-holidays.ts +214 -0
  34. package/src/editing/command-history.ts +78 -0
  35. package/src/editing/commands.ts +327 -0
  36. package/src/editing/composite-command.ts +64 -0
  37. package/src/editing/draft-project.ts +59 -0
  38. package/src/editing/errors.ts +14 -0
  39. package/src/editing/factories.ts +92 -0
  40. package/src/editing/use-editable-project.ts +122 -0
  41. package/src/export/index.ts +12 -0
  42. package/src/export/offscreen.tsx +89 -0
  43. package/src/export/pdf-dimensions.ts +64 -0
  44. package/src/export/pdf.ts +68 -0
  45. package/src/export/png.ts +48 -0
  46. package/src/export/types.ts +42 -0
  47. package/src/export/xlsx.ts +70 -0
  48. package/src/index.ts +89 -0
  49. package/src/mspdi/parse.ts +820 -0
  50. package/src/mspdi/serialize.ts +352 -0
  51. package/src/mspdi/types.ts +53 -0
  52. package/src/schedule.ts +470 -0
  53. package/src/topological-sort.ts +51 -0
  54. package/src/types.ts +254 -0
  55. package/src/visibility.ts +35 -0
  56. package/src/working-time.ts +235 -0
package/src/types.ts ADDED
@@ -0,0 +1,254 @@
1
+ // Core data model for construction-gantt.
2
+ //
3
+ // Designed to overlap with MS Project's data shape where it matters for
4
+ // MSPDI XML round-trip (ADR-003): all 8 constraint types, manual/auto
5
+ // scheduling per task, multi-baseline (0-10), working-time calendars with
6
+ // partial-day shifts.
7
+ //
8
+ // These types are public API and SVAR-agnostic per ADR-002. The wrapper
9
+ // converts them to SVAR's ITask before handing to the renderer.
10
+
11
+ export type TaskId = string | number;
12
+ export type LinkId = string | number;
13
+ export type ResourceId = string | number;
14
+ export type CalendarId = string | number;
15
+ export type AssignmentId = string | number;
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Tasks
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export type TaskType = 'task' | 'summary' | 'milestone';
22
+
23
+ export type ScheduleMode = 'auto' | 'manual';
24
+
25
+ /**
26
+ * MS Project's 8 constraint types.
27
+ *
28
+ * - ASAP / ALAP: scheduling preferences, no date required.
29
+ * - MSO / MFO: hard constraints, pin the date; override predecessor logic.
30
+ * - SNET / SNLT / FNET / FNLT: soft date bounds.
31
+ *
32
+ * Hard constraints can produce negative slack — the contract-trouble
33
+ * signal we surface rather than silently clip to zero (ADR-003).
34
+ */
35
+ export type ConstraintType = 'ASAP' | 'ALAP' | 'MSO' | 'MFO' | 'SNET' | 'SNLT' | 'FNET' | 'FNLT';
36
+
37
+ export interface Constraint {
38
+ type: ConstraintType;
39
+ /** Required for all types except ASAP and ALAP. */
40
+ date?: Date;
41
+ }
42
+
43
+ export interface Task {
44
+ id: TaskId;
45
+ text: string;
46
+ /** Hierarchy parent. Undefined = top-level. */
47
+ parent?: TaskId;
48
+ type: TaskType;
49
+ scheduleMode: ScheduleMode;
50
+
51
+ /**
52
+ * For summary tasks: are children currently expanded?
53
+ * Default true (expanded). Toggled by the user via the renderer's
54
+ * collapse/expand chevron.
55
+ */
56
+ open?: boolean;
57
+
58
+ /** Working-time duration in minutes. For milestones this is 0. */
59
+ duration: number;
60
+
61
+ /**
62
+ * User-set start / end. For manual-scheduled tasks these are authoritative.
63
+ * For auto-scheduled tasks the engine recomputes them and writes the
64
+ * result back. Either way, the engine populates `computed` with the
65
+ * full forward/backward-pass data.
66
+ */
67
+ start: Date;
68
+ end: Date;
69
+
70
+ /** Percent complete, 0-100. */
71
+ progress: number;
72
+
73
+ constraint?: Constraint;
74
+
75
+ /** Resource assignments. Order is not meaningful. */
76
+ resourceIds?: ResourceId[];
77
+
78
+ /**
79
+ * Calendar override. If unset, the task uses the project default calendar.
80
+ * If a resource on the task has a calendar, the engine reconciles them
81
+ * (resource calendar wins for activities depending on that resource).
82
+ */
83
+ calendarId?: CalendarId;
84
+
85
+ /** Populated by the scheduling engine. Read-only for user code. */
86
+ computed?: TaskComputed;
87
+ }
88
+
89
+ /**
90
+ * Forward/backward-pass output. Filled in by `schedule()`.
91
+ *
92
+ * `totalSlack` is the contract-trouble signal: when negative, the task is
93
+ * already late against a downstream constraint and the schedule is
94
+ * infeasible without intervention. We display this; we don't clip it.
95
+ */
96
+ export interface TaskComputed {
97
+ earlyStart: Date;
98
+ earlyFinish: Date;
99
+ lateStart: Date;
100
+ lateFinish: Date;
101
+ /** Working-minutes of total float. Can be negative. */
102
+ totalSlack: number;
103
+ /** Working-minutes of free float (slack against the earliest successor). */
104
+ freeSlack: number;
105
+ isCritical: boolean;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Links
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Dependency type, matching MS Project notation:
114
+ * - FS = Finish-to-Start (most common in construction)
115
+ * - SS = Start-to-Start (concurrent activities sharing a start trigger)
116
+ * - FF = Finish-to-Finish (concurrent activities sharing a finish gate)
117
+ * - SF = Start-to-Finish (rare; reverse-direction)
118
+ */
119
+ export type DependencyType = 'FS' | 'SS' | 'FF' | 'SF';
120
+
121
+ export interface Link {
122
+ id: LinkId;
123
+ source: TaskId;
124
+ target: TaskId;
125
+ type: DependencyType;
126
+ /**
127
+ * Lag in working-minutes. Positive = delay, negative = lead.
128
+ * Construction example: 3-day cure time on a FS link = lag of 3 working days
129
+ * converted to minutes via the calendar.
130
+ */
131
+ lag: number;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Calendars (working-time)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /** Minutes-from-midnight working interval. e.g. 8am-5pm = {480, 1020}. */
139
+ export interface WorkInterval {
140
+ startMinutes: number;
141
+ endMinutes: number;
142
+ }
143
+
144
+ export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; // Sun=0 … Sat=6
145
+
146
+ /**
147
+ * A calendar describes when work happens.
148
+ *
149
+ * - `workWeek` is the recurring base: 7 entries, each an array of intervals.
150
+ * Empty array = non-working day. Multiple intervals = split shift
151
+ * (e.g. 7-12 + 13-15 for concreting with a lunch break).
152
+ * - `exceptions` override specific dates: holidays, weather days, site
153
+ * shutdown periods, or "this Saturday is a working day."
154
+ * - `baseCalendarId` lets resource/task calendars inherit from a base
155
+ * and override only the differences.
156
+ */
157
+ export interface Calendar {
158
+ id: CalendarId;
159
+ name: string;
160
+ workWeek: WorkInterval[][];
161
+ exceptions: CalendarException[];
162
+ baseCalendarId?: CalendarId;
163
+ }
164
+
165
+ export interface CalendarException {
166
+ date: Date;
167
+ /** false = non-working day (holiday). true = working with given intervals. */
168
+ isWorking: boolean;
169
+ intervals?: WorkInterval[];
170
+ /** Display name. NZ Anzac Day, Auckland Anniversary, weather day, etc. */
171
+ name?: string;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Resources
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export interface Resource {
179
+ id: ResourceId;
180
+ name: string;
181
+ /** Optional per-resource calendar override (plasterer's 4-day week, etc.). */
182
+ calendarId?: CalendarId;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Assignments
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Allocates a Resource to a Task. v0.2 first cut carries only the core
191
+ * triple (taskId + resourceId + units) — enough to round-trip MSPDI
192
+ * <Assignment> elements without losing the allocation graph.
193
+ *
194
+ * Per-day timephased work data + cost tracking + EV fields are MSPDI-side
195
+ * concerns that we don't yet model internally; they appear in
196
+ * `droppedFields` on parse rather than entering our model.
197
+ *
198
+ * The eventual v0.4 editing-model expansion will likely add `actualWork`,
199
+ * `plannedWork`, and `progress` fields here, plus a separate
200
+ * `TimephasedSpread` type for per-day allocations.
201
+ */
202
+ export interface Assignment {
203
+ id: AssignmentId;
204
+ taskId: TaskId;
205
+ resourceId: ResourceId;
206
+ /**
207
+ * Allocation share. 1.0 = 100% (fully assigned). 0.5 = part-time. >1.0
208
+ * is over-allocated (MS Project allows this — the renderer surfaces it
209
+ * with the OverAllocated flag). Default 1.0 if omitted.
210
+ */
211
+ units?: number;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Baselines
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /** MS Project supports 11 baselines (Baseline 0-10). We match. */
219
+ export type BaselineIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
220
+
221
+ export interface Baseline {
222
+ index: BaselineIndex;
223
+ /** Display name. "Original contract programme", "Variation 1 reprogramme", etc. */
224
+ name?: string;
225
+ capturedAt: Date;
226
+ /** Snapshot of task dates + durations at capture time. */
227
+ tasks: Map<TaskId, BaselineTaskSnapshot>;
228
+ }
229
+
230
+ export interface BaselineTaskSnapshot {
231
+ start: Date;
232
+ end: Date;
233
+ duration: number;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Project
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export interface Project {
241
+ /** Project start anchor. Forward pass starts here. */
242
+ start: Date;
243
+ /** Optional finish anchor. If set, backward pass terminates here instead of latest task EarlyFinish. */
244
+ end?: Date;
245
+ defaultCalendarId: CalendarId;
246
+ tasks: Task[];
247
+ links: Link[];
248
+ resources: Resource[];
249
+ calendars: Calendar[];
250
+ /** Up to 11 entries (Baseline 0-10). */
251
+ baselines: Baseline[];
252
+ /** Resource-to-task allocations. Empty array if no resources are allocated. */
253
+ assignments: Assignment[];
254
+ }
@@ -0,0 +1,35 @@
1
+ // Render-only visibility filter for <Gantt> tasks.
2
+ //
3
+ // Per ADR-005 (engine-first; no BYO-CPM mode), consumer domain rules around
4
+ // hiding tasks must surface as render-time props, never as engine bypasses.
5
+ // This is the canonical example: the engine always runs on the full task
6
+ // set; the visibility filter applies only to the rendered output.
7
+ //
8
+ // Direct lift from the CM domain rule (FrappeGanttView.tsx:366-371 in
9
+ // Bode-Builds): "Hiding a predecessor would otherwise make a dependent
10
+ // task's early-start collapse to 0, which is wrong." We honour the rule
11
+ // by structure — the filter sits AFTER schedule() in the pipeline, so
12
+ // computed fields on the visible tasks reflect the full schedule.
13
+
14
+ import type { Task, TaskId } from './types.js';
15
+
16
+ /**
17
+ * Return the subset of `tasks` whose ids appear in `visibleTaskIds`.
18
+ *
19
+ * - `undefined` → no filter; the original array reference is returned
20
+ * so React's `useMemo` identity stays stable on the noop path.
21
+ * - empty set → returns an empty array (hide everything).
22
+ * - set containing ids not present in `tasks` → unknown ids ignored;
23
+ * only matching tasks survive.
24
+ *
25
+ * Crucially: this is a pure projection. `task.computed` is preserved
26
+ * exactly — no recomputation, no clipping, no slack adjustment. The
27
+ * engine already ran on the full set; we trust its result.
28
+ */
29
+ export function filterTasksByVisibility(
30
+ tasks: Task[],
31
+ visibleTaskIds: ReadonlySet<TaskId> | undefined,
32
+ ): Task[] {
33
+ if (visibleTaskIds === undefined) return tasks;
34
+ return tasks.filter((t) => visibleTaskIds.has(t.id));
35
+ }
@@ -0,0 +1,235 @@
1
+ import type { Calendar, CalendarException, WorkInterval } from './types';
2
+
3
+ export function isWorkingDay(date: Date, calendar: Calendar): boolean {
4
+ return getIntervalsForDay(date, calendar).length > 0;
5
+ }
6
+
7
+ export function getDayWorkingMinutes(date: Date, calendar: Calendar): number {
8
+ return getIntervalsForDay(date, calendar).reduce(
9
+ (sum, iv) => sum + (iv.endMinutes - iv.startMinutes),
10
+ 0,
11
+ );
12
+ }
13
+
14
+ /**
15
+ * Add `minutes` of working time to `start`, skipping non-working time
16
+ * (weekends, holidays, partial-day shift gaps). The result is the wall-clock
17
+ * Date exactly `minutes` working-time-minutes after `start`.
18
+ *
19
+ * If `start` falls in a non-working moment (weekend, after-hours, lunch
20
+ * break), the working-time clock begins from the next working interval.
21
+ *
22
+ * Note: not DST-aware. Working hours are wall-clock; cross-DST scheduling
23
+ * is approximate. Real DST handling is a future enhancement.
24
+ */
25
+ export function addWorkingMinutes(start: Date, minutes: number, calendar: Calendar): Date {
26
+ if (minutes <= 0) return new Date(start);
27
+
28
+ let current = new Date(start);
29
+ let remaining = minutes;
30
+
31
+ while (remaining > 0) {
32
+ const interval = findNextWorkingInterval(current, calendar);
33
+ if (!interval) {
34
+ throw new Error('Calendar has no working time within ~1 year after the given date');
35
+ }
36
+
37
+ if (current < interval.start) current = new Date(interval.start);
38
+
39
+ const availableMinutes = (interval.end.getTime() - current.getTime()) / 60_000;
40
+
41
+ if (remaining <= availableMinutes) {
42
+ return new Date(current.getTime() + remaining * 60_000);
43
+ }
44
+
45
+ remaining -= availableMinutes;
46
+ current = new Date(interval.end);
47
+ }
48
+
49
+ return current;
50
+ }
51
+
52
+ /**
53
+ * Subtract `minutes` of working time from `end`, walking backward through
54
+ * working intervals. Backward-pass mirror of {@link addWorkingMinutes}.
55
+ *
56
+ * If `end` falls in a non-working moment, the clock begins from the end of
57
+ * the most recent working interval.
58
+ */
59
+ export function subtractWorkingMinutes(end: Date, minutes: number, calendar: Calendar): Date {
60
+ if (minutes <= 0) return new Date(end);
61
+
62
+ let current = new Date(end);
63
+ let remaining = minutes;
64
+
65
+ while (remaining > 0) {
66
+ const interval = findPreviousWorkingInterval(current, calendar);
67
+ if (!interval) {
68
+ throw new Error('Calendar has no working time within ~1 year before the given date');
69
+ }
70
+
71
+ if (current > interval.end) current = new Date(interval.end);
72
+
73
+ const availableMinutes = (current.getTime() - interval.start.getTime()) / 60_000;
74
+
75
+ if (remaining <= availableMinutes) {
76
+ return new Date(current.getTime() - remaining * 60_000);
77
+ }
78
+
79
+ remaining -= availableMinutes;
80
+ current = new Date(interval.start);
81
+ }
82
+
83
+ return current;
84
+ }
85
+
86
+ /**
87
+ * Return the next working moment at or after `date`. If `date` already falls
88
+ * inside a working interval, returns it unchanged. If it falls in non-working
89
+ * time (weekend, after-hours, lunch break, holiday), returns the start of the
90
+ * next working interval.
91
+ */
92
+ export function snapToNextWorkingMoment(date: Date, calendar: Calendar): Date {
93
+ const interval = findNextWorkingInterval(date, calendar);
94
+ if (!interval) {
95
+ throw new Error('Calendar has no working time within ~1 year after the given date');
96
+ }
97
+ return date < interval.start ? new Date(interval.start) : new Date(date);
98
+ }
99
+
100
+ /**
101
+ * Return the previous working moment at or before `date`. Mirror of
102
+ * {@link snapToNextWorkingMoment} for backward-pass scheduling.
103
+ */
104
+ export function snapToPreviousWorkingMoment(date: Date, calendar: Calendar): Date {
105
+ const interval = findPreviousWorkingInterval(date, calendar);
106
+ if (!interval) {
107
+ throw new Error('Calendar has no working time within ~1 year before the given date');
108
+ }
109
+ return date > interval.end ? new Date(interval.end) : new Date(date);
110
+ }
111
+
112
+ /**
113
+ * Working-minute distance from `a` to `b`. Positive if `a` is earlier than
114
+ * `b`, negative if `a` is later. Used to compute slack (lateStart - earlyStart
115
+ * in working time).
116
+ */
117
+ export function workingMinutesBetween(a: Date, b: Date, calendar: Calendar): number {
118
+ if (a.getTime() === b.getTime()) return 0;
119
+ const reverse = a > b;
120
+ const start = reverse ? b : a;
121
+ const end = reverse ? a : b;
122
+
123
+ let current = new Date(start);
124
+ let total = 0;
125
+ while (current < end) {
126
+ const interval = findNextWorkingInterval(current, calendar);
127
+ if (!interval) break;
128
+ if (current < interval.start) current = new Date(interval.start);
129
+ if (current >= end) break;
130
+
131
+ if (interval.end >= end) {
132
+ total += (end.getTime() - current.getTime()) / 60_000;
133
+ break;
134
+ }
135
+ total += (interval.end.getTime() - current.getTime()) / 60_000;
136
+ current = new Date(interval.end);
137
+ }
138
+
139
+ return reverse ? -total : total;
140
+ }
141
+
142
+ interface DatedInterval {
143
+ start: Date;
144
+ end: Date;
145
+ }
146
+
147
+ /**
148
+ * Find the earliest working interval whose end is strictly after `date`.
149
+ * Walks forward up to ~1 year before giving up.
150
+ */
151
+ function findNextWorkingInterval(date: Date, calendar: Calendar): DatedInterval | null {
152
+ const msPerDay = 24 * 60 * 60 * 1000;
153
+ let day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
154
+
155
+ for (let i = 0; i < 366; i++) {
156
+ const intervals = getIntervalsForDay(day, calendar);
157
+ for (const iv of intervals) {
158
+ const intervalStart = new Date(
159
+ day.getFullYear(),
160
+ day.getMonth(),
161
+ day.getDate(),
162
+ 0,
163
+ iv.startMinutes,
164
+ );
165
+ const intervalEnd = new Date(
166
+ day.getFullYear(),
167
+ day.getMonth(),
168
+ day.getDate(),
169
+ 0,
170
+ iv.endMinutes,
171
+ );
172
+ if (intervalEnd > date) {
173
+ return { start: intervalStart, end: intervalEnd };
174
+ }
175
+ }
176
+ day = new Date(day.getTime() + msPerDay);
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Find the latest working interval whose start is strictly before `date`.
183
+ * Walks backward up to ~1 year before giving up.
184
+ */
185
+ function findPreviousWorkingInterval(date: Date, calendar: Calendar): DatedInterval | null {
186
+ const msPerDay = 24 * 60 * 60 * 1000;
187
+ let day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
188
+
189
+ for (let i = 0; i < 366; i++) {
190
+ const intervals = getIntervalsForDay(day, calendar);
191
+ for (let j = intervals.length - 1; j >= 0; j--) {
192
+ const iv = intervals[j];
193
+ if (!iv) continue;
194
+ const intervalStart = new Date(
195
+ day.getFullYear(),
196
+ day.getMonth(),
197
+ day.getDate(),
198
+ 0,
199
+ iv.startMinutes,
200
+ );
201
+ const intervalEnd = new Date(
202
+ day.getFullYear(),
203
+ day.getMonth(),
204
+ day.getDate(),
205
+ 0,
206
+ iv.endMinutes,
207
+ );
208
+ if (intervalStart < date) {
209
+ return { start: intervalStart, end: intervalEnd };
210
+ }
211
+ }
212
+ day = new Date(day.getTime() - msPerDay);
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function getIntervalsForDay(date: Date, calendar: Calendar): WorkInterval[] {
218
+ const exception = findException(date, calendar);
219
+ if (exception) {
220
+ return exception.isWorking ? (exception.intervals ?? []) : [];
221
+ }
222
+ return calendar.workWeek[date.getDay()] ?? [];
223
+ }
224
+
225
+ function findException(date: Date, calendar: Calendar): CalendarException | undefined {
226
+ return calendar.exceptions.find((ex) => isSameCalendarDay(ex.date, date));
227
+ }
228
+
229
+ function isSameCalendarDay(a: Date, b: Date): boolean {
230
+ return (
231
+ a.getFullYear() === b.getFullYear() &&
232
+ a.getMonth() === b.getMonth() &&
233
+ a.getDate() === b.getDate()
234
+ );
235
+ }