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/dist/index.cjs CHANGED
@@ -1907,7 +1907,182 @@ function reducer(state, action) {
1907
1907
  };
1908
1908
  }
1909
1909
 
1910
- __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.construction-gantt-marker-today{background:#ef4444;color:#fff;font-weight:600}.construction-gantt-marker-milestone{background:#2563eb;color:#fff;font-weight:500}.bode-baseline-ghost{display:flex;align-items:center;gap:6px;height:60%;margin-top:15%;padding:0 8px;border-radius:3px;font-size:9px;font-style:italic;color:#fff;opacity:.85;white-space:nowrap}.bode-baseline-0{background:#6b7280}.bode-baseline-1{background:#3b82f6}.bode-baseline-2{background:#8b5cf6}.bode-baseline-3{background:#14b8a6;color:#0f172a}.bode-baseline-4{background:#f59e0b;color:#0f172a}.bode-baseline-5{background:#ec4899}.bode-baseline-6{background:#06b6d4;color:#0f172a}.bode-baseline-7{background:#84cc16;color:#0f172a}.bode-baseline-8{background:#f97316;color:#0f172a}.bode-baseline-9{background:#a855f7}.bode-baseline-10{background:#dc2626}");
1910
+ __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.construction-gantt-marker-today{background:#ef4444;color:#fff;font-weight:600}.construction-gantt-marker-milestone{background:#2563eb;color:#fff;font-weight:500}.bode-baseline-ghost{display:flex;align-items:center;gap:6px;height:60%;margin-top:15%;padding:0 8px;border-radius:3px;font-size:9px;font-style:italic;color:#fff;opacity:.85;white-space:nowrap}.bode-baseline-0{background:#6b7280}.bode-baseline-1{background:#3b82f6}.bode-baseline-2{background:#8b5cf6}.bode-baseline-3{background:#14b8a6;color:#0f172a}.bode-baseline-4{background:#f59e0b;color:#0f172a}.bode-baseline-5{background:#ec4899}.bode-baseline-6{background:#06b6d4;color:#0f172a}.bode-baseline-7{background:#84cc16;color:#0f172a}.bode-baseline-8{background:#f97316;color:#0f172a}.bode-baseline-9{background:#a855f7}.bode-baseline-10{background:#dc2626}.bode-edit-preview{height:60%;margin-top:15%;background:repeating-linear-gradient(45deg,rgba(59,130,246,.35) 0,rgba(59,130,246,.35) 6px,rgba(59,130,246,.15) 6px,rgba(59,130,246,.15) 12px);border:1.5px dashed rgba(59,130,246,.7);border-radius:3px;pointer-events:none}.construction-gantt-drag-handle{position:absolute;right:-5px;top:50%;transform:translateY(-50%);width:10px;height:10px;border-radius:50%;background:rgba(59,130,246,.85);cursor:crosshair;opacity:0;transition:opacity .15s;z-index:10}.wx-task:hover .construction-gantt-drag-handle{opacity:1}");
1911
+
1912
+ const DRAG_INITIAL = {
1913
+ status: 'idle'
1914
+ };
1915
+ function startDrag(sourceId, startX, startY) {
1916
+ return {
1917
+ status: 'dragging',
1918
+ sourceId,
1919
+ startX,
1920
+ startY,
1921
+ cursorX: startX,
1922
+ cursorY: startY
1923
+ };
1924
+ }
1925
+ function moveDrag(state, cursorX, cursorY) {
1926
+ if (state.status !== 'dragging') return state;
1927
+ return {
1928
+ ...state,
1929
+ cursorX,
1930
+ cursorY
1931
+ };
1932
+ }
1933
+ function cancelDrag(_state) {
1934
+ return DRAG_INITIAL;
1935
+ }
1936
+ function isDragInvalid(sourceId, targetId, existingLinks, summaryIds) {
1937
+ if (String(sourceId) === String(targetId)) return true;
1938
+ if ([
1939
+ ...summaryIds
1940
+ ].some((id)=>String(id) === String(targetId))) return true;
1941
+ return existingLinks.some((l)=>String(l.source) === String(sourceId) && String(l.target) === String(targetId));
1942
+ }
1943
+
1944
+ function parseFieldValue(field, value) {
1945
+ switch(field){
1946
+ case 'text':
1947
+ return {
1948
+ text: value
1949
+ };
1950
+ case 'start':
1951
+ {
1952
+ const d = new Date(`${value}T08:00:00`);
1953
+ if (Number.isNaN(d.getTime())) return {};
1954
+ return {
1955
+ start: d
1956
+ };
1957
+ }
1958
+ case 'end':
1959
+ {
1960
+ const d = new Date(`${value}T08:00:00`);
1961
+ if (Number.isNaN(d.getTime())) return {};
1962
+ return {
1963
+ end: d
1964
+ };
1965
+ }
1966
+ case 'duration':
1967
+ {
1968
+ const n = Number(value);
1969
+ if (!Number.isFinite(n) || n < 0) return {};
1970
+ return {
1971
+ duration: Math.round(n * 8 * 60)
1972
+ };
1973
+ }
1974
+ case 'progress':
1975
+ {
1976
+ const n = Number(value);
1977
+ if (!Number.isFinite(n)) return {};
1978
+ return {
1979
+ progress: Math.min(100, Math.max(0, n))
1980
+ };
1981
+ }
1982
+ case 'scheduleMode':
1983
+ {
1984
+ if (value !== 'auto' && value !== 'manual') return {};
1985
+ return {
1986
+ scheduleMode: value
1987
+ };
1988
+ }
1989
+ }
1990
+ }
1991
+ function useEditState() {
1992
+ const [state, setState] = react.useState({
1993
+ activeCell: null,
1994
+ dirtyValue: ''
1995
+ });
1996
+ // stateRef lets commitCell read the latest state without stale closures.
1997
+ const stateRef = react.useRef(state);
1998
+ stateRef.current = state;
1999
+ const activateCell = react.useCallback((taskId, field, initialValue)=>{
2000
+ setState({
2001
+ activeCell: {
2002
+ taskId,
2003
+ field
2004
+ },
2005
+ dirtyValue: initialValue
2006
+ });
2007
+ }, []);
2008
+ const setValue = react.useCallback((value)=>{
2009
+ setState((prev)=>({
2010
+ ...prev,
2011
+ dirtyValue: value
2012
+ }));
2013
+ }, []);
2014
+ const commitCell = react.useCallback((onTaskEdit)=>{
2015
+ const { activeCell, dirtyValue } = stateRef.current;
2016
+ if (activeCell === null) return;
2017
+ const patch = parseFieldValue(activeCell.field, dirtyValue);
2018
+ onTaskEdit?.(activeCell.taskId, patch);
2019
+ setState({
2020
+ activeCell: null,
2021
+ dirtyValue: ''
2022
+ });
2023
+ }, []);
2024
+ const cancelCell = react.useCallback(()=>{
2025
+ setState({
2026
+ activeCell: null,
2027
+ dirtyValue: ''
2028
+ });
2029
+ }, []);
2030
+ return {
2031
+ activeCell: state.activeCell,
2032
+ dirtyValue: state.dirtyValue,
2033
+ activateCell,
2034
+ setValue,
2035
+ commitCell,
2036
+ cancelCell
2037
+ };
2038
+ }
2039
+
2040
+ function usePreviewEngine(committed, activeCell, dirtyValue, debounceMs = 80) {
2041
+ const [ghostProject, setGhostProject] = react.useState(null);
2042
+ const timerRef = react.useRef(null);
2043
+ react.useEffect(()=>{
2044
+ const CPM_IRRELEVANT_FIELDS = new Set([
2045
+ 'text',
2046
+ 'progress',
2047
+ 'scheduleMode'
2048
+ ]);
2049
+ if (activeCell === null || CPM_IRRELEVANT_FIELDS.has(activeCell.field)) {
2050
+ setGhostProject(null);
2051
+ return;
2052
+ }
2053
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
2054
+ timerRef.current = setTimeout(()=>{
2055
+ const patch = parseFieldValue(activeCell.field, dirtyValue);
2056
+ if (Object.keys(patch).length === 0) {
2057
+ setGhostProject(null); // invalid input — suppress ghost
2058
+ return;
2059
+ }
2060
+ const patchedTasks = committed.tasks.map((t)=>t.id === activeCell.taskId ? {
2061
+ ...t,
2062
+ ...patch
2063
+ } : t);
2064
+ setGhostProject(schedule({
2065
+ ...committed,
2066
+ tasks: patchedTasks
2067
+ }));
2068
+ }, debounceMs);
2069
+ return ()=>{
2070
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
2071
+ };
2072
+ }, [
2073
+ committed,
2074
+ activeCell,
2075
+ dirtyValue,
2076
+ debounceMs
2077
+ ]);
2078
+ // Clear immediately when cell deactivates (don't wait for debounce timeout).
2079
+ react.useEffect(()=>{
2080
+ if (activeCell === null) setGhostProject(null);
2081
+ }, [
2082
+ activeCell
2083
+ ]);
2084
+ return ghostProject;
2085
+ }
1911
2086
 
1912
2087
  // Render-only visibility filter for <Gantt> tasks.
1913
2088
  //
@@ -1916,11 +2091,9 @@ __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.c
1916
2091
  // This is the canonical example: the engine always runs on the full task
1917
2092
  // set; the visibility filter applies only to the rendered output.
1918
2093
  //
1919
- // Direct lift from the CM domain rule (FrappeGanttView.tsx:366-371 in
1920
- // Bode-Builds): "Hiding a predecessor would otherwise make a dependent
1921
- // task's early-start collapse to 0, which is wrong." We honour the rule
1922
- // by structure — the filter sits AFTER schedule() in the pipeline, so
1923
- // computed fields on the visible tasks reflect the full schedule.
2094
+ // The filter sits AFTER schedule() in the pipeline so computed fields on
2095
+ // the visible tasks reflect the full schedule — hiding a predecessor must
2096
+ // not cause a dependent task's early-start to collapse to 0.
1924
2097
  /**
1925
2098
  * Return the subset of `tasks` whose ids appear in `visibleTaskIds`.
1926
2099
  *
@@ -1938,7 +2111,84 @@ __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.c
1938
2111
  return tasks.filter((t)=>visibleTaskIds.has(t.id));
1939
2112
  }
1940
2113
 
1941
- const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height = 500, cellWidth = 48, cellHeight = 42, preScheduled = false, markers, baselineIndex, baselineIndices, showBaselineBars, columns, visibleTaskIds }, ref) {
2114
+ const DEFAULT_EDIT_COLUMNS = [
2115
+ {
2116
+ id: 'text',
2117
+ header: 'Task Name',
2118
+ field: 'text',
2119
+ width: 220
2120
+ },
2121
+ {
2122
+ id: 'start',
2123
+ header: 'Start',
2124
+ field: 'start',
2125
+ width: 100,
2126
+ align: 'center'
2127
+ },
2128
+ {
2129
+ id: 'end',
2130
+ header: 'Finish',
2131
+ field: 'end',
2132
+ width: 100,
2133
+ align: 'center'
2134
+ },
2135
+ {
2136
+ id: 'duration',
2137
+ header: 'Days',
2138
+ field: 'duration',
2139
+ width: 60,
2140
+ align: 'right'
2141
+ },
2142
+ {
2143
+ id: 'progress',
2144
+ header: '%',
2145
+ field: 'progress',
2146
+ width: 50,
2147
+ align: 'right'
2148
+ }
2149
+ ];
2150
+ function useDragLink() {
2151
+ const [dragState, setDragState] = react.useState(DRAG_INITIAL);
2152
+ const dragRef = react.useRef(dragState);
2153
+ dragRef.current = dragState;
2154
+ const onBarMouseDown = react.useCallback((sourceId, e)=>{
2155
+ e.preventDefault();
2156
+ setDragState(startDrag(sourceId, e.clientX, e.clientY));
2157
+ }, []);
2158
+ const onMouseMove = react.useCallback((e)=>{
2159
+ setDragState((s)=>moveDrag(s, e.clientX, e.clientY));
2160
+ }, []);
2161
+ const onMouseUp = react.useCallback((e, project, onLinkCreate)=>{
2162
+ const current = dragRef.current;
2163
+ if (current.status !== 'dragging') return;
2164
+ const el = document.elementFromPoint(e.clientX, e.clientY);
2165
+ const barEl = el?.closest('[data-task-id]');
2166
+ const targetAttr = barEl?.getAttribute('data-task-id') ?? null;
2167
+ // Resolve to a real task — rejects phantom row ids (e.g. "t1__baseline_0")
2168
+ // and preserves the original typed TaskId (fixes numeric id loss via DOM attribute).
2169
+ const targetTask = targetAttr ? project.tasks.find((t)=>String(t.id) === targetAttr) : undefined;
2170
+ if (!targetTask) {
2171
+ setDragState(cancelDrag());
2172
+ return;
2173
+ }
2174
+ const summaryIds = new Set(project.tasks.filter((t)=>t.type === 'summary').map((t)=>t.id));
2175
+ if (isDragInvalid(current.sourceId, targetTask.id, project.links, summaryIds)) {
2176
+ setDragState(cancelDrag());
2177
+ return;
2178
+ }
2179
+ onLinkCreate?.(current.sourceId, targetTask.id, 'FS');
2180
+ setDragState(DRAG_INITIAL);
2181
+ }, []);
2182
+ const cancelDragLink = react.useCallback(()=>setDragState(DRAG_INITIAL), []);
2183
+ return {
2184
+ dragState,
2185
+ onBarMouseDown,
2186
+ onMouseMove,
2187
+ onMouseUp,
2188
+ cancelDragLink
2189
+ };
2190
+ }
2191
+ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height = 500, cellWidth = 48, cellHeight = 42, preScheduled = false, markers, baselineIndex, baselineIndices, showBaselineBars, columns, visibleTaskIds, editMode = false, onTaskEdit, onLinkCreate, onLinkDelete }, ref) {
1942
2192
  const containerRef = react.useRef(null);
1943
2193
  const effectiveBaselineIndices = react.useMemo(()=>resolveEffectiveBaselineIndices(baselineIndices, baselineIndex), [
1944
2194
  baselineIndices,
@@ -1958,6 +2208,37 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
1958
2208
  scheduled.calendars,
1959
2209
  scheduled.defaultCalendarId
1960
2210
  ]);
2211
+ const editState = useEditState();
2212
+ const editStateRef = react.useRef(editState);
2213
+ editStateRef.current = editState;
2214
+ const onTaskEditRef = react.useRef(onTaskEdit);
2215
+ onTaskEditRef.current = onTaskEdit;
2216
+ const editGhostProject = usePreviewEngine(scheduled, editState.activeCell, editState.dirtyValue);
2217
+ const { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink } = useDragLink();
2218
+ react.useEffect(()=>{
2219
+ if (!editMode || dragState.status !== 'dragging') return;
2220
+ const handleMove = (e)=>onMouseMove(e);
2221
+ const handleUp = (e)=>onMouseUp(e, scheduled, onLinkCreate);
2222
+ const handleEsc = (e)=>{
2223
+ if (e.key === 'Escape') cancelDragLink();
2224
+ };
2225
+ window.addEventListener('mousemove', handleMove);
2226
+ window.addEventListener('mouseup', handleUp);
2227
+ window.addEventListener('keydown', handleEsc);
2228
+ return ()=>{
2229
+ window.removeEventListener('mousemove', handleMove);
2230
+ window.removeEventListener('mouseup', handleUp);
2231
+ window.removeEventListener('keydown', handleEsc);
2232
+ };
2233
+ }, [
2234
+ editMode,
2235
+ dragState.status,
2236
+ onMouseMove,
2237
+ onMouseUp,
2238
+ cancelDragLink,
2239
+ scheduled,
2240
+ onLinkCreate
2241
+ ]);
1961
2242
  // Resolve effective indices → actual Baseline records, dropping any that
1962
2243
  // don't exist on the project. Preserves caller order so phantom rows
1963
2244
  // render in the array order the consumer passed.
@@ -1966,11 +2247,12 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
1966
2247
  effectiveBaselineIndices
1967
2248
  ]);
1968
2249
  const ghostBarsEnabled = resolvedBaselines.length > 0 && (showBaselineBars ?? true);
1969
- const svarTasks = react.useMemo(()=>buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled), [
2250
+ const svarTasks = react.useMemo(()=>buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject ?? undefined), [
1970
2251
  renderableTasks,
1971
2252
  resolvedBaselines,
1972
2253
  calendar,
1973
- ghostBarsEnabled
2254
+ ghostBarsEnabled,
2255
+ editGhostProject
1974
2256
  ]);
1975
2257
  const svarLinks = react.useMemo(()=>scheduled.links.map(toSvarLink), [
1976
2258
  scheduled.links
@@ -1990,12 +2272,59 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
1990
2272
  // undefined → don't pass columns to SVAR (use SVAR defaults).
1991
2273
  // [] → pass false to SVAR (hide grid entirely).
1992
2274
  // [...] → convert each column.
2275
+ // In editMode, inject interactive cell renderers for known editable fields.
2276
+ // 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
1993
2277
  const svarColumns = react.useMemo(()=>{
1994
- if (columns === undefined) return undefined;
1995
- if (columns.length === 0) return false;
1996
- return columns.map(toSvarColumn);
2278
+ if (!editMode) {
2279
+ if (columns === undefined) return undefined;
2280
+ if (columns.length === 0) return false;
2281
+ return columns.map(toSvarColumn);
2282
+ }
2283
+ const effectiveCols = columns ?? DEFAULT_EDIT_COLUMNS;
2284
+ if (effectiveCols.length === 0) return false;
2285
+ return effectiveCols.map((col)=>{
2286
+ if (col.render || !col.field || !EDITABLE_FIELDS.has(col.field)) {
2287
+ return toSvarColumn(col);
2288
+ }
2289
+ const base = toSvarColumn(col);
2290
+ return {
2291
+ ...base,
2292
+ cell: buildEditableCell(col.field, editStateRef, onTaskEditRef)
2293
+ };
2294
+ });
1997
2295
  }, [
1998
- columns
2296
+ columns,
2297
+ editMode,
2298
+ editState.activeCell
2299
+ ]);
2300
+ const taskTemplate = react.useMemo(()=>{
2301
+ if (!editMode) return ConstructionBar;
2302
+ // Wrap ConstructionBar with a drag handle at the right edge of each task bar.
2303
+ const EditableBar = ({ data })=>/*#__PURE__*/ jsxRuntime.jsxs("div", {
2304
+ "data-task-id": data.id !== undefined ? String(data.id) : undefined,
2305
+ style: {
2306
+ position: 'relative',
2307
+ width: '100%',
2308
+ height: '100%'
2309
+ },
2310
+ children: [
2311
+ /*#__PURE__*/ jsxRuntime.jsx(ConstructionBar, {
2312
+ data: data
2313
+ }),
2314
+ !data.is_baseline_ghost && !data.is_edit_preview && // biome-ignore lint/a11y/noStaticElementInteractions: drag handle — pointer-down initiates link drag; keyboard alternative (Escape) handled at window level
2315
+ /*#__PURE__*/ jsxRuntime.jsx("div", {
2316
+ className: "construction-gantt-drag-handle",
2317
+ onMouseDown: (e)=>{
2318
+ if (data.id !== undefined) onBarMouseDown(data.id, e);
2319
+ },
2320
+ title: "Drag to create link"
2321
+ })
2322
+ ]
2323
+ });
2324
+ return EditableBar;
2325
+ }, [
2326
+ editMode,
2327
+ onBarMouseDown
1999
2328
  ]);
2000
2329
  react.useImperativeHandle(ref, ()=>({
2001
2330
  async exportPNG (options) {
@@ -2053,28 +2382,67 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
2053
2382
  height,
2054
2383
  visibleTaskIds
2055
2384
  ]);
2056
- return /*#__PURE__*/ jsxRuntime.jsx("div", {
2385
+ return(// biome-ignore lint/a11y/noStaticElementInteractions: gantt container — link-delete click is an optional editing affordance, not a primary interaction target
2386
+ // biome-ignore lint/a11y/useKeyWithClickEvents: keyboard alternative (Delete key) is out of scope for v0.4; Escape already handled in drag listener
2387
+ /*#__PURE__*/ jsxRuntime.jsxs("div", {
2057
2388
  ref: containerRef,
2058
2389
  style: {
2390
+ position: 'relative',
2059
2391
  height
2060
2392
  },
2061
- children: /*#__PURE__*/ jsxRuntime.jsx(reactGantt.Gantt, {
2062
- tasks: svarTasks,
2063
- links: svarLinks,
2064
- start: scheduled.start,
2065
- end: projectEnd,
2066
- cellWidth: cellWidth,
2067
- cellHeight: cellHeight,
2068
- markers: svarMarkers,
2069
- highlightTime: highlightTime,
2070
- taskTemplate: ConstructionBar,
2071
- ...svarColumns !== undefined ? {
2072
- columns: svarColumns
2073
- } : {}
2074
- })
2075
- });
2393
+ onClick: editMode && onLinkDelete ? (e)=>{
2394
+ // SVAR-internal: dependency arrows render as <polyline class="wx-line ..."> with
2395
+ // data-link-id holding the link id. Re-verify on SVAR upgrades by searching
2396
+ // node_modules/@svar-ui/react-gantt/dist/index.es.js for "wx-line" + "data-link-id".
2397
+ const el = e.target.closest('.wx-line');
2398
+ if (!el) return;
2399
+ const linkId = el.getAttribute('data-link-id');
2400
+ if (linkId) onLinkDelete(linkId);
2401
+ } : undefined,
2402
+ children: [
2403
+ /*#__PURE__*/ jsxRuntime.jsx(reactGantt.Gantt, {
2404
+ tasks: svarTasks,
2405
+ links: svarLinks,
2406
+ start: scheduled.start,
2407
+ end: projectEnd,
2408
+ cellWidth: cellWidth,
2409
+ cellHeight: cellHeight,
2410
+ markers: svarMarkers,
2411
+ highlightTime: highlightTime,
2412
+ taskTemplate: taskTemplate,
2413
+ ...svarColumns !== undefined ? {
2414
+ columns: svarColumns
2415
+ } : {}
2416
+ }),
2417
+ editMode && dragState.status === 'dragging' && /*#__PURE__*/ jsxRuntime.jsx("svg", {
2418
+ "aria-hidden": "true",
2419
+ style: {
2420
+ position: 'fixed',
2421
+ inset: 0,
2422
+ width: '100vw',
2423
+ height: '100vh',
2424
+ pointerEvents: 'none',
2425
+ zIndex: 9999
2426
+ },
2427
+ children: /*#__PURE__*/ jsxRuntime.jsx("line", {
2428
+ x1: dragState.startX,
2429
+ y1: dragState.startY,
2430
+ x2: dragState.cursorX,
2431
+ y2: dragState.cursorY,
2432
+ stroke: "rgba(59,130,246,0.8)",
2433
+ strokeWidth: 2,
2434
+ strokeDasharray: "6 3"
2435
+ })
2436
+ })
2437
+ ]
2438
+ }));
2076
2439
  });
2077
2440
  const ConstructionBar = ({ data })=>{
2441
+ if (data.is_edit_preview) {
2442
+ return /*#__PURE__*/ jsxRuntime.jsx("div", {
2443
+ className: "bode-edit-preview"
2444
+ });
2445
+ }
2078
2446
  // Phantom baseline row — render a slim outlined ghost bar.
2079
2447
  if (data.is_baseline_ghost) {
2080
2448
  const baselineIdx = data.baseline_index ?? 0;
@@ -2298,6 +2666,20 @@ function makeBaselinePhantom(t, baseline, calendar) {
2298
2666
  is_ahead: startVariance <= -30
2299
2667
  };
2300
2668
  }
2669
+ function makeEditPreviewPhantom(liveTask, ghostTask) {
2670
+ return {
2671
+ id: `${liveTask.id}__edit_preview`,
2672
+ text: 'Preview',
2673
+ start: ghostTask.start,
2674
+ end: ghostTask.end,
2675
+ duration: ghostTask.duration,
2676
+ progress: 0,
2677
+ type: 'task',
2678
+ parent: liveTask.parent,
2679
+ is_baseline_ghost: true,
2680
+ is_edit_preview: true
2681
+ };
2682
+ }
2301
2683
  function toSvarLink(l) {
2302
2684
  return {
2303
2685
  id: l.id,
@@ -2398,10 +2780,24 @@ function getProjectEnd(p) {
2398
2780
  * gets its own.
2399
2781
  *
2400
2782
  * Exported for testing. Not part of the public surface.
2401
- */ function buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled) {
2783
+ */ function buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject) {
2784
+ const ghostById = new Map(editGhostProject?.tasks.map((t)=>[
2785
+ t.id,
2786
+ t
2787
+ ]) ?? []);
2402
2788
  if (!ghostBarsEnabled || resolvedBaselines.length === 0) {
2403
2789
  const primary = resolvedBaselines[0];
2404
- return renderableTasks.map((t)=>toSvarTask(t, primary, calendar));
2790
+ const out = renderableTasks.map((t)=>toSvarTask(t, primary, calendar));
2791
+ if (ghostById.size > 0) {
2792
+ for (const t of renderableTasks){
2793
+ if (t.type === 'summary') continue;
2794
+ const ghost = ghostById.get(t.id);
2795
+ if (ghost && (ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())) {
2796
+ out.push(makeEditPreviewPhantom(t, ghost));
2797
+ }
2798
+ }
2799
+ }
2800
+ return out;
2405
2801
  }
2406
2802
  const out = [];
2407
2803
  for (const t of renderableTasks){
@@ -2412,6 +2808,11 @@ function getProjectEnd(p) {
2412
2808
  const phantom = makeBaselinePhantom(t, b, calendar);
2413
2809
  if (phantom) out.push(phantom);
2414
2810
  }
2811
+ // Edit preview phantom goes last (renders below baseline phantoms).
2812
+ const ghost = ghostById.get(t.id);
2813
+ if (ghost && (ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())) {
2814
+ out.push(makeEditPreviewPhantom(t, ghost));
2815
+ }
2415
2816
  }
2416
2817
  return out;
2417
2818
  }
@@ -2483,6 +2884,95 @@ function toSvarMarker(m) {
2483
2884
  };
2484
2885
  return config;
2485
2886
  }
2887
+ function getInputType(field) {
2888
+ if (field === 'start' || field === 'end') return 'date';
2889
+ if (field === 'duration' || field === 'progress') return 'number';
2890
+ return 'text';
2891
+ }
2892
+ function getInputValue(task, field) {
2893
+ switch(field){
2894
+ case 'text':
2895
+ return task.text;
2896
+ case 'start':
2897
+ return formatShortDate(task.start);
2898
+ case 'end':
2899
+ return formatShortDate(task.end);
2900
+ case 'duration':
2901
+ return String(Math.round(task.duration / 60 / 8));
2902
+ case 'progress':
2903
+ return String(task.progress);
2904
+ case 'scheduleMode':
2905
+ return task.scheduleMode;
2906
+ }
2907
+ }
2908
+ const EDITABLE_FIELDS = new Set([
2909
+ 'text',
2910
+ 'start',
2911
+ 'end',
2912
+ 'duration',
2913
+ 'progress',
2914
+ 'scheduleMode'
2915
+ ]);
2916
+ function buildEditableCell(field, editStateRef, onTaskEditRef) {
2917
+ return ({ row })=>{
2918
+ const editState = editStateRef.current;
2919
+ const task = row;
2920
+ if (task.is_baseline_ghost || task.is_edit_preview) {
2921
+ return /*#__PURE__*/ jsxRuntime.jsx("span", {});
2922
+ }
2923
+ const isReadOnly = task.type === 'summary' && (field === 'start' || field === 'end' || field === 'duration');
2924
+ const isActive = editState.activeCell?.taskId === task.id && editState.activeCell?.field === field;
2925
+ if (isActive) {
2926
+ return /*#__PURE__*/ jsxRuntime.jsx("input", {
2927
+ // biome-ignore lint/a11y/noAutofocus: intentional — cell was clicked
2928
+ autoFocus: true,
2929
+ type: getInputType(field),
2930
+ defaultValue: editState.dirtyValue,
2931
+ style: {
2932
+ width: '100%',
2933
+ boxSizing: 'border-box'
2934
+ },
2935
+ onChange: (e)=>editStateRef.current.setValue(e.target.value),
2936
+ onBlur: ()=>editStateRef.current.commitCell(onTaskEditRef.current),
2937
+ onKeyDown: (e)=>{
2938
+ if (e.key === 'Enter' || e.key === 'Tab') {
2939
+ e.preventDefault();
2940
+ editStateRef.current.commitCell(onTaskEditRef.current);
2941
+ } else if (e.key === 'Escape') {
2942
+ editStateRef.current.cancelCell();
2943
+ }
2944
+ }
2945
+ }, `${editState.activeCell?.taskId}-${field}`);
2946
+ }
2947
+ const displayValue = (()=>{
2948
+ if (field === 'start' || field === 'end') {
2949
+ const d = task[field];
2950
+ return d instanceof Date ? formatShortDate(d) : '';
2951
+ }
2952
+ if (field === 'duration') return String(Math.round((task.duration ?? 0) / 60 / 8));
2953
+ if (field === 'progress') return String(task.progress ?? 0);
2954
+ return String(task[field] ?? '');
2955
+ })();
2956
+ if (isReadOnly || task.id === undefined) {
2957
+ return /*#__PURE__*/ jsxRuntime.jsx("span", {
2958
+ children: displayValue
2959
+ });
2960
+ }
2961
+ const taskId = task.id;
2962
+ const activate = ()=>editStateRef.current.activateCell(taskId, field, getInputValue(task, field));
2963
+ return(// biome-ignore lint/a11y/useKeyWithClickEvents: cell activation via keyboard handled by the input that renders on activate
2964
+ // biome-ignore lint/a11y/noStaticElementInteractions: grid cell — role="gridcell" would be on the parent SVAR element
2965
+ /*#__PURE__*/ jsxRuntime.jsx("span", {
2966
+ style: {
2967
+ cursor: 'text',
2968
+ display: 'block',
2969
+ width: '100%'
2970
+ },
2971
+ onClick: activate,
2972
+ children: displayValue
2973
+ }));
2974
+ };
2975
+ }
2486
2976
  function buildHighlightTime(calendar) {
2487
2977
  if (!calendar) return undefined;
2488
2978
  return (date, unit)=>{
@@ -2528,6 +3018,7 @@ const KNOWN_TASK_FIELDS = new Set([
2528
3018
  'Finish',
2529
3019
  'Duration',
2530
3020
  'ConstraintType',
3021
+ 'ConstraintDate',
2531
3022
  'Milestone',
2532
3023
  'Summary',
2533
3024
  'OutlineLevel',
@@ -2711,6 +3202,7 @@ function parseMspdi(xml) {
2711
3202
  }
2712
3203
  const tasks = [];
2713
3204
  const links = [];
3205
+ const outlineStack = [];
2714
3206
  // Per-task baseline snapshots, keyed by baseline Number (BaselineIndex).
2715
3207
  // Flattened into project.baselines below.
2716
3208
  const baselineAccum = new Map();
@@ -2731,7 +3223,10 @@ function parseMspdi(xml) {
2731
3223
  const isMilestone = String(raw.Milestone ?? '0') === '1';
2732
3224
  const isSummary = String(raw.Summary ?? '0') === '1';
2733
3225
  const taskType = isSummary ? 'summary' : isMilestone ? 'milestone' : 'task';
2734
- tasks.push({
3226
+ const outlineLevel = Math.max(1, Number(raw.OutlineLevel ?? '1'));
3227
+ const parentId = outlineLevel > 1 ? outlineStack[outlineLevel - 2] : undefined;
3228
+ const constraint = parseConstraint(raw);
3229
+ const parsedTask = {
2735
3230
  id: uid,
2736
3231
  text: name,
2737
3232
  type: taskType,
@@ -2739,8 +3234,13 @@ function parseMspdi(xml) {
2739
3234
  duration,
2740
3235
  start,
2741
3236
  end,
2742
- progress: 0
2743
- });
3237
+ progress: Number(raw.PercentComplete ?? '0')
3238
+ };
3239
+ if (parentId !== undefined) parsedTask.parent = parentId;
3240
+ if (constraint !== undefined) parsedTask.constraint = constraint;
3241
+ tasks.push(parsedTask);
3242
+ outlineStack[outlineLevel - 1] = uid;
3243
+ outlineStack.length = outlineLevel;
2744
3244
  // Walk predecessor links nested inside the task.
2745
3245
  const preds = Array.isArray(raw.PredecessorLink) ? raw.PredecessorLink : raw.PredecessorLink !== undefined ? [
2746
3246
  raw.PredecessorLink
@@ -3142,9 +3642,31 @@ const MSPDI_TYPE_TO_DEPENDENCY = {
3142
3642
  2: 'SF',
3143
3643
  3: 'SS'
3144
3644
  };
3645
+ const MSPDI_CONSTRAINT_TO_INTERNAL = {
3646
+ 0: 'ASAP',
3647
+ 1: 'ALAP',
3648
+ 2: 'MSO',
3649
+ 3: 'MFO',
3650
+ 4: 'SNET',
3651
+ 5: 'SNLT',
3652
+ 6: 'FNET',
3653
+ 7: 'FNLT'
3654
+ };
3145
3655
  function mspdiTypeToDependencyType(t) {
3146
3656
  return MSPDI_TYPE_TO_DEPENDENCY[t] ?? 'FS';
3147
3657
  }
3658
+ function parseConstraint(raw) {
3659
+ const value = Number(raw.ConstraintType ?? '0');
3660
+ const type = MSPDI_CONSTRAINT_TO_INTERNAL[value];
3661
+ if (!type || type === 'ASAP') return undefined;
3662
+ const constraint = {
3663
+ type
3664
+ };
3665
+ if (raw.ConstraintDate !== undefined && raw.ConstraintDate !== '') {
3666
+ constraint.date = parseMspdiDate(String(raw.ConstraintDate));
3667
+ }
3668
+ return constraint;
3669
+ }
3148
3670
  function parseMspdiDate(s) {
3149
3671
  // MSPDI emits ISO 8601 like `2026-01-05T08:00:00` (no timezone in
3150
3672
  // practice; MS Project writes local time). We treat it as local.
@@ -3254,6 +3776,7 @@ function serializeMspdi(project, options = {}) {
3254
3776
  // Group baselines by taskId. Each task may have one snapshot per baseline
3255
3777
  // (0-10), emitted as <Baseline> children nested inside the task.
3256
3778
  const baselinesByTask = buildBaselinesByTask(project.baselines);
3779
+ const outlineLevelByTask = buildOutlineLevels(project.tasks);
3257
3780
  const tasksOut = project.tasks.map((t, idx)=>{
3258
3781
  const taskOut = {
3259
3782
  UID: String(t.id),
@@ -3262,11 +3785,15 @@ function serializeMspdi(project, options = {}) {
3262
3785
  Start: formatMspdiDate(t.start),
3263
3786
  Finish: formatMspdiDate(t.end),
3264
3787
  Duration: formatMspdiDuration(t.duration),
3265
- ConstraintType: 0,
3788
+ ConstraintType: constraintTypeToMspdi(t.constraint?.type),
3266
3789
  Milestone: t.type === 'milestone' ? 1 : 0,
3267
3790
  Summary: t.type === 'summary' ? 1 : 0,
3268
- OutlineLevel: 1
3791
+ OutlineLevel: outlineLevelByTask.get(t.id) ?? 1,
3792
+ PercentComplete: t.progress
3269
3793
  };
3794
+ if (t.constraint?.date) {
3795
+ taskOut.ConstraintDate = formatMspdiDate(t.constraint.date);
3796
+ }
3270
3797
  const incoming = linksByTarget.get(String(t.id));
3271
3798
  if (incoming?.length) {
3272
3799
  taskOut.PredecessorLink = incoming.map(toMspdiLink);
@@ -3343,6 +3870,46 @@ function dependencyTypeToMspdi(t) {
3343
3870
  return 3;
3344
3871
  }
3345
3872
  }
3873
+ function constraintTypeToMspdi(t) {
3874
+ switch(t){
3875
+ case undefined:
3876
+ case 'ASAP':
3877
+ return 0;
3878
+ case 'ALAP':
3879
+ return 1;
3880
+ case 'MSO':
3881
+ return 2;
3882
+ case 'MFO':
3883
+ return 3;
3884
+ case 'SNET':
3885
+ return 4;
3886
+ case 'SNLT':
3887
+ return 5;
3888
+ case 'FNET':
3889
+ return 6;
3890
+ case 'FNLT':
3891
+ return 7;
3892
+ }
3893
+ }
3894
+ function buildOutlineLevels(tasks) {
3895
+ const parentById = new Map(tasks.map((t)=>[
3896
+ t.id,
3897
+ t.parent
3898
+ ]));
3899
+ const cache = new Map();
3900
+ function depth(taskId) {
3901
+ const cached = cache.get(taskId);
3902
+ if (cached !== undefined) return cached;
3903
+ const parentId = parentById.get(taskId);
3904
+ const value = parentId === undefined ? 1 : depth(parentId) + 1;
3905
+ cache.set(taskId, value);
3906
+ return value;
3907
+ }
3908
+ return new Map(tasks.map((t)=>[
3909
+ t.id,
3910
+ depth(t.id)
3911
+ ]));
3912
+ }
3346
3913
  function formatMspdiDate(d) {
3347
3914
  // MSPDI emits local time without timezone (e.g. `2026-01-05T08:00:00`).
3348
3915
  const pad = (n)=>String(n).padStart(2, '0');