construction-gantt 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "construction-gantt",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MIT React Gantt for construction project management — PRO-equivalent scheduling engine + construction-vertical extensions on free SVAR React Gantt",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/Gantt.css CHANGED
@@ -60,3 +60,41 @@
60
60
  .bode-baseline-8 { background: #f97316; color: #0f172a; } /* orange */
61
61
  .bode-baseline-9 { background: #a855f7; } /* purple */
62
62
  .bode-baseline-10 { background: #dc2626; } /* red */
63
+
64
+ /* Edit preview ghost bar — shows live CPM cascade preview while editing.
65
+ * Matches baseline ghost sizing (60% height, 15% top margin) so preview
66
+ * phantoms read as secondary/tentative alongside the live bar. */
67
+ .bode-edit-preview {
68
+ height: 60%;
69
+ margin-top: 15%;
70
+ background: repeating-linear-gradient(
71
+ 45deg,
72
+ rgba(59, 130, 246, 0.35) 0px,
73
+ rgba(59, 130, 246, 0.35) 6px,
74
+ rgba(59, 130, 246, 0.15) 6px,
75
+ rgba(59, 130, 246, 0.15) 12px
76
+ );
77
+ border: 1.5px dashed rgba(59, 130, 246, 0.7);
78
+ border-radius: 3px;
79
+ pointer-events: none;
80
+ }
81
+
82
+ /* Drag handle — appears at right edge of task bar in editMode */
83
+ .construction-gantt-drag-handle {
84
+ position: absolute;
85
+ right: -5px;
86
+ top: 50%;
87
+ transform: translateY(-50%);
88
+ width: 10px;
89
+ height: 10px;
90
+ border-radius: 50%;
91
+ background: rgba(59, 130, 246, 0.85);
92
+ cursor: crosshair;
93
+ opacity: 0;
94
+ transition: opacity 0.15s;
95
+ z-index: 10;
96
+ }
97
+
98
+ .wx-task:hover .construction-gantt-drag-handle {
99
+ opacity: 1;
100
+ }
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,
@@ -162,8 +187,21 @@ export interface GanttProps {
162
187
  * `visibility.ts` for the contract test.
163
188
  */
164
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;
165
195
  }
166
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
+
167
205
  interface SvarTaskWithComputed extends ITask {
168
206
  is_critical?: boolean;
169
207
  is_late?: boolean;
@@ -178,6 +216,8 @@ interface SvarTaskWithComputed extends ITask {
178
216
  is_baseline_ghost?: boolean;
179
217
  /** When set, identifies which baseline (0..10) this phantom row mirrors. */
180
218
  baseline_index?: BaselineIndex;
219
+ /** True for phantom rows representing a live recalc preview position. */
220
+ is_edit_preview?: boolean;
181
221
  }
182
222
 
183
223
  interface SvarMarker {
@@ -186,6 +226,58 @@ interface SvarMarker {
186
226
  css?: string;
187
227
  }
188
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
+
189
281
  export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
190
282
  {
191
283
  project,
@@ -199,6 +291,10 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
199
291
  showBaselineBars,
200
292
  columns,
201
293
  visibleTaskIds,
294
+ editMode = false,
295
+ onTaskEdit,
296
+ onLinkCreate,
297
+ onLinkDelete,
202
298
  },
203
299
  ref,
204
300
  ) {
@@ -226,6 +322,32 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
226
322
  [scheduled.calendars, scheduled.defaultCalendarId],
227
323
  );
228
324
 
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);
331
+
332
+ const { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink } = useDragLink();
333
+
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
+
229
351
  // Resolve effective indices → actual Baseline records, dropping any that
230
352
  // don't exist on the project. Preserves caller order so phantom rows
231
353
  // render in the array order the consumer passed.
@@ -237,8 +359,15 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
237
359
  const ghostBarsEnabled = resolvedBaselines.length > 0 && (showBaselineBars ?? true);
238
360
 
239
361
  const svarTasks: ITask[] = useMemo(
240
- () => buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled),
241
- [renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled],
362
+ () =>
363
+ buildSvarTasks(
364
+ renderableTasks,
365
+ resolvedBaselines,
366
+ calendar,
367
+ ghostBarsEnabled,
368
+ editGhostProject ?? undefined,
369
+ ),
370
+ [renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject],
242
371
  );
243
372
  const svarLinks: ILink[] = useMemo(() => scheduled.links.map(toSvarLink), [scheduled.links]);
244
373
 
@@ -257,11 +386,51 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
257
386
  // undefined → don't pass columns to SVAR (use SVAR defaults).
258
387
  // [] → pass false to SVAR (hide grid entirely).
259
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
260
391
  const svarColumns: IColumnConfig[] | false | undefined = useMemo(() => {
261
- if (columns === undefined) return undefined;
262
- if (columns.length === 0) return false;
263
- return columns.map(toSvarColumn);
264
- }, [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]);
265
434
 
266
435
  useImperativeHandle(
267
436
  ref,
@@ -322,7 +491,25 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
322
491
  );
323
492
 
324
493
  return (
325
- <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
+ >
326
513
  <SvarGantt
327
514
  tasks={svarTasks}
328
515
  links={svarLinks}
@@ -332,14 +519,40 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
332
519
  cellHeight={cellHeight}
333
520
  markers={svarMarkers}
334
521
  highlightTime={highlightTime}
335
- taskTemplate={ConstructionBar}
522
+ taskTemplate={taskTemplate}
336
523
  {...(svarColumns !== undefined ? { columns: svarColumns as IColumnConfig[] } : {})}
337
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
+ )}
338
548
  </div>
339
549
  );
340
550
  });
341
551
 
342
552
  export const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
553
+ if (data.is_edit_preview) {
554
+ return <div className="bode-edit-preview" />;
555
+ }
343
556
  // Phantom baseline row — render a slim outlined ghost bar.
344
557
  if (data.is_baseline_ghost) {
345
558
  const baselineIdx = data.baseline_index ?? 0;
@@ -569,6 +782,21 @@ function makeBaselinePhantom(
569
782
  };
570
783
  }
571
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,
797
+ };
798
+ }
799
+
572
800
  function toSvarLink(l: Link): ILink {
573
801
  return {
574
802
  id: l.id,
@@ -687,10 +915,26 @@ export function buildSvarTasks(
687
915
  resolvedBaselines: Baseline[],
688
916
  calendar: Calendar | undefined,
689
917
  ghostBarsEnabled: boolean,
918
+ editGhostProject?: Project,
690
919
  ): SvarTaskWithComputed[] {
920
+ const ghostById = new Map<TaskId, Task>(editGhostProject?.tasks.map((t) => [t.id, t]) ?? []);
921
+
691
922
  if (!ghostBarsEnabled || resolvedBaselines.length === 0) {
692
923
  const primary = resolvedBaselines[0];
693
- return renderableTasks.map((t) => toSvarTask(t, primary, calendar));
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;
694
938
  }
695
939
  const out: SvarTaskWithComputed[] = [];
696
940
  for (const t of renderableTasks) {
@@ -701,6 +945,14 @@ export function buildSvarTasks(
701
945
  const phantom = makeBaselinePhantom(t, b, calendar);
702
946
  if (phantom) out.push(phantom);
703
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
+ }
704
956
  }
705
957
  return out;
706
958
  }
@@ -767,6 +1019,110 @@ function toSvarColumn(c: GanttColumn): IColumnConfig {
767
1019
  return config;
768
1020
  }
769
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
+
770
1126
  function buildHighlightTime(
771
1127
  calendar: Calendar | undefined,
772
1128
  ): ((date: Date, unit: 'day' | 'hour') => string) | undefined {
@@ -0,0 +1,47 @@
1
+ import type { Link, TaskId } from '../types.js';
2
+
3
+ export type DragLinkState =
4
+ | { status: 'idle' }
5
+ | {
6
+ status: 'dragging';
7
+ sourceId: TaskId;
8
+ startX: number;
9
+ startY: number;
10
+ cursorX: number;
11
+ cursorY: number;
12
+ }
13
+ | { status: 'dropped'; sourceId: TaskId; targetId: TaskId };
14
+
15
+ export const DRAG_INITIAL: DragLinkState = { status: 'idle' };
16
+
17
+ export function startDrag(sourceId: TaskId, startX: number, startY: number): DragLinkState {
18
+ return { status: 'dragging', sourceId, startX, startY, cursorX: startX, cursorY: startY };
19
+ }
20
+
21
+ export function moveDrag(state: DragLinkState, cursorX: number, cursorY: number): DragLinkState {
22
+ if (state.status !== 'dragging') return state;
23
+ return { ...state, cursorX, cursorY };
24
+ }
25
+
26
+ export function completeDrop(state: DragLinkState, targetId: TaskId | null): DragLinkState {
27
+ if (state.status !== 'dragging') return DRAG_INITIAL;
28
+ if (targetId === null) return DRAG_INITIAL;
29
+ return { status: 'dropped', sourceId: state.sourceId, targetId };
30
+ }
31
+
32
+ export function cancelDrag(_state: DragLinkState): DragLinkState {
33
+ return DRAG_INITIAL;
34
+ }
35
+
36
+ export function isDragInvalid(
37
+ sourceId: TaskId,
38
+ targetId: TaskId,
39
+ existingLinks: Link[],
40
+ summaryIds: Set<TaskId>,
41
+ ): boolean {
42
+ if (String(sourceId) === String(targetId)) return true;
43
+ if ([...summaryIds].some((id) => String(id) === String(targetId))) return true;
44
+ return existingLinks.some(
45
+ (l) => String(l.source) === String(sourceId) && String(l.target) === String(targetId),
46
+ );
47
+ }
@@ -0,0 +1,98 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ // biome-ignore lint/correctness/noUnusedImports: ScheduleMode is implicitly used in TaskEditPatch return type
3
+ import type { ScheduleMode, Task, TaskId } from '../types.js';
4
+
5
+ export type EditableField = 'text' | 'start' | 'end' | 'duration' | 'progress' | 'scheduleMode';
6
+
7
+ export interface ActiveCell {
8
+ taskId: TaskId;
9
+ field: EditableField;
10
+ }
11
+
12
+ export type TaskEditPatch = Partial<
13
+ Pick<Task, 'text' | 'start' | 'end' | 'duration' | 'progress' | 'scheduleMode'>
14
+ >;
15
+
16
+ export interface EditState {
17
+ activeCell: ActiveCell | null;
18
+ dirtyValue: string;
19
+ activateCell(taskId: TaskId, field: EditableField, initialValue: string): void;
20
+ setValue(value: string): void;
21
+ commitCell(onTaskEdit?: (id: TaskId, patch: TaskEditPatch) => void): void;
22
+ cancelCell(): void;
23
+ }
24
+
25
+ export function parseFieldValue(field: EditableField, value: string): TaskEditPatch {
26
+ switch (field) {
27
+ case 'text':
28
+ return { text: value };
29
+ case 'start': {
30
+ const d = new Date(`${value}T08:00:00`);
31
+ if (Number.isNaN(d.getTime())) return {};
32
+ return { start: d };
33
+ }
34
+ case 'end': {
35
+ const d = new Date(`${value}T08:00:00`);
36
+ if (Number.isNaN(d.getTime())) return {};
37
+ return { end: d };
38
+ }
39
+ case 'duration': {
40
+ const n = Number(value);
41
+ if (!Number.isFinite(n) || n < 0) return {};
42
+ return { duration: Math.round(n * 8 * 60) };
43
+ }
44
+ case 'progress': {
45
+ const n = Number(value);
46
+ if (!Number.isFinite(n)) return {};
47
+ return { progress: Math.min(100, Math.max(0, n)) };
48
+ }
49
+ case 'scheduleMode': {
50
+ if (value !== 'auto' && value !== 'manual') return {};
51
+ return { scheduleMode: value };
52
+ }
53
+ }
54
+ }
55
+
56
+ interface EditStateInternal {
57
+ activeCell: ActiveCell | null;
58
+ dirtyValue: string;
59
+ }
60
+
61
+ export function useEditState(): EditState {
62
+ const [state, setState] = useState<EditStateInternal>({
63
+ activeCell: null,
64
+ dirtyValue: '',
65
+ });
66
+ // stateRef lets commitCell read the latest state without stale closures.
67
+ const stateRef = useRef(state);
68
+ stateRef.current = state;
69
+
70
+ const activateCell = useCallback((taskId: TaskId, field: EditableField, initialValue: string) => {
71
+ setState({ activeCell: { taskId, field }, dirtyValue: initialValue });
72
+ }, []);
73
+
74
+ const setValue = useCallback((value: string) => {
75
+ setState((prev) => ({ ...prev, dirtyValue: value }));
76
+ }, []);
77
+
78
+ const commitCell = useCallback((onTaskEdit?: (id: TaskId, patch: TaskEditPatch) => void) => {
79
+ const { activeCell, dirtyValue } = stateRef.current;
80
+ if (activeCell === null) return;
81
+ const patch = parseFieldValue(activeCell.field, dirtyValue);
82
+ onTaskEdit?.(activeCell.taskId, patch);
83
+ setState({ activeCell: null, dirtyValue: '' });
84
+ }, []);
85
+
86
+ const cancelCell = useCallback(() => {
87
+ setState({ activeCell: null, dirtyValue: '' });
88
+ }, []);
89
+
90
+ return {
91
+ activeCell: state.activeCell,
92
+ dirtyValue: state.dirtyValue,
93
+ activateCell,
94
+ setValue,
95
+ commitCell,
96
+ cancelCell,
97
+ };
98
+ }