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/Gantt.css ADDED
@@ -0,0 +1,23 @@
1
+ /* Non-working day shading on the time scale.
2
+ * Applied to cells where the calendar has no working intervals for the
3
+ * given date (weekends + holiday exceptions). Construction PMs expect
4
+ * to see at a glance which days the crew is on site. */
5
+ .construction-gantt-non-working {
6
+ background: rgba(241, 245, 249, 0.65);
7
+ }
8
+
9
+ /* Today-line marker. Red 2px vertical line; high-priority visual cue for
10
+ * what the date cursor is on. */
11
+ .construction-gantt-marker-today {
12
+ background: #ef4444;
13
+ color: #ffffff;
14
+ font-weight: 600;
15
+ }
16
+
17
+ /* Generic milestone marker (council inspections, payment-claim dates,
18
+ * handover gates). Blue, less aggressive than today. */
19
+ .construction-gantt-marker-milestone {
20
+ background: #2563eb;
21
+ color: #ffffff;
22
+ font-weight: 500;
23
+ }
package/src/Gantt.tsx ADDED
@@ -0,0 +1,636 @@
1
+ // The real <Gantt>. Consumes a Project, runs the scheduling engine, converts
2
+ // our SVAR-agnostic data model to SVAR's ITask/ILink, renders through
3
+ // SVAR's free-tier React component per ADR-002 (shape-c slot composition).
4
+ //
5
+ // Public API surface stays SVAR-agnostic: consumers pass a Project; SVAR
6
+ // is a private implementation detail. If we ever swap renderers (per
7
+ // ADR-002's seam), this file is the only consumer-facing change.
8
+
9
+ import {
10
+ type IColumnConfig,
11
+ type ILink,
12
+ type ITask,
13
+ Gantt as SvarGantt,
14
+ } from '@svar-ui/react-gantt';
15
+ import '@svar-ui/react-gantt/style.css';
16
+ import './Gantt.css';
17
+ import { type FC, forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
18
+ import { getTaskBaselineVariance } from './baseline';
19
+ import type {
20
+ GanttHandle,
21
+ PdfExportOptions,
22
+ PngExportOptions,
23
+ XlsxExportOptions,
24
+ } from './export/types.js';
25
+ import { schedule } from './schedule';
26
+ import type {
27
+ Baseline,
28
+ BaselineIndex,
29
+ Calendar,
30
+ DependencyType,
31
+ Link,
32
+ Project,
33
+ Task,
34
+ TaskId,
35
+ } from './types';
36
+ import { filterTasksByVisibility } from './visibility.js';
37
+ import { isWorkingDay } from './working-time';
38
+
39
+ export interface GanttMarker {
40
+ start: Date;
41
+ text?: string;
42
+ /**
43
+ * Visual style. 'today' renders as a red line; 'milestone' as blue;
44
+ * 'custom' lets you supply a `css` class name yourself.
45
+ */
46
+ variant?: 'today' | 'milestone' | 'custom';
47
+ /** CSS class name when `variant: 'custom'`. */
48
+ css?: string;
49
+ }
50
+
51
+ /**
52
+ * Defines a column in the left-hand grid of the Gantt chart.
53
+ * Construction-PM-facing: these are the columns site PMs expect alongside
54
+ * the task name (WBS code, trade package, assigned subcontractor, etc.).
55
+ *
56
+ * This is a SVAR-agnostic type — internal conversion to SVAR's IColumnConfig
57
+ * happens inside the <Gantt> wrapper.
58
+ */
59
+ export interface GanttColumn<TTask = Task> {
60
+ /** Identifier; used as SVAR column id. */
61
+ id: string;
62
+ /** Header label rendered in the column header row. */
63
+ header: string;
64
+ /**
65
+ * The field on the task to pluck for the default cell render.
66
+ * Optional — if `render` is provided, this is ignored.
67
+ */
68
+ field?: keyof TTask;
69
+ /** Column width in pixels. Defaults to SVAR's default if unset. */
70
+ width?: number;
71
+ /** Text alignment for the cell. */
72
+ align?: 'left' | 'center' | 'right';
73
+ /**
74
+ * Custom cell render. Receives the live (scheduled) task. Use for any
75
+ * column more complex than displaying a single field value.
76
+ */
77
+ render?: FC<{ task: Task }>;
78
+ }
79
+
80
+ export interface GanttProps {
81
+ project: Project;
82
+ /** Container height. Defaults to 500 (px). */
83
+ height?: number | string;
84
+ /** Width of one time-scale cell (per `cellWidth` SVAR prop). Default 48. */
85
+ cellWidth?: number;
86
+ /** Height of one row. */
87
+ cellHeight?: number;
88
+ /**
89
+ * Skip running the scheduling engine. Use when the project's tasks
90
+ * already have `computed` populated by a prior `schedule()` call.
91
+ */
92
+ preScheduled?: boolean;
93
+ /**
94
+ * Vertical markers (today line + arbitrary milestones).
95
+ * Default: a today line if the current date falls within the project
96
+ * window. Pass an empty array to suppress, or your own markers list to
97
+ * override.
98
+ */
99
+ markers?: GanttMarker[];
100
+ /**
101
+ * Show variance against this baseline index. If unset (or no matching
102
+ * baseline exists on `project.baselines`), bars render without variance
103
+ * pills. Construction-vertical use case (ADR-003): comparing the live
104
+ * programme against the original contract programme captured under
105
+ * NZS 3910 / AS 4000.
106
+ */
107
+ baselineIndex?: BaselineIndex;
108
+ /**
109
+ * Render the baseline as a separate "ghost" bar beneath each live task.
110
+ * Matches the MS Project baseline-view idiom construction PMs expect
111
+ * when reviewing variation claims. Default true when `baselineIndex`
112
+ * is set; pass `false` to keep variance shown only as in-bar pills.
113
+ */
114
+ showBaselineBars?: boolean;
115
+ /**
116
+ * Grid columns displayed alongside the Gantt bars.
117
+ *
118
+ * - `undefined` (default): SVAR renders its built-in columns (task name +
119
+ * duration + start + end).
120
+ * - `[]` (empty array): hides the grid entirely (passes `columns={false}`
121
+ * to SVAR).
122
+ * - `GanttColumn[]`: replaces SVAR's default columns with the supplied set.
123
+ *
124
+ * Construction-PM-facing columns live here: WBS code, trade package,
125
+ * assigned subcontractor, and similar project-specific fields.
126
+ */
127
+ columns?: GanttColumn[];
128
+ /**
129
+ * Render-only visibility filter. When set, only tasks whose `id` is in
130
+ * the set are rendered. **CPM still runs on the full task set** — hidden
131
+ * predecessors continue to drive their visible successors' computed
132
+ * fields. The visibility filter is a render-only concern (ADR-005).
133
+ *
134
+ * - `undefined` (default): no filter; render everything.
135
+ * - empty set: render nothing.
136
+ * - set containing ids not present in `project.tasks`: those ids are
137
+ * ignored; only matching tasks render.
138
+ *
139
+ * Lifts the "filter-while-keeping-CPM-correct" domain rule that
140
+ * consumer apps would otherwise have to write themselves. See
141
+ * `visibility.ts` for the contract test.
142
+ */
143
+ visibleTaskIds?: ReadonlySet<TaskId>;
144
+ }
145
+
146
+ interface SvarTaskWithComputed extends ITask {
147
+ is_critical?: boolean;
148
+ is_late?: boolean;
149
+ total_slack?: number;
150
+ /** Working-minutes by which start has slipped against the baseline. */
151
+ start_variance?: number;
152
+ /** True if startVariance >= 30 working minutes (drifted later than plan). */
153
+ is_slipped?: boolean;
154
+ /** True if startVariance <= -30 working minutes (ahead of plan). */
155
+ is_ahead?: boolean;
156
+ /** True for phantom rows representing a baseline snapshot's position. */
157
+ is_baseline_ghost?: boolean;
158
+ }
159
+
160
+ interface SvarMarker {
161
+ start: Date;
162
+ text?: string;
163
+ css?: string;
164
+ }
165
+
166
+ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
167
+ {
168
+ project,
169
+ height = 500,
170
+ cellWidth = 48,
171
+ cellHeight = 42,
172
+ preScheduled = false,
173
+ markers,
174
+ baselineIndex,
175
+ showBaselineBars,
176
+ columns,
177
+ visibleTaskIds,
178
+ },
179
+ ref,
180
+ ) {
181
+ const containerRef = useRef<HTMLDivElement>(null);
182
+
183
+ const scheduled = useMemo(
184
+ () => (preScheduled ? project : schedule(project)),
185
+ [project, preScheduled],
186
+ );
187
+
188
+ // Visibility filter is render-only — applied AFTER schedule() has run so
189
+ // computed fields on visible tasks reflect the full project. ADR-005.
190
+ const renderableTasks = useMemo(
191
+ () => filterTasksByVisibility(scheduled.tasks, visibleTaskIds),
192
+ [scheduled.tasks, visibleTaskIds],
193
+ );
194
+
195
+ const calendar = useMemo(
196
+ () => scheduled.calendars.find((c) => c.id === scheduled.defaultCalendarId),
197
+ [scheduled.calendars, scheduled.defaultCalendarId],
198
+ );
199
+
200
+ const baseline = useMemo<Baseline | undefined>(() => {
201
+ if (baselineIndex === undefined) return undefined;
202
+ return scheduled.baselines.find((b) => b.index === baselineIndex);
203
+ }, [scheduled.baselines, baselineIndex]);
204
+
205
+ const ghostBarsEnabled = baseline !== undefined && (showBaselineBars ?? true);
206
+
207
+ const svarTasks: ITask[] = useMemo(() => {
208
+ if (!ghostBarsEnabled || !baseline) {
209
+ return renderableTasks.map((t) => toSvarTask(t, baseline, calendar));
210
+ }
211
+ // Interleave each real task with its baseline-snapshot ghost row.
212
+ // Phantoms share the real task's `parent` so they stay grouped under
213
+ // the same summary in hierarchy views.
214
+ const out: SvarTaskWithComputed[] = [];
215
+ for (const t of renderableTasks) {
216
+ out.push(toSvarTask(t, baseline, calendar));
217
+ if (t.type === 'summary') continue;
218
+ const phantom = makeBaselinePhantom(t, baseline);
219
+ if (phantom) out.push(phantom);
220
+ }
221
+ return out;
222
+ }, [renderableTasks, baseline, calendar, ghostBarsEnabled]);
223
+ const svarLinks: ILink[] = useMemo(() => scheduled.links.map(toSvarLink), [scheduled.links]);
224
+
225
+ const projectEnd = useMemo(() => getProjectEnd(scheduled), [scheduled]);
226
+ // Mark calendar referenced even when consumed only by useMemo args (TS unused-let guard)
227
+ void calendar;
228
+
229
+ const svarMarkers: SvarMarker[] = useMemo(
230
+ () => resolveMarkers(markers, scheduled.start, projectEnd),
231
+ [markers, scheduled.start, projectEnd],
232
+ );
233
+
234
+ const highlightTime = useMemo(() => buildHighlightTime(calendar), [calendar]);
235
+
236
+ // Convert our SVAR-agnostic GanttColumn[] to SVAR's IColumnConfig[].
237
+ // undefined → don't pass columns to SVAR (use SVAR defaults).
238
+ // [] → pass false to SVAR (hide grid entirely).
239
+ // [...] → convert each column.
240
+ const svarColumns: IColumnConfig[] | false | undefined = useMemo(() => {
241
+ if (columns === undefined) return undefined;
242
+ if (columns.length === 0) return false;
243
+ return columns.map(toSvarColumn);
244
+ }, [columns]);
245
+
246
+ useImperativeHandle(
247
+ ref,
248
+ () => ({
249
+ async exportPNG(options?: PngExportOptions): Promise<Blob> {
250
+ const { exportPNG } = await import('./export/png.js');
251
+ return exportPNG({
252
+ scheduled,
253
+ ganttProps: {
254
+ cellWidth,
255
+ cellHeight,
256
+ markers,
257
+ baselineIndex,
258
+ showBaselineBars,
259
+ columns,
260
+ height,
261
+ visibleTaskIds,
262
+ },
263
+ options: options ?? {},
264
+ });
265
+ },
266
+ async exportPDF(options?: PdfExportOptions): Promise<Blob> {
267
+ const { exportPDF } = await import('./export/pdf.js');
268
+ return exportPDF({
269
+ scheduled,
270
+ ganttProps: {
271
+ cellWidth,
272
+ cellHeight,
273
+ markers,
274
+ baselineIndex,
275
+ showBaselineBars,
276
+ columns,
277
+ height,
278
+ visibleTaskIds,
279
+ },
280
+ options: options ?? {},
281
+ });
282
+ },
283
+ async exportXLSX(options?: XlsxExportOptions): Promise<Blob> {
284
+ const { exportXLSX } = await import('./export/xlsx.js');
285
+ return exportXLSX({ scheduled, options: options ?? {} });
286
+ },
287
+ }),
288
+ [
289
+ scheduled,
290
+ cellWidth,
291
+ cellHeight,
292
+ markers,
293
+ baselineIndex,
294
+ showBaselineBars,
295
+ columns,
296
+ height,
297
+ visibleTaskIds,
298
+ ],
299
+ );
300
+
301
+ return (
302
+ <div ref={containerRef} style={{ height }}>
303
+ <SvarGantt
304
+ tasks={svarTasks}
305
+ links={svarLinks}
306
+ start={scheduled.start}
307
+ end={projectEnd}
308
+ cellWidth={cellWidth}
309
+ cellHeight={cellHeight}
310
+ markers={svarMarkers}
311
+ highlightTime={highlightTime}
312
+ taskTemplate={ConstructionBar}
313
+ {...(svarColumns !== undefined ? { columns: svarColumns as IColumnConfig[] } : {})}
314
+ />
315
+ </div>
316
+ );
317
+ });
318
+
319
+ const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
320
+ // Phantom baseline row — render a slim outlined ghost bar.
321
+ if (data.is_baseline_ghost) {
322
+ return (
323
+ <div
324
+ style={{
325
+ height: '60%',
326
+ marginTop: '15%',
327
+ border: '1.5px dashed #94a3b8',
328
+ background: 'transparent',
329
+ borderRadius: 3,
330
+ fontSize: 9,
331
+ color: '#64748b',
332
+ display: 'flex',
333
+ alignItems: 'center',
334
+ padding: '0 6px',
335
+ fontStyle: 'italic',
336
+ whiteSpace: 'nowrap',
337
+ }}
338
+ title="Baseline position — where this task was when the baseline was captured"
339
+ >
340
+ baseline
341
+ </div>
342
+ );
343
+ }
344
+
345
+ const isCritical = data.is_critical ?? false;
346
+ const isLate = data.is_late ?? false;
347
+ const isSummary = data.type === 'summary';
348
+ const isMilestone = data.type === 'milestone';
349
+
350
+ if (isMilestone) {
351
+ return (
352
+ <div
353
+ style={{
354
+ width: '100%',
355
+ height: '100%',
356
+ display: 'flex',
357
+ alignItems: 'center',
358
+ justifyContent: 'center',
359
+ color: isCritical ? '#fff' : '#1f2937',
360
+ fontWeight: 600,
361
+ fontSize: 11,
362
+ }}
363
+ >
364
+ ◆ {data.text}
365
+ </div>
366
+ );
367
+ }
368
+
369
+ const isSlipped = data.is_slipped ?? false;
370
+ const isAhead = data.is_ahead ?? false;
371
+ // Show slack indicator for non-critical, non-summary, non-milestone tasks
372
+ // with at least half a working day of total float. Skips the noise of "+5m"
373
+ // pills on the visually-critical path tasks.
374
+ const totalSlack = data.total_slack ?? 0;
375
+ const showSlackIndicator = !isSummary && !isCritical && totalSlack >= 270; // >= 30 min more than half a day
376
+
377
+ return (
378
+ <div
379
+ style={{
380
+ display: 'flex',
381
+ alignItems: 'center',
382
+ gap: 6,
383
+ height: '100%',
384
+ padding: '0 8px',
385
+ fontSize: 12,
386
+ fontWeight: isSummary ? 600 : 500,
387
+ color: isCritical ? '#fff' : '#1f2937',
388
+ background: isSummary ? 'transparent' : isCritical ? '#dc2626' : '#cbd5e1',
389
+ borderRadius: 4,
390
+ border: isSummary ? '2px solid #1e293b' : undefined,
391
+ }}
392
+ >
393
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
394
+ {data.text}
395
+ </span>
396
+ {isSlipped && (
397
+ <span
398
+ style={{
399
+ padding: '0 6px',
400
+ background: '#fed7aa',
401
+ color: '#7c2d12',
402
+ borderRadius: 3,
403
+ fontSize: 10,
404
+ fontWeight: 700,
405
+ lineHeight: '16px',
406
+ whiteSpace: 'nowrap',
407
+ }}
408
+ title="Drifted later than the baseline"
409
+ >
410
+ +{workingMinutesToShortLabel(data.start_variance ?? 0)}
411
+ </span>
412
+ )}
413
+ {isAhead && (
414
+ <span
415
+ style={{
416
+ padding: '0 6px',
417
+ background: '#bbf7d0',
418
+ color: '#14532d',
419
+ borderRadius: 3,
420
+ fontSize: 10,
421
+ fontWeight: 700,
422
+ lineHeight: '16px',
423
+ whiteSpace: 'nowrap',
424
+ }}
425
+ title="Ahead of the baseline"
426
+ >
427
+ −{workingMinutesToShortLabel(data.start_variance ?? 0)}
428
+ </span>
429
+ )}
430
+ {showSlackIndicator && (
431
+ <span
432
+ style={{
433
+ padding: '0 6px',
434
+ background: '#dbeafe',
435
+ color: '#1e3a8a',
436
+ borderRadius: 3,
437
+ fontSize: 10,
438
+ fontWeight: 600,
439
+ lineHeight: '16px',
440
+ whiteSpace: 'nowrap',
441
+ }}
442
+ title="Total float — how much this task can slip before becoming critical"
443
+ >
444
+ {workingMinutesToShortLabel(totalSlack)} float
445
+ </span>
446
+ )}
447
+ {isLate && (
448
+ <span
449
+ style={{
450
+ padding: '0 6px',
451
+ background: '#fde68a',
452
+ color: '#78350f',
453
+ borderRadius: 3,
454
+ fontSize: 10,
455
+ fontWeight: 700,
456
+ lineHeight: '16px',
457
+ whiteSpace: 'nowrap',
458
+ }}
459
+ title="Negative slack — contract trouble"
460
+ >
461
+ {workingMinutesToShortLabel(data.total_slack ?? 0)} late
462
+ </span>
463
+ )}
464
+ </div>
465
+ );
466
+ };
467
+
468
+ function toSvarTask(
469
+ t: Task,
470
+ baseline: Baseline | undefined,
471
+ calendar: Calendar | undefined,
472
+ ): SvarTaskWithComputed {
473
+ const variance =
474
+ baseline && calendar ? getTaskBaselineVariance(t, baseline, calendar) : undefined;
475
+ const startVariance = variance?.startVariance ?? 0;
476
+
477
+ const base: SvarTaskWithComputed = {
478
+ id: t.id,
479
+ text: t.text,
480
+ start: t.start,
481
+ end: t.end,
482
+ duration: t.duration,
483
+ progress: t.progress,
484
+ type: t.type,
485
+ parent: t.parent,
486
+ is_critical: t.computed?.isCritical ?? false,
487
+ is_late: (t.computed?.totalSlack ?? 0) < 0,
488
+ total_slack: t.computed?.totalSlack ?? 0,
489
+ start_variance: startVariance,
490
+ is_slipped: startVariance >= 30,
491
+ is_ahead: startVariance <= -30,
492
+ };
493
+ // `open` only meaningful on summary tasks. Setting it on leaves trips
494
+ // SVAR's child-iteration path (null forEach).
495
+ if (t.type === 'summary') base.open = t.open ?? true;
496
+ return base;
497
+ }
498
+
499
+ function makeBaselinePhantom(t: Task, baseline: Baseline): SvarTaskWithComputed | null {
500
+ const snap = baseline.tasks.get(t.id);
501
+ if (!snap) return null;
502
+ return {
503
+ id: `${t.id}-baseline-${baseline.index}`,
504
+ text: '(baseline)',
505
+ start: snap.start,
506
+ end: snap.end,
507
+ duration: snap.duration,
508
+ progress: 0,
509
+ type: 'task',
510
+ parent: t.parent,
511
+ is_baseline_ghost: true,
512
+ };
513
+ }
514
+
515
+ function toSvarLink(l: Link): ILink {
516
+ return {
517
+ id: l.id,
518
+ source: l.source,
519
+ target: l.target,
520
+ type: dependencyTypeToSvar(l.type),
521
+ lag: l.lag,
522
+ };
523
+ }
524
+
525
+ function dependencyTypeToSvar(t: DependencyType): ILink['type'] {
526
+ switch (t) {
527
+ case 'FS':
528
+ return 'e2s';
529
+ case 'SS':
530
+ return 's2s';
531
+ case 'FF':
532
+ return 'e2e';
533
+ case 'SF':
534
+ return 's2e';
535
+ }
536
+ }
537
+
538
+ function getProjectEnd(p: Project): Date {
539
+ if (p.end) return p.end;
540
+ let latestMs = Number.NEGATIVE_INFINITY;
541
+ for (const t of p.tasks) {
542
+ if (t.end.getTime() > latestMs) latestMs = t.end.getTime();
543
+ }
544
+ // Pad by one cell so the last bar isn't clipped to the right edge.
545
+ const cushion = 24 * 60 * 60 * 1000; // 1 day
546
+ return Number.isFinite(latestMs) ? new Date(latestMs + cushion) : new Date(p.start);
547
+ }
548
+
549
+ function resolveMarkers(
550
+ userMarkers: GanttMarker[] | undefined,
551
+ projectStart: Date,
552
+ projectEnd: Date,
553
+ ): SvarMarker[] {
554
+ if (userMarkers) return userMarkers.map(toSvarMarker);
555
+ // Default: today line, only if today falls within the project window.
556
+ const today = new Date();
557
+ if (today >= projectStart && today <= projectEnd) {
558
+ return [{ start: today, text: 'Today', css: 'construction-gantt-marker-today' }];
559
+ }
560
+ return [];
561
+ }
562
+
563
+ function toSvarMarker(m: GanttMarker): SvarMarker {
564
+ const css =
565
+ m.css ??
566
+ (m.variant === 'milestone'
567
+ ? 'construction-gantt-marker-milestone'
568
+ : m.variant === 'today'
569
+ ? 'construction-gantt-marker-today'
570
+ : undefined);
571
+ return { start: m.start, text: m.text, css };
572
+ }
573
+
574
+ /**
575
+ * Convert a public GanttColumn to SVAR's IColumnConfig.
576
+ *
577
+ * render takes priority over field. When only field is set we emit a default
578
+ * cell that formats the value as a string (Date → ISO date, undefined → "").
579
+ * We cast row to our Task type directly — the relevant fields (id, text,
580
+ * start, end, duration, progress, type, parent, computed, constraint)
581
+ * all overlap. SVAR's internal $x/$y/$w computed fields are never passed
582
+ * through to the consumer's render prop.
583
+ */
584
+ function toSvarColumn(c: GanttColumn): IColumnConfig {
585
+ let cell: IColumnConfig['cell'] | undefined;
586
+
587
+ if (c.render) {
588
+ const Render = c.render;
589
+ cell = (props: { row: unknown }) => <Render task={props.row as Task} />;
590
+ } else if (c.field) {
591
+ const field = c.field;
592
+ cell = (props: { row: unknown }) => {
593
+ const task = props.row as Task;
594
+ const value = task[field as keyof Task];
595
+ if (value === undefined || value === null) return <span />;
596
+ if (value instanceof Date) return <span>{value.toISOString().slice(0, 10)}</span>;
597
+ return <span>{String(value)}</span>;
598
+ };
599
+ }
600
+
601
+ const config: IColumnConfig = {
602
+ id: c.id,
603
+ header: c.header,
604
+ ...(c.width !== undefined ? { width: c.width } : {}),
605
+ ...(c.align !== undefined ? { align: c.align } : {}),
606
+ ...(cell !== undefined ? { cell } : {}),
607
+ };
608
+ return config;
609
+ }
610
+
611
+ function buildHighlightTime(
612
+ calendar: Calendar | undefined,
613
+ ): ((date: Date, unit: 'day' | 'hour') => string) | undefined {
614
+ if (!calendar) return undefined;
615
+ return (date, unit) => {
616
+ if (unit !== 'day') return '';
617
+ if (!isWorkingDay(date, calendar)) return 'construction-gantt-non-working';
618
+ return '';
619
+ };
620
+ }
621
+
622
+ function workingMinutesToShortLabel(minutes: number): string {
623
+ const abs = Math.abs(minutes);
624
+ if (abs >= 540) {
625
+ // Approximate working-days from 9h-per-day. Display is "best-effort"
626
+ // since real durations depend on each task's calendar — good enough
627
+ // for an in-bar pill.
628
+ const days = Math.round(abs / 540);
629
+ return `${days}d`;
630
+ }
631
+ if (abs >= 60) {
632
+ const hours = Math.round(abs / 60);
633
+ return `${hours}h`;
634
+ }
635
+ return `${Math.round(abs)}m`;
636
+ }