construction-gantt 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Gantt.tsx CHANGED
@@ -14,8 +14,32 @@ import {
14
14
  } from '@svar-ui/react-gantt';
15
15
  import '@svar-ui/react-gantt/style.css';
16
16
  import './Gantt.css';
17
- import { type FC, forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
17
+ import {
18
+ type FC,
19
+ forwardRef,
20
+ useCallback,
21
+ useEffect,
22
+ useImperativeHandle,
23
+ useMemo,
24
+ useRef,
25
+ useState,
26
+ } from 'react';
18
27
  import { getTaskBaselineVariance } from './baseline';
28
+ import {
29
+ cancelDrag,
30
+ DRAG_INITIAL,
31
+ type DragLinkState,
32
+ isDragInvalid,
33
+ moveDrag,
34
+ startDrag,
35
+ } from './editing/dragLink.js';
36
+ import {
37
+ type EditableField,
38
+ type EditState,
39
+ type TaskEditPatch,
40
+ useEditState,
41
+ } from './editing/useEditState.js';
42
+ import { usePreviewEngine } from './editing/usePreviewEngine.js';
19
43
  import type {
20
44
  GanttHandle,
21
45
  PdfExportOptions,
@@ -29,6 +53,7 @@ import type {
29
53
  Calendar,
30
54
  DependencyType,
31
55
  Link,
56
+ LinkId,
32
57
  Project,
33
58
  Task,
34
59
  TaskId,
@@ -98,6 +123,26 @@ export interface GanttProps {
98
123
  */
99
124
  markers?: GanttMarker[];
100
125
  /**
126
+ * Baselines to overlay as phantom ghost rows beneath each live task.
127
+ *
128
+ * - Single-baseline mode (length 1): the live bar carries the variance pill,
129
+ * matching the existing single-baseline behaviour.
130
+ * - Multi-baseline mode (length > 1): each phantom row carries its own
131
+ * variance pill against the live task; the live bar has no pill.
132
+ * - Indices not present on `project.baselines` are silently skipped (no
133
+ * throw) so consumers can pass a fixed shape regardless of how many
134
+ * baselines a particular project has captured.
135
+ *
136
+ * Phantom rows render in array order — consumers wanting chronological
137
+ * order should sort by `baseline.capturedAt` before passing.
138
+ */
139
+ baselineIndices?: ReadonlyArray<BaselineIndex>;
140
+ /**
141
+ * @deprecated Use `baselineIndices: [N]`. Single-index convenience prop
142
+ * kept as a no-friction alias for v0.x consumers. Removed at v1.0.
143
+ * If both `baselineIndex` and `baselineIndices` are set, `baselineIndices`
144
+ * takes precedence.
145
+ *
101
146
  * Show variance against this baseline index. If unset (or no matching
102
147
  * baseline exists on `project.baselines`), bars render without variance
103
148
  * pills. Construction-vertical use case (ADR-003): comparing the live
@@ -108,8 +153,9 @@ export interface GanttProps {
108
153
  /**
109
154
  * Render the baseline as a separate "ghost" bar beneath each live task.
110
155
  * 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.
156
+ * when reviewing variation claims. Default true when either
157
+ * `baselineIndex` or `baselineIndices` is set; pass `false` to keep
158
+ * variance shown only as in-bar pills.
113
159
  */
114
160
  showBaselineBars?: boolean;
115
161
  /**
@@ -141,8 +187,21 @@ export interface GanttProps {
141
187
  * `visibility.ts` for the contract test.
142
188
  */
143
189
  visibleTaskIds?: ReadonlySet<TaskId>;
190
+ // --- v0.4 editing ---
191
+ editMode?: boolean;
192
+ onTaskEdit?: (id: TaskId, patch: TaskEditPatch) => void;
193
+ onLinkCreate?: (source: TaskId, target: TaskId, type: DependencyType) => void;
194
+ onLinkDelete?: (linkId: LinkId) => void;
144
195
  }
145
196
 
197
+ const DEFAULT_EDIT_COLUMNS: GanttColumn[] = [
198
+ { id: 'text', header: 'Task Name', field: 'text', width: 220 },
199
+ { id: 'start', header: 'Start', field: 'start', width: 100, align: 'center' },
200
+ { id: 'end', header: 'Finish', field: 'end', width: 100, align: 'center' },
201
+ { id: 'duration', header: 'Days', field: 'duration', width: 60, align: 'right' },
202
+ { id: 'progress', header: '%', field: 'progress', width: 50, align: 'right' },
203
+ ];
204
+
146
205
  interface SvarTaskWithComputed extends ITask {
147
206
  is_critical?: boolean;
148
207
  is_late?: boolean;
@@ -155,6 +214,10 @@ interface SvarTaskWithComputed extends ITask {
155
214
  is_ahead?: boolean;
156
215
  /** True for phantom rows representing a baseline snapshot's position. */
157
216
  is_baseline_ghost?: boolean;
217
+ /** When set, identifies which baseline (0..10) this phantom row mirrors. */
218
+ baseline_index?: BaselineIndex;
219
+ /** True for phantom rows representing a live recalc preview position. */
220
+ is_edit_preview?: boolean;
158
221
  }
159
222
 
160
223
  interface SvarMarker {
@@ -163,6 +226,58 @@ interface SvarMarker {
163
226
  css?: string;
164
227
  }
165
228
 
229
+ function useDragLink() {
230
+ const [dragState, setDragState] = useState<DragLinkState>(DRAG_INITIAL);
231
+ const dragRef = useRef(dragState);
232
+ dragRef.current = dragState;
233
+
234
+ const onBarMouseDown = useCallback((sourceId: TaskId, e: React.MouseEvent) => {
235
+ e.preventDefault();
236
+ setDragState(startDrag(sourceId, e.clientX, e.clientY));
237
+ }, []);
238
+
239
+ const onMouseMove = useCallback((e: MouseEvent) => {
240
+ setDragState((s) => moveDrag(s, e.clientX, e.clientY));
241
+ }, []);
242
+
243
+ const onMouseUp = useCallback(
244
+ (
245
+ e: MouseEvent,
246
+ project: Project,
247
+ onLinkCreate: ((s: TaskId, t: TaskId, type: DependencyType) => void) | undefined,
248
+ ) => {
249
+ const current = dragRef.current;
250
+ if (current.status !== 'dragging') return;
251
+ const el = document.elementFromPoint(e.clientX, e.clientY);
252
+ const barEl = el?.closest('[data-task-id]');
253
+ const targetAttr = barEl?.getAttribute('data-task-id') ?? null;
254
+ // Resolve to a real task — rejects phantom row ids (e.g. "t1__baseline_0")
255
+ // and preserves the original typed TaskId (fixes numeric id loss via DOM attribute).
256
+ const targetTask = targetAttr
257
+ ? project.tasks.find((t) => String(t.id) === targetAttr)
258
+ : undefined;
259
+ if (!targetTask) {
260
+ setDragState(cancelDrag(current));
261
+ return;
262
+ }
263
+ const summaryIds = new Set<TaskId>(
264
+ project.tasks.filter((t) => t.type === 'summary').map((t) => t.id),
265
+ );
266
+ if (isDragInvalid(current.sourceId, targetTask.id, project.links, summaryIds)) {
267
+ setDragState(cancelDrag(current));
268
+ return;
269
+ }
270
+ onLinkCreate?.(current.sourceId, targetTask.id, 'FS');
271
+ setDragState(DRAG_INITIAL);
272
+ },
273
+ [],
274
+ );
275
+
276
+ const cancelDragLink = useCallback(() => setDragState(DRAG_INITIAL), []);
277
+
278
+ return { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink };
279
+ }
280
+
166
281
  export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
167
282
  {
168
283
  project,
@@ -172,14 +287,24 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
172
287
  preScheduled = false,
173
288
  markers,
174
289
  baselineIndex,
290
+ baselineIndices,
175
291
  showBaselineBars,
176
292
  columns,
177
293
  visibleTaskIds,
294
+ editMode = false,
295
+ onTaskEdit,
296
+ onLinkCreate,
297
+ onLinkDelete,
178
298
  },
179
299
  ref,
180
300
  ) {
181
301
  const containerRef = useRef<HTMLDivElement>(null);
182
302
 
303
+ const effectiveBaselineIndices = useMemo<ReadonlyArray<BaselineIndex>>(
304
+ () => resolveEffectiveBaselineIndices(baselineIndices, baselineIndex),
305
+ [baselineIndices, baselineIndex],
306
+ );
307
+
183
308
  const scheduled = useMemo(
184
309
  () => (preScheduled ? project : schedule(project)),
185
310
  [project, preScheduled],
@@ -197,29 +322,53 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
197
322
  [scheduled.calendars, scheduled.defaultCalendarId],
198
323
  );
199
324
 
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]);
325
+ const editState = useEditState();
326
+ const editStateRef = useRef<typeof editState>(editState);
327
+ editStateRef.current = editState;
328
+ const onTaskEditRef = useRef(onTaskEdit);
329
+ onTaskEditRef.current = onTaskEdit;
330
+ const editGhostProject = usePreviewEngine(scheduled, editState.activeCell, editState.dirtyValue);
204
331
 
205
- const ghostBarsEnabled = baseline !== undefined && (showBaselineBars ?? true);
332
+ const { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink } = useDragLink();
206
333
 
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]);
334
+ useEffect(() => {
335
+ if (!editMode || dragState.status !== 'dragging') return;
336
+ const handleMove = (e: MouseEvent) => onMouseMove(e);
337
+ const handleUp = (e: MouseEvent) => onMouseUp(e, scheduled, onLinkCreate);
338
+ const handleEsc = (e: KeyboardEvent) => {
339
+ if (e.key === 'Escape') cancelDragLink();
340
+ };
341
+ window.addEventListener('mousemove', handleMove);
342
+ window.addEventListener('mouseup', handleUp);
343
+ window.addEventListener('keydown', handleEsc);
344
+ return () => {
345
+ window.removeEventListener('mousemove', handleMove);
346
+ window.removeEventListener('mouseup', handleUp);
347
+ window.removeEventListener('keydown', handleEsc);
348
+ };
349
+ }, [editMode, dragState.status, onMouseMove, onMouseUp, cancelDragLink, scheduled, onLinkCreate]);
350
+
351
+ // Resolve effective indices → actual Baseline records, dropping any that
352
+ // don't exist on the project. Preserves caller order so phantom rows
353
+ // render in the array order the consumer passed.
354
+ const resolvedBaselines = useMemo<Baseline[]>(
355
+ () => resolveBaselines(scheduled.baselines, effectiveBaselineIndices),
356
+ [scheduled.baselines, effectiveBaselineIndices],
357
+ );
358
+
359
+ const ghostBarsEnabled = resolvedBaselines.length > 0 && (showBaselineBars ?? true);
360
+
361
+ const svarTasks: ITask[] = useMemo(
362
+ () =>
363
+ buildSvarTasks(
364
+ renderableTasks,
365
+ resolvedBaselines,
366
+ calendar,
367
+ ghostBarsEnabled,
368
+ editGhostProject ?? undefined,
369
+ ),
370
+ [renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject],
371
+ );
223
372
  const svarLinks: ILink[] = useMemo(() => scheduled.links.map(toSvarLink), [scheduled.links]);
224
373
 
225
374
  const projectEnd = useMemo(() => getProjectEnd(scheduled), [scheduled]);
@@ -237,11 +386,51 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
237
386
  // undefined → don't pass columns to SVAR (use SVAR defaults).
238
387
  // [] → pass false to SVAR (hide grid entirely).
239
388
  // [...] → convert each column.
389
+ // In editMode, inject interactive cell renderers for known editable fields.
390
+ // biome-ignore lint/correctness/useExhaustiveDependencies: editState.activeCell is intentionally used as a dep — editStateRef/onTaskEditRef are accessed at event time via refs, so only activeCell (which changes cell-to-cell) drives column recompute
240
391
  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]);
392
+ if (!editMode) {
393
+ if (columns === undefined) return undefined;
394
+ if (columns.length === 0) return false;
395
+ return columns.map(toSvarColumn);
396
+ }
397
+ const effectiveCols = columns ?? DEFAULT_EDIT_COLUMNS;
398
+ if (effectiveCols.length === 0) return false;
399
+ return effectiveCols.map((col) => {
400
+ if (col.render || !col.field || !EDITABLE_FIELDS.has(col.field as string)) {
401
+ return toSvarColumn(col);
402
+ }
403
+ const base = toSvarColumn(col);
404
+ return {
405
+ ...base,
406
+ cell: buildEditableCell(col.field as EditableField, editStateRef, onTaskEditRef),
407
+ };
408
+ });
409
+ }, [columns, editMode, editState.activeCell]);
410
+
411
+ const taskTemplate = useMemo(() => {
412
+ if (!editMode) return ConstructionBar as FC<{ data: ITask }>;
413
+ // Wrap ConstructionBar with a drag handle at the right edge of each task bar.
414
+ const EditableBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => (
415
+ <div
416
+ data-task-id={data.id !== undefined ? String(data.id) : undefined}
417
+ style={{ position: 'relative', width: '100%', height: '100%' }}
418
+ >
419
+ <ConstructionBar data={data} />
420
+ {!data.is_baseline_ghost && !data.is_edit_preview && (
421
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag handle — pointer-down initiates link drag; keyboard alternative (Escape) handled at window level
422
+ <div
423
+ className="construction-gantt-drag-handle"
424
+ onMouseDown={(e) => {
425
+ if (data.id !== undefined) onBarMouseDown(data.id, e);
426
+ }}
427
+ title="Drag to create link"
428
+ />
429
+ )}
430
+ </div>
431
+ );
432
+ return EditableBar as FC<{ data: ITask }>;
433
+ }, [editMode, onBarMouseDown]);
245
434
 
246
435
  useImperativeHandle(
247
436
  ref,
@@ -255,6 +444,7 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
255
444
  cellHeight,
256
445
  markers,
257
446
  baselineIndex,
447
+ baselineIndices,
258
448
  showBaselineBars,
259
449
  columns,
260
450
  height,
@@ -272,6 +462,7 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
272
462
  cellHeight,
273
463
  markers,
274
464
  baselineIndex,
465
+ baselineIndices,
275
466
  showBaselineBars,
276
467
  columns,
277
468
  height,
@@ -291,6 +482,7 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
291
482
  cellHeight,
292
483
  markers,
293
484
  baselineIndex,
485
+ baselineIndices,
294
486
  showBaselineBars,
295
487
  columns,
296
488
  height,
@@ -299,7 +491,25 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
299
491
  );
300
492
 
301
493
  return (
302
- <div ref={containerRef} style={{ height }}>
494
+ // biome-ignore lint/a11y/noStaticElementInteractions: gantt container — link-delete click is an optional editing affordance, not a primary interaction target
495
+ // biome-ignore lint/a11y/useKeyWithClickEvents: keyboard alternative (Delete key) is out of scope for v0.4; Escape already handled in drag listener
496
+ <div
497
+ ref={containerRef}
498
+ style={{ position: 'relative', height }}
499
+ onClick={
500
+ editMode && onLinkDelete
501
+ ? (e) => {
502
+ // SVAR-internal: dependency arrows render as <polyline class="wx-line ..."> with
503
+ // data-link-id holding the link id. Re-verify on SVAR upgrades by searching
504
+ // node_modules/@svar-ui/react-gantt/dist/index.es.js for "wx-line" + "data-link-id".
505
+ const el = (e.target as Element).closest('.wx-line');
506
+ if (!el) return;
507
+ const linkId = el.getAttribute('data-link-id');
508
+ if (linkId) onLinkDelete(linkId);
509
+ }
510
+ : undefined
511
+ }
512
+ >
303
513
  <SvarGantt
304
514
  tasks={svarTasks}
305
515
  links={svarLinks}
@@ -309,35 +519,85 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
309
519
  cellHeight={cellHeight}
310
520
  markers={svarMarkers}
311
521
  highlightTime={highlightTime}
312
- taskTemplate={ConstructionBar}
522
+ taskTemplate={taskTemplate}
313
523
  {...(svarColumns !== undefined ? { columns: svarColumns as IColumnConfig[] } : {})}
314
524
  />
525
+ {editMode && dragState.status === 'dragging' && (
526
+ <svg
527
+ aria-hidden="true"
528
+ style={{
529
+ position: 'fixed',
530
+ inset: 0,
531
+ width: '100vw',
532
+ height: '100vh',
533
+ pointerEvents: 'none',
534
+ zIndex: 9999,
535
+ }}
536
+ >
537
+ <line
538
+ x1={dragState.startX}
539
+ y1={dragState.startY}
540
+ x2={dragState.cursorX}
541
+ y2={dragState.cursorY}
542
+ stroke="rgba(59,130,246,0.8)"
543
+ strokeWidth={2}
544
+ strokeDasharray="6 3"
545
+ />
546
+ </svg>
547
+ )}
315
548
  </div>
316
549
  );
317
550
  });
318
551
 
319
- const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
552
+ export const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
553
+ if (data.is_edit_preview) {
554
+ return <div className="bode-edit-preview" />;
555
+ }
320
556
  // Phantom baseline row — render a slim outlined ghost bar.
321
557
  if (data.is_baseline_ghost) {
558
+ const baselineIdx = data.baseline_index ?? 0;
559
+ const phantomSlipped = data.is_slipped ?? false;
560
+ const phantomAhead = data.is_ahead ?? false;
322
561
  return (
323
562
  <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
- }}
563
+ className={`bode-baseline-ghost bode-baseline-${baselineIdx}`}
338
564
  title="Baseline position — where this task was when the baseline was captured"
339
565
  >
340
- baseline
566
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{data.text}</span>
567
+ {phantomSlipped && (
568
+ <span
569
+ style={{
570
+ padding: '0 6px',
571
+ background: '#fed7aa',
572
+ color: '#7c2d12',
573
+ borderRadius: 3,
574
+ fontSize: 10,
575
+ fontWeight: 700,
576
+ lineHeight: '16px',
577
+ whiteSpace: 'nowrap',
578
+ }}
579
+ title="Drifted later than the baseline"
580
+ >
581
+ +{workingMinutesToShortLabel(data.start_variance ?? 0)}
582
+ </span>
583
+ )}
584
+ {phantomAhead && (
585
+ <span
586
+ style={{
587
+ padding: '0 6px',
588
+ background: '#bbf7d0',
589
+ color: '#14532d',
590
+ borderRadius: 3,
591
+ fontSize: 10,
592
+ fontWeight: 700,
593
+ lineHeight: '16px',
594
+ whiteSpace: 'nowrap',
595
+ }}
596
+ title="Ahead of the baseline"
597
+ >
598
+ −{workingMinutesToShortLabel(data.start_variance ?? 0)}
599
+ </span>
600
+ )}
341
601
  </div>
342
602
  );
343
603
  }
@@ -496,12 +756,18 @@ function toSvarTask(
496
756
  return base;
497
757
  }
498
758
 
499
- function makeBaselinePhantom(t: Task, baseline: Baseline): SvarTaskWithComputed | null {
759
+ function makeBaselinePhantom(
760
+ t: Task,
761
+ baseline: Baseline,
762
+ calendar: Calendar | undefined,
763
+ ): SvarTaskWithComputed | null {
500
764
  const snap = baseline.tasks.get(t.id);
501
765
  if (!snap) return null;
766
+ const variance = calendar ? getTaskBaselineVariance(t, baseline, calendar) : undefined;
767
+ const startVariance = variance?.startVariance ?? 0;
502
768
  return {
503
- id: `${t.id}-baseline-${baseline.index}`,
504
- text: '(baseline)',
769
+ id: `${t.id}__baseline_${baseline.index}`,
770
+ text: formatBaselineLabel(baseline),
505
771
  start: snap.start,
506
772
  end: snap.end,
507
773
  duration: snap.duration,
@@ -509,6 +775,25 @@ function makeBaselinePhantom(t: Task, baseline: Baseline): SvarTaskWithComputed
509
775
  type: 'task',
510
776
  parent: t.parent,
511
777
  is_baseline_ghost: true,
778
+ baseline_index: baseline.index,
779
+ start_variance: startVariance,
780
+ is_slipped: startVariance >= 30,
781
+ is_ahead: startVariance <= -30,
782
+ };
783
+ }
784
+
785
+ function makeEditPreviewPhantom(liveTask: Task, ghostTask: Task): SvarTaskWithComputed {
786
+ return {
787
+ id: `${liveTask.id}__edit_preview`,
788
+ text: 'Preview',
789
+ start: ghostTask.start,
790
+ end: ghostTask.end,
791
+ duration: ghostTask.duration,
792
+ progress: 0,
793
+ type: 'task',
794
+ parent: liveTask.parent,
795
+ is_baseline_ghost: true,
796
+ is_edit_preview: true,
512
797
  };
513
798
  }
514
799
 
@@ -535,6 +820,30 @@ function dependencyTypeToSvar(t: DependencyType): ILink['type'] {
535
820
  }
536
821
  }
537
822
 
823
+ /**
824
+ * Format a baseline's metadata for display in the phantom row's label.
825
+ * Returns "${name ?? `Baseline ${index}`} — captured ${formatShortDate(capturedAt)}".
826
+ *
827
+ * Exported for testing. Not part of the public surface.
828
+ */
829
+ export function formatBaselineLabel(baseline: Baseline): string {
830
+ const name = baseline.name ?? `Baseline ${baseline.index}`;
831
+ return `${name} — captured ${formatShortDate(baseline.capturedAt)}`;
832
+ }
833
+
834
+ /**
835
+ * Format a Date as YYYY-MM-DD using local-time components. Deterministic
836
+ * across locales (no Intl.DateTimeFormat — those vary by host locale).
837
+ *
838
+ * Exported for testing. Not part of the public surface.
839
+ */
840
+ export function formatShortDate(d: Date): string {
841
+ const year = d.getFullYear();
842
+ const month = String(d.getMonth() + 1).padStart(2, '0');
843
+ const day = String(d.getDate()).padStart(2, '0');
844
+ return `${year}-${month}-${day}`;
845
+ }
846
+
538
847
  function getProjectEnd(p: Project): Date {
539
848
  if (p.end) return p.end;
540
849
  let latestMs = Number.NEGATIVE_INFINITY;
@@ -546,6 +855,108 @@ function getProjectEnd(p: Project): Date {
546
855
  return Number.isFinite(latestMs) ? new Date(latestMs + cushion) : new Date(p.start);
547
856
  }
548
857
 
858
+ /**
859
+ * Resolve the effective baseline indices from the two GanttProps inputs.
860
+ *
861
+ * - `baselineIndices` takes precedence when set (including when empty —
862
+ * passing `[]` is an explicit opt-out signal).
863
+ * - When `baselineIndices` is undefined, fall back to wrapping `baselineIndex`
864
+ * in a single-element array.
865
+ * - When both are undefined, return an empty array.
866
+ *
867
+ * Exported for testing. Not part of the public surface; consumers don't
868
+ * call this directly.
869
+ */
870
+ export function resolveEffectiveBaselineIndices(
871
+ baselineIndices: ReadonlyArray<BaselineIndex> | undefined,
872
+ baselineIndex: BaselineIndex | undefined,
873
+ ): ReadonlyArray<BaselineIndex> {
874
+ return baselineIndices ?? (baselineIndex !== undefined ? [baselineIndex] : []);
875
+ }
876
+
877
+ /**
878
+ * Map effective baseline indices to actual Baseline records on a project,
879
+ * preserving caller order. Indices not present on the project are silently
880
+ * dropped (per spec — consumers can pass a fixed shape across projects
881
+ * with varying baseline counts).
882
+ *
883
+ * Exported for testing. Not part of the public surface.
884
+ */
885
+ export function resolveBaselines(
886
+ allBaselines: Baseline[],
887
+ effectiveIndices: ReadonlyArray<BaselineIndex>,
888
+ ): Baseline[] {
889
+ if (effectiveIndices.length === 0) return [];
890
+ const byIndex = new Map(allBaselines.map((b) => [b.index, b]));
891
+ const out: Baseline[] = [];
892
+ for (const idx of effectiveIndices) {
893
+ const b = byIndex.get(idx);
894
+ if (b) out.push(b);
895
+ }
896
+ return out;
897
+ }
898
+
899
+ /**
900
+ * Convert the rendered tasks + resolved baselines into the SVAR ITask[]
901
+ * shape, including phantom ghost rows for each (task × baseline) pair
902
+ * when ghost bars are enabled.
903
+ *
904
+ * In single-baseline mode (resolvedBaselines.length === 1), the live row
905
+ * carries the variance pill (`start_variance` / `is_slipped` / `is_ahead`
906
+ * are populated relative to that one baseline). In multi-baseline mode
907
+ * (length > 1), the live row gets no variance fields (would require a
908
+ * "primary" baseline concept that the spec rejects); each phantom row
909
+ * gets its own.
910
+ *
911
+ * Exported for testing. Not part of the public surface.
912
+ */
913
+ export function buildSvarTasks(
914
+ renderableTasks: Task[],
915
+ resolvedBaselines: Baseline[],
916
+ calendar: Calendar | undefined,
917
+ ghostBarsEnabled: boolean,
918
+ editGhostProject?: Project,
919
+ ): SvarTaskWithComputed[] {
920
+ const ghostById = new Map<TaskId, Task>(editGhostProject?.tasks.map((t) => [t.id, t]) ?? []);
921
+
922
+ if (!ghostBarsEnabled || resolvedBaselines.length === 0) {
923
+ const primary = resolvedBaselines[0];
924
+ const out = renderableTasks.map((t) => toSvarTask(t, primary, calendar));
925
+ if (ghostById.size > 0) {
926
+ for (const t of renderableTasks) {
927
+ if (t.type === 'summary') continue;
928
+ const ghost = ghostById.get(t.id);
929
+ if (
930
+ ghost &&
931
+ (ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())
932
+ ) {
933
+ out.push(makeEditPreviewPhantom(t, ghost));
934
+ }
935
+ }
936
+ }
937
+ return out;
938
+ }
939
+ const out: SvarTaskWithComputed[] = [];
940
+ for (const t of renderableTasks) {
941
+ const liveBarBaseline = resolvedBaselines.length === 1 ? resolvedBaselines[0] : undefined;
942
+ out.push(toSvarTask(t, liveBarBaseline, calendar));
943
+ if (t.type === 'summary') continue;
944
+ for (const b of resolvedBaselines) {
945
+ const phantom = makeBaselinePhantom(t, b, calendar);
946
+ if (phantom) out.push(phantom);
947
+ }
948
+ // Edit preview phantom goes last (renders below baseline phantoms).
949
+ const ghost = ghostById.get(t.id);
950
+ if (
951
+ ghost &&
952
+ (ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())
953
+ ) {
954
+ out.push(makeEditPreviewPhantom(t, ghost));
955
+ }
956
+ }
957
+ return out;
958
+ }
959
+
549
960
  function resolveMarkers(
550
961
  userMarkers: GanttMarker[] | undefined,
551
962
  projectStart: Date,
@@ -608,6 +1019,110 @@ function toSvarColumn(c: GanttColumn): IColumnConfig {
608
1019
  return config;
609
1020
  }
610
1021
 
1022
+ function getInputType(field: EditableField): string {
1023
+ if (field === 'start' || field === 'end') return 'date';
1024
+ if (field === 'duration' || field === 'progress') return 'number';
1025
+ return 'text';
1026
+ }
1027
+
1028
+ function getInputValue(task: Task, field: EditableField): string {
1029
+ switch (field) {
1030
+ case 'text':
1031
+ return task.text;
1032
+ case 'start':
1033
+ return formatShortDate(task.start);
1034
+ case 'end':
1035
+ return formatShortDate(task.end);
1036
+ case 'duration':
1037
+ return String(Math.round(task.duration / 60 / 8));
1038
+ case 'progress':
1039
+ return String(task.progress);
1040
+ case 'scheduleMode':
1041
+ return task.scheduleMode;
1042
+ }
1043
+ }
1044
+
1045
+ const EDITABLE_FIELDS = new Set<string>([
1046
+ 'text',
1047
+ 'start',
1048
+ 'end',
1049
+ 'duration',
1050
+ 'progress',
1051
+ 'scheduleMode',
1052
+ ]);
1053
+
1054
+ function buildEditableCell(
1055
+ field: EditableField,
1056
+ editStateRef: { readonly current: EditState },
1057
+ onTaskEditRef: { readonly current: GanttProps['onTaskEdit'] },
1058
+ ): IColumnConfig['cell'] {
1059
+ return ({ row }: { row: unknown }) => {
1060
+ const editState = editStateRef.current;
1061
+ const task = row as SvarTaskWithComputed;
1062
+ if (task.is_baseline_ghost || (task as { is_edit_preview?: boolean }).is_edit_preview) {
1063
+ return <span />;
1064
+ }
1065
+ const isReadOnly =
1066
+ task.type === 'summary' && (field === 'start' || field === 'end' || field === 'duration');
1067
+
1068
+ const isActive =
1069
+ editState.activeCell?.taskId === task.id && editState.activeCell?.field === field;
1070
+
1071
+ if (isActive) {
1072
+ return (
1073
+ <input
1074
+ // biome-ignore lint/a11y/noAutofocus: intentional — cell was clicked
1075
+ autoFocus
1076
+ key={`${editState.activeCell?.taskId}-${field}`}
1077
+ type={getInputType(field)}
1078
+ defaultValue={editState.dirtyValue}
1079
+ style={{ width: '100%', boxSizing: 'border-box' }}
1080
+ onChange={(e) => editStateRef.current.setValue(e.target.value)}
1081
+ onBlur={() => editStateRef.current.commitCell(onTaskEditRef.current)}
1082
+ onKeyDown={(e) => {
1083
+ if (e.key === 'Enter' || e.key === 'Tab') {
1084
+ e.preventDefault();
1085
+ editStateRef.current.commitCell(onTaskEditRef.current);
1086
+ } else if (e.key === 'Escape') {
1087
+ editStateRef.current.cancelCell();
1088
+ }
1089
+ }}
1090
+ />
1091
+ );
1092
+ }
1093
+
1094
+ const displayValue = (() => {
1095
+ if (field === 'start' || field === 'end') {
1096
+ const d = task[field as 'start' | 'end'];
1097
+ return d instanceof Date ? formatShortDate(d) : '';
1098
+ }
1099
+ if (field === 'duration') return String(Math.round((task.duration ?? 0) / 60 / 8));
1100
+ if (field === 'progress') return String(task.progress ?? 0);
1101
+ return String((task as Record<string, unknown>)[field] ?? '');
1102
+ })();
1103
+
1104
+ if (isReadOnly || task.id === undefined) {
1105
+ return <span>{displayValue}</span>;
1106
+ }
1107
+
1108
+ const taskId = task.id as TaskId;
1109
+ const activate = () =>
1110
+ editStateRef.current.activateCell(
1111
+ taskId,
1112
+ field,
1113
+ getInputValue(task as unknown as Task, field),
1114
+ );
1115
+
1116
+ return (
1117
+ // biome-ignore lint/a11y/useKeyWithClickEvents: cell activation via keyboard handled by the input that renders on activate
1118
+ // biome-ignore lint/a11y/noStaticElementInteractions: grid cell — role="gridcell" would be on the parent SVAR element
1119
+ <span style={{ cursor: 'text', display: 'block', width: '100%' }} onClick={activate}>
1120
+ {displayValue}
1121
+ </span>
1122
+ );
1123
+ };
1124
+ }
1125
+
611
1126
  function buildHighlightTime(
612
1127
  calendar: Calendar | undefined,
613
1128
  ): ((date: Date, unit: 'day' | 'hour') => string) | undefined {