@ifc-lite/viewer 1.17.6 → 1.19.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/.turbo/turbo-build.log +20 -15
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +949 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/sandbox-Baez7n-t.js +9682 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +11 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +73 -12
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +29 -2
- package/src/components/viewer/ViewportContainer.tsx +45 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +89 -13
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +8 -3
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +79 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/pointCloudSlice.ts +102 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/types.ts +7 -0
- package/src/store.ts +14 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -0,0 +1,1330 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Schedule state slice — IFC 4D / IfcTask Gantt panel + playback animation.
|
|
7
|
+
*
|
|
8
|
+
* The slice holds:
|
|
9
|
+
* • extracted schedule data (tasks, sequences, work schedules)
|
|
10
|
+
* • UI state (panel visibility, selected work schedule, expanded rows)
|
|
11
|
+
* • playback state (current time, speed, isPlaying)
|
|
12
|
+
* • derived-set caches that wire into the 3D viewport's hidden-entity set
|
|
13
|
+
* during animation (written through `visibilitySlice.hiddenEntities`).
|
|
14
|
+
*
|
|
15
|
+
* Time is stored as an epoch-millisecond number. When the schedule lacks real
|
|
16
|
+
* dates we fall back to a synthetic range (day 0 … sum-of-durations).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { StateCreator } from 'zustand';
|
|
20
|
+
import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
|
|
21
|
+
import { deterministicGlobalId } from '@ifc-lite/parser';
|
|
22
|
+
import {
|
|
23
|
+
parseIsoDate,
|
|
24
|
+
msToIsoDuration,
|
|
25
|
+
addIsoDurationToEpoch,
|
|
26
|
+
toIsoUtc,
|
|
27
|
+
isoNowAt8,
|
|
28
|
+
reconcileTaskTime,
|
|
29
|
+
cloneExtraction,
|
|
30
|
+
resolveSingleModelId,
|
|
31
|
+
resolveIdOffset,
|
|
32
|
+
} from './schedule-edit-helpers.js';
|
|
33
|
+
|
|
34
|
+
export type GanttTimeScale = 'hour' | 'day' | 'week' | 'month' | 'year';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Undo / redo entry — discriminated union with two kinds:
|
|
38
|
+
*
|
|
39
|
+
* • `kind: 'full'` — captures the entire `scheduleData` as a deep clone.
|
|
40
|
+
* Used by structural edits (add / delete / move / assign / unassign)
|
|
41
|
+
* and by transaction-begin, where the set of affected fields is
|
|
42
|
+
* hard to bound ahead of time.
|
|
43
|
+
*
|
|
44
|
+
* • `kind: 'fieldPatch'` — captures only the before-state of the fields
|
|
45
|
+
* that changed on a single task. Used by `updateTask` and
|
|
46
|
+
* `updateTaskTime` (the common case — typing a name, dragging a bar).
|
|
47
|
+
* ~100 bytes per entry vs ~20 KB for a full clone of a 500-task
|
|
48
|
+
* schedule, which matters at the 50-entry stack cap.
|
|
49
|
+
*
|
|
50
|
+
* `label` surfaces in the UI toast so users see "Undone: edit task
|
|
51
|
+
* name" rather than a generic message. `priorRange` + `priorIsEdited`
|
|
52
|
+
* are captured on both kinds so undo restores derived state + the
|
|
53
|
+
* pending-edit flag correctly (recomputing is cheap but doesn't tell
|
|
54
|
+
* us whether the schedule was "clean" before the edit — an edit sequence
|
|
55
|
+
* could span crossings of that flag).
|
|
56
|
+
*/
|
|
57
|
+
export interface ScheduleFullSnapshot {
|
|
58
|
+
kind: 'full';
|
|
59
|
+
label: string;
|
|
60
|
+
data: ScheduleExtraction | null;
|
|
61
|
+
range: ScheduleTimeRange | null;
|
|
62
|
+
isEdited: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ScheduleFieldPatchSnapshot {
|
|
66
|
+
kind: 'fieldPatch';
|
|
67
|
+
label: string;
|
|
68
|
+
taskGlobalId: string;
|
|
69
|
+
/** Fields on the task as they were BEFORE the edit. Sparse. */
|
|
70
|
+
before: Partial<ScheduleTaskInfo>;
|
|
71
|
+
/** Range before the edit — restored verbatim on undo. */
|
|
72
|
+
priorRange: ScheduleTimeRange | null;
|
|
73
|
+
/** `scheduleIsEdited` flag before the edit. */
|
|
74
|
+
priorIsEdited: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ScheduleSnapshot = ScheduleFullSnapshot | ScheduleFieldPatchSnapshot;
|
|
78
|
+
|
|
79
|
+
export interface ScheduleTimeRange {
|
|
80
|
+
/** Earliest task start time, epoch ms. */
|
|
81
|
+
start: number;
|
|
82
|
+
/** Latest task finish time, epoch ms. */
|
|
83
|
+
end: number;
|
|
84
|
+
/** true when task dates were synthesized from durations (no ScheduleStart values). */
|
|
85
|
+
synthetic: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ScheduleSlice {
|
|
89
|
+
// ── Data ──────────────────────────────────────────────
|
|
90
|
+
/** Extracted schedule data for the currently loaded model(s). */
|
|
91
|
+
scheduleData: ScheduleExtraction | null;
|
|
92
|
+
/** Pre-computed min/max date range across all tasks with dates. */
|
|
93
|
+
scheduleRange: ScheduleTimeRange | null;
|
|
94
|
+
/** Currently focused work schedule globalId ('' = show all tasks). */
|
|
95
|
+
activeWorkScheduleId: string;
|
|
96
|
+
|
|
97
|
+
// ── Panel UI ──────────────────────────────────────────
|
|
98
|
+
ganttPanelVisible: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Generate-schedule-from-storeys dialog open flag. Lives in the slice (not
|
|
101
|
+
* local component state) so the command palette and other entry points can
|
|
102
|
+
* open it without coupling to GanttPanel's render tree.
|
|
103
|
+
*/
|
|
104
|
+
generateScheduleDialogOpen: boolean;
|
|
105
|
+
/** globalIds of expanded rows in the task tree. */
|
|
106
|
+
expandedTaskGlobalIds: Set<string>;
|
|
107
|
+
/** globalId currently hovered in the Gantt timeline. */
|
|
108
|
+
hoveredTaskGlobalId: string | null;
|
|
109
|
+
/** globalIds currently selected in the Gantt (separate from viewport selection). */
|
|
110
|
+
selectedTaskGlobalIds: Set<string>;
|
|
111
|
+
/** Timeline zoom scale. */
|
|
112
|
+
ganttTimeScale: GanttTimeScale;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Model the current `scheduleData` is attributed to, for federation +
|
|
116
|
+
* dirty-tracking integration. Set by `commitGeneratedSchedule` when the
|
|
117
|
+
* user generates a schedule from the spatial hierarchy; remains null
|
|
118
|
+
* when the schedule came from extraction (extracted tasks already exist
|
|
119
|
+
* in the host STEP file and aren't "pending").
|
|
120
|
+
*/
|
|
121
|
+
scheduleSourceModelId: string | null;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* True when any schedule edit (updateTask / updateTaskTime / assign /
|
|
125
|
+
* unassign / deleteTask / sequence mutation) has diverged the in-memory
|
|
126
|
+
* `scheduleData` from whatever the host STEP file currently has on disk.
|
|
127
|
+
*
|
|
128
|
+
* When set, the export path rewrites the entire schedule block (strips
|
|
129
|
+
* the original entities, re-emits from `scheduleData`). Cheaper than
|
|
130
|
+
* per-entity diffing and eliminates whole classes of dangling-reference
|
|
131
|
+
* bugs when dependent entities (`IfcTaskTime`, `IfcLagTime`, `IfcRel*`)
|
|
132
|
+
* cascade on task deletion.
|
|
133
|
+
*
|
|
134
|
+
* Distinct from "has generated tasks" (`expressId <= 0`): a schedule can
|
|
135
|
+
* be edited without any generated tasks (e.g. renamed a parsed task) and
|
|
136
|
+
* must still trigger the rewrite.
|
|
137
|
+
*/
|
|
138
|
+
scheduleIsEdited: boolean;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Snapshot undo/redo stacks for schedule edits. Each entry is a deep
|
|
142
|
+
* clone of `scheduleData` taken BEFORE a mutator ran, so undo restores
|
|
143
|
+
* the exact pre-mutation state byte-for-byte. Stack caps at 50 — older
|
|
144
|
+
* snapshots are dropped from the bottom on overflow.
|
|
145
|
+
*
|
|
146
|
+
* Transactions: mutators invoked inside a `beginScheduleTransaction()` /
|
|
147
|
+
* `endScheduleTransaction()` pair push exactly ONE snapshot at begin,
|
|
148
|
+
* so a drag-gesture that fires 60 updateTaskTime calls still undoes as
|
|
149
|
+
* one user-visible step.
|
|
150
|
+
*/
|
|
151
|
+
scheduleUndoStack: ScheduleSnapshot[];
|
|
152
|
+
scheduleRedoStack: ScheduleSnapshot[];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Transaction state for the edit pipeline. Lives in the store (not at
|
|
156
|
+
* module scope) so parallel test stores, hot-reloaded sessions, and
|
|
157
|
+
* multiple mounted viewer instances each have their own transaction
|
|
158
|
+
* window. `pushedAt` tracks the stack depth at which `beginScheduleTransaction`
|
|
159
|
+
* snapshotted, so `abortScheduleTransaction` can precisely unwind.
|
|
160
|
+
*/
|
|
161
|
+
scheduleTransaction: { active: boolean; label: string; pushedAt: number };
|
|
162
|
+
|
|
163
|
+
// ── Actions ──────────────────────────────────────────
|
|
164
|
+
setScheduleData: (data: ScheduleExtraction | null) => void;
|
|
165
|
+
setGanttPanelVisible: (visible: boolean) => void;
|
|
166
|
+
toggleGanttPanel: () => void;
|
|
167
|
+
setActiveWorkScheduleId: (globalId: string) => void;
|
|
168
|
+
setGanttTimeScale: (scale: GanttTimeScale) => void;
|
|
169
|
+
|
|
170
|
+
setGenerateScheduleDialogOpen: (open: boolean) => void;
|
|
171
|
+
|
|
172
|
+
toggleTaskExpanded: (globalId: string) => void;
|
|
173
|
+
expandAllTasks: () => void;
|
|
174
|
+
collapseAllTasks: () => void;
|
|
175
|
+
setHoveredTaskGlobalId: (globalId: string | null) => void;
|
|
176
|
+
setSelectedTaskGlobalIds: (globalIds: string[]) => void;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Commit a *generated* schedule (from the Generate dialog) as a first-
|
|
180
|
+
* class pending edit. Sets scheduleData + sourceModelId, marks the
|
|
181
|
+
* source model as dirty, and bumps the mutation version so every
|
|
182
|
+
* export-badge selector repaints.
|
|
183
|
+
*
|
|
184
|
+
* Extracted schedules go through `setScheduleData(data)` without a
|
|
185
|
+
* sourceModelId — they're already in the host file, not pending.
|
|
186
|
+
*/
|
|
187
|
+
commitGeneratedSchedule: (data: ScheduleExtraction, sourceModelId: string) => void;
|
|
188
|
+
/**
|
|
189
|
+
* Discard the generated tail of the current schedule — tasks with
|
|
190
|
+
* `expressId <= 0` or missing. Keeps extracted tasks intact so
|
|
191
|
+
* partial-authoring workflows (parsed schedule + user-appended task)
|
|
192
|
+
* still reset cleanly. Returns the number of tasks removed.
|
|
193
|
+
*/
|
|
194
|
+
clearGeneratedSchedule: () => number;
|
|
195
|
+
|
|
196
|
+
// ── Schedule editing (P1) ──────────────────────────────
|
|
197
|
+
/**
|
|
198
|
+
* Patch a task's identity / descriptive fields. Silently no-ops when
|
|
199
|
+
* the globalId isn't found. Enabling `isMilestone: true` also forces
|
|
200
|
+
* `taskTime.scheduleDuration` to `PT0S` and `scheduleFinish` equal to
|
|
201
|
+
* `scheduleStart` so the Gantt renders the diamond correctly.
|
|
202
|
+
*/
|
|
203
|
+
updateTask: (
|
|
204
|
+
globalId: string,
|
|
205
|
+
patch: Partial<Pick<ScheduleTaskInfo,
|
|
206
|
+
'name' | 'identification' | 'description' | 'longDescription'
|
|
207
|
+
| 'objectType' | 'predefinedType' | 'isMilestone'>>,
|
|
208
|
+
) => void;
|
|
209
|
+
/**
|
|
210
|
+
* Patch a task's schedule time fields. The three related values
|
|
211
|
+
* (`scheduleStart`, `scheduleFinish`, `scheduleDuration`) are kept
|
|
212
|
+
* internally consistent: if the caller supplies any two, the third is
|
|
213
|
+
* recomputed; if only one is supplied, the others are preserved from
|
|
214
|
+
* the existing `taskTime`. Rejects finish-before-start by ignoring
|
|
215
|
+
* that patch (caller can surface a validation error).
|
|
216
|
+
*/
|
|
217
|
+
updateTaskTime: (
|
|
218
|
+
globalId: string,
|
|
219
|
+
patch: { scheduleStart?: string; scheduleFinish?: string; scheduleDuration?: string },
|
|
220
|
+
) => void;
|
|
221
|
+
/**
|
|
222
|
+
* Append products (renderer-space global IDs) to a task's assignment
|
|
223
|
+
* list. Federation-aware: globals are translated to local expressIds
|
|
224
|
+
* using the active source model's `idOffset`. De-duplicates against
|
|
225
|
+
* existing membership so double-clicking "Add" is safe.
|
|
226
|
+
*/
|
|
227
|
+
assignProductsToTask: (taskGlobalId: string, globalProductIds: number[]) => void;
|
|
228
|
+
/** Remove products (global IDs) from a task's assignment list. */
|
|
229
|
+
unassignProductsFromTask: (taskGlobalId: string, globalProductIds: number[]) => void;
|
|
230
|
+
/**
|
|
231
|
+
* Delete a task and cascade-clean dependent entities:
|
|
232
|
+
* • drop `IfcRelSequence` edges that reference it (either side)
|
|
233
|
+
* • reparent children to the deleted task's parent (or detach to root)
|
|
234
|
+
* • remove from the owning work-schedule's `taskGlobalIds`
|
|
235
|
+
*/
|
|
236
|
+
deleteTask: (globalId: string) => void;
|
|
237
|
+
/**
|
|
238
|
+
* Create a brand-new task and insert it after `afterGlobalId` (or at the
|
|
239
|
+
* end of the root-task list when absent). Inherits PredefinedType from
|
|
240
|
+
* the task it's inserted after when possible; duration defaults to 5
|
|
241
|
+
* days; start anchors to the predecessor's finish + 1 day (or the
|
|
242
|
+
* schedule range's start when there's no predecessor).
|
|
243
|
+
*
|
|
244
|
+
* Returns the new task's globalId so callers can immediately select it
|
|
245
|
+
* (the Gantt toolbar's "+ Task" button does this for a rename-right-
|
|
246
|
+
* away flow).
|
|
247
|
+
*/
|
|
248
|
+
addTask: (options?: {
|
|
249
|
+
afterGlobalId?: string;
|
|
250
|
+
parentGlobalId?: string | null;
|
|
251
|
+
nameDefault?: string;
|
|
252
|
+
predefinedTypeDefault?: string;
|
|
253
|
+
durationDays?: number;
|
|
254
|
+
}) => string;
|
|
255
|
+
/**
|
|
256
|
+
* Move a task to a new position in the flat root-order. v1 keeps the
|
|
257
|
+
* parent unchanged (no re-parenting from tree drag yet — that's a
|
|
258
|
+
* larger sub-feature). `newIndex` is interpreted in the context of
|
|
259
|
+
* the current root-ordering, i.e. the position in
|
|
260
|
+
* `workSchedule.taskGlobalIds`.
|
|
261
|
+
*/
|
|
262
|
+
moveTask: (globalId: string, newIndex: number) => void;
|
|
263
|
+
|
|
264
|
+
// ── Undo / redo ────────────────────────────────────────
|
|
265
|
+
undoScheduleEdit: () => void;
|
|
266
|
+
redoScheduleEdit: () => void;
|
|
267
|
+
/**
|
|
268
|
+
* Transactions coalesce a burst of rapid edits (bar drag, typing into
|
|
269
|
+
* a field) into a single undo step: one snapshot at begin, later
|
|
270
|
+
* mutators skip snapshotting until `endScheduleTransaction` closes the
|
|
271
|
+
* window. `abortScheduleTransaction` pops the snapshot we pushed at
|
|
272
|
+
* begin — use it when a drag is Esc-cancelled.
|
|
273
|
+
*/
|
|
274
|
+
beginScheduleTransaction: (label: string) => void;
|
|
275
|
+
endScheduleTransaction: () => void;
|
|
276
|
+
abortScheduleTransaction: () => void;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Derive a plausible finish time for a task when `ScheduleFinish` is absent.
|
|
281
|
+
* Uses ScheduleDuration (ISO 8601 seconds) on top of ScheduleStart. Returns
|
|
282
|
+
* undefined when no start time is available.
|
|
283
|
+
*/
|
|
284
|
+
function taskFinishEpoch(task: ScheduleTaskInfo): number | undefined {
|
|
285
|
+
const start = parseIsoDate(task.taskTime?.scheduleStart ?? task.taskTime?.actualStart);
|
|
286
|
+
const finish = parseIsoDate(task.taskTime?.scheduleFinish ?? task.taskTime?.actualFinish);
|
|
287
|
+
if (finish !== undefined) return finish;
|
|
288
|
+
if (start === undefined) return undefined;
|
|
289
|
+
const duration = task.taskTime?.scheduleDuration ?? task.taskTime?.actualDuration;
|
|
290
|
+
if (!duration) return start;
|
|
291
|
+
const match = duration.match(
|
|
292
|
+
/^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/,
|
|
293
|
+
);
|
|
294
|
+
if (!match) return start;
|
|
295
|
+
const [, y, mo, w, d, h, mi, s] = match;
|
|
296
|
+
const yearMs = 365.2425 * 86400_000;
|
|
297
|
+
const monthMs = yearMs / 12;
|
|
298
|
+
const totalMs =
|
|
299
|
+
(y ? parseFloat(y) * yearMs : 0) +
|
|
300
|
+
(mo ? parseFloat(mo) * monthMs : 0) +
|
|
301
|
+
(w ? parseFloat(w) * 7 * 86400_000 : 0) +
|
|
302
|
+
(d ? parseFloat(d) * 86400_000 : 0) +
|
|
303
|
+
(h ? parseFloat(h) * 3_600_000 : 0) +
|
|
304
|
+
(mi ? parseFloat(mi) * 60_000 : 0) +
|
|
305
|
+
(s ? parseFloat(s) * 1000 : 0);
|
|
306
|
+
return start + totalMs;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function taskStartEpoch(task: ScheduleTaskInfo): number | undefined {
|
|
310
|
+
return parseIsoDate(task.taskTime?.scheduleStart ?? task.taskTime?.actualStart);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Compute the schedule time range across all tasks. Prefers real dates from
|
|
315
|
+
* TaskTime attributes; falls back to a synthetic 0 … max-duration window.
|
|
316
|
+
*/
|
|
317
|
+
export function computeScheduleRange(data: ScheduleExtraction | null): ScheduleTimeRange | null {
|
|
318
|
+
if (!data || data.tasks.length === 0) return null;
|
|
319
|
+
let min = Number.POSITIVE_INFINITY;
|
|
320
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
321
|
+
for (const task of data.tasks) {
|
|
322
|
+
const start = taskStartEpoch(task);
|
|
323
|
+
const finish = taskFinishEpoch(task);
|
|
324
|
+
// Use whichever datapoint we have — a task with only ScheduleFinish still
|
|
325
|
+
// anchors the range. Folding start into `max` (and finish into `min`) keeps
|
|
326
|
+
// the range deterministic even when only one end is defined.
|
|
327
|
+
if (start !== undefined) {
|
|
328
|
+
min = Math.min(min, start);
|
|
329
|
+
max = Math.max(max, start);
|
|
330
|
+
}
|
|
331
|
+
if (finish !== undefined) {
|
|
332
|
+
min = Math.min(min, finish);
|
|
333
|
+
max = Math.max(max, finish);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (Number.isFinite(min) && Number.isFinite(max) && max >= min) {
|
|
337
|
+
// Single-point schedules get a nominal 1-day tail so the Gantt has something to render.
|
|
338
|
+
return { start: min, end: max === min ? min + 86_400_000 : max, synthetic: false };
|
|
339
|
+
}
|
|
340
|
+
// No dates anywhere — synthesize a deterministic day-0 / +30d window keyed on
|
|
341
|
+
// the task count so playback state survives reloads of the same model.
|
|
342
|
+
const base = 0;
|
|
343
|
+
return { start: base, end: base + 30 * 86_400_000, synthetic: true };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Fields that live on OTHER slices but are read/written from the schedule
|
|
348
|
+
* slice. Listed here so the StateCreator's generic parameter includes
|
|
349
|
+
* them — this eliminates the `as unknown as { ... }` casts we'd otherwise
|
|
350
|
+
* need at every cross-slice site and turns type errors on the other
|
|
351
|
+
* slice's shape into compile errors here instead of silent runtime bugs.
|
|
352
|
+
*
|
|
353
|
+
* Kept as a small weakly-typed projection rather than importing the
|
|
354
|
+
* owning slices' types (which would create a cycle through index.ts).
|
|
355
|
+
*/
|
|
356
|
+
interface ScheduleCrossSliceReads {
|
|
357
|
+
/** modelSlice — id of the model the user is currently focused on. */
|
|
358
|
+
activeModelId?: string | null;
|
|
359
|
+
/** modelSlice — every model the viewer knows about, keyed by id. */
|
|
360
|
+
models?: Map<string, { idOffset?: number }>;
|
|
361
|
+
/** mutationSlice — set of model ids that have pending edits. */
|
|
362
|
+
dirtyModels?: Set<string>;
|
|
363
|
+
/** mutationSlice — monotonic version number; bumped on every write. */
|
|
364
|
+
mutationVersion?: number;
|
|
365
|
+
/** mutationSlice — per-model override views for property edits. */
|
|
366
|
+
mutationViews?: Map<string, unknown>;
|
|
367
|
+
/** mutationSlice — per-model georef overrides. */
|
|
368
|
+
georefMutations?: Map<string, unknown>;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export const createScheduleSlice: StateCreator<
|
|
372
|
+
ScheduleSlice & ScheduleCrossSliceReads,
|
|
373
|
+
[],
|
|
374
|
+
[],
|
|
375
|
+
ScheduleSlice
|
|
376
|
+
> = (set, get) => ({
|
|
377
|
+
// Initial state
|
|
378
|
+
scheduleData: null,
|
|
379
|
+
scheduleRange: null,
|
|
380
|
+
activeWorkScheduleId: '',
|
|
381
|
+
ganttPanelVisible: false,
|
|
382
|
+
generateScheduleDialogOpen: false,
|
|
383
|
+
expandedTaskGlobalIds: new Set(),
|
|
384
|
+
hoveredTaskGlobalId: null,
|
|
385
|
+
selectedTaskGlobalIds: new Set(),
|
|
386
|
+
ganttTimeScale: 'week',
|
|
387
|
+
scheduleSourceModelId: null,
|
|
388
|
+
scheduleIsEdited: false,
|
|
389
|
+
scheduleUndoStack: [],
|
|
390
|
+
scheduleRedoStack: [],
|
|
391
|
+
scheduleTransaction: { active: false, label: '', pushedAt: -1 },
|
|
392
|
+
|
|
393
|
+
// Actions
|
|
394
|
+
setScheduleData: (scheduleData) => {
|
|
395
|
+
const range = computeScheduleRange(scheduleData);
|
|
396
|
+
// Extracted-schedule attribution: even though the schedule wasn't
|
|
397
|
+
// user-generated, every downstream consumer (export splice gate,
|
|
398
|
+
// `mutationSlice.hasChanges`, the Inspector's pending chip) cares
|
|
399
|
+
// about "which model does this schedule live in?". Previously
|
|
400
|
+
// sourceModelId was only set by `commitGeneratedSchedule`, which
|
|
401
|
+
// meant extracted schedules surfaced as `null` and downstream
|
|
402
|
+
// reads had to fall through a single-model heuristic to cope. Now
|
|
403
|
+
// we populate it from the active model at every `setScheduleData`
|
|
404
|
+
// call so the field is always truthful.
|
|
405
|
+
set(state => {
|
|
406
|
+
const activeId = state.activeModelId ?? null;
|
|
407
|
+
const single = state.models && state.models.size === 1
|
|
408
|
+
? (state.models.keys().next().value as string | undefined) ?? null
|
|
409
|
+
: null;
|
|
410
|
+
const derivedSourceModelId = scheduleData ? (activeId ?? single) : null;
|
|
411
|
+
return {
|
|
412
|
+
scheduleData,
|
|
413
|
+
scheduleRange: range,
|
|
414
|
+
// Reset playback to the schedule's start when loading new data.
|
|
415
|
+
playbackTime: range?.start ?? 0,
|
|
416
|
+
playbackIsPlaying: false,
|
|
417
|
+
// Pick the first work schedule by default.
|
|
418
|
+
activeWorkScheduleId: scheduleData?.workSchedules[0]?.globalId ?? '',
|
|
419
|
+
// Expand roots by default so the user sees something.
|
|
420
|
+
expandedTaskGlobalIds: new Set(
|
|
421
|
+
scheduleData?.tasks.filter(t => !t.parentGlobalId).map(t => t.globalId) ?? [],
|
|
422
|
+
),
|
|
423
|
+
selectedTaskGlobalIds: new Set(),
|
|
424
|
+
hoveredTaskGlobalId: null,
|
|
425
|
+
scheduleSourceModelId: derivedSourceModelId,
|
|
426
|
+
// New data = clean slate. Edit state from a prior schedule doesn't
|
|
427
|
+
// carry over.
|
|
428
|
+
scheduleIsEdited: false,
|
|
429
|
+
scheduleUndoStack: [],
|
|
430
|
+
scheduleRedoStack: [],
|
|
431
|
+
// Any in-flight transaction is abandoned when fresh data is loaded.
|
|
432
|
+
scheduleTransaction: { active: false, label: '', pushedAt: -1 },
|
|
433
|
+
} as Partial<ScheduleSlice>;
|
|
434
|
+
});
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
setGanttPanelVisible: (ganttPanelVisible) => set({ ganttPanelVisible }),
|
|
438
|
+
toggleGanttPanel: () => set((s) => ({ ganttPanelVisible: !s.ganttPanelVisible })),
|
|
439
|
+
|
|
440
|
+
setActiveWorkScheduleId: (activeWorkScheduleId) => set({ activeWorkScheduleId }),
|
|
441
|
+
setGanttTimeScale: (ganttTimeScale) => set({ ganttTimeScale }),
|
|
442
|
+
|
|
443
|
+
setGenerateScheduleDialogOpen: (generateScheduleDialogOpen) => set({ generateScheduleDialogOpen }),
|
|
444
|
+
|
|
445
|
+
toggleTaskExpanded: (globalId) => set((s) => {
|
|
446
|
+
const next = new Set(s.expandedTaskGlobalIds);
|
|
447
|
+
if (next.has(globalId)) next.delete(globalId);
|
|
448
|
+
else next.add(globalId);
|
|
449
|
+
return { expandedTaskGlobalIds: next };
|
|
450
|
+
}),
|
|
451
|
+
expandAllTasks: () => set((s) => ({
|
|
452
|
+
expandedTaskGlobalIds: new Set(s.scheduleData?.tasks.map(t => t.globalId) ?? []),
|
|
453
|
+
})),
|
|
454
|
+
collapseAllTasks: () => set({ expandedTaskGlobalIds: new Set() }),
|
|
455
|
+
|
|
456
|
+
setHoveredTaskGlobalId: (hoveredTaskGlobalId) => set({ hoveredTaskGlobalId }),
|
|
457
|
+
setSelectedTaskGlobalIds: (ids) => set({ selectedTaskGlobalIds: new Set(ids) }),
|
|
458
|
+
|
|
459
|
+
commitGeneratedSchedule: (data, sourceModelId) => {
|
|
460
|
+
const range = computeScheduleRange(data);
|
|
461
|
+
set(state => {
|
|
462
|
+
const newDirty = new Set(state.dirtyModels);
|
|
463
|
+
newDirty.add(sourceModelId);
|
|
464
|
+
const bump = (state.mutationVersion ?? 0) + 1;
|
|
465
|
+
return {
|
|
466
|
+
scheduleData: data,
|
|
467
|
+
scheduleRange: range,
|
|
468
|
+
scheduleSourceModelId: sourceModelId,
|
|
469
|
+
playbackTime: range?.start ?? 0,
|
|
470
|
+
playbackIsPlaying: false,
|
|
471
|
+
activeWorkScheduleId: data.workSchedules[0]?.globalId ?? '',
|
|
472
|
+
expandedTaskGlobalIds: new Set(
|
|
473
|
+
data.tasks.filter(t => !t.parentGlobalId).map(t => t.globalId),
|
|
474
|
+
),
|
|
475
|
+
selectedTaskGlobalIds: new Set(),
|
|
476
|
+
hoveredTaskGlobalId: null,
|
|
477
|
+
// Generated schedules haven't been edited (they're brand-new);
|
|
478
|
+
// stacks reset so users can't undo back to a pre-generation state
|
|
479
|
+
// via the schedule undo UI (keeps the semantic crisp).
|
|
480
|
+
scheduleIsEdited: false,
|
|
481
|
+
scheduleUndoStack: [],
|
|
482
|
+
scheduleRedoStack: [],
|
|
483
|
+
scheduleTransaction: { active: false, label: '', pushedAt: -1 },
|
|
484
|
+
// Cross-slice writes live behind a cast because the slice creator
|
|
485
|
+
// is only typed for its own shape; the store combines slices so
|
|
486
|
+
// these fields exist at runtime.
|
|
487
|
+
dirtyModels: newDirty,
|
|
488
|
+
mutationVersion: bump,
|
|
489
|
+
} as Partial<ScheduleSlice>;
|
|
490
|
+
});
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
clearGeneratedSchedule: () => {
|
|
494
|
+
const current = get().scheduleData;
|
|
495
|
+
if (!current || current.tasks.length === 0) return 0;
|
|
496
|
+
|
|
497
|
+
const keptTasks = current.tasks.filter(t => t.expressId && t.expressId > 0);
|
|
498
|
+
const removed = current.tasks.length - keptTasks.length;
|
|
499
|
+
if (removed === 0) return 0;
|
|
500
|
+
|
|
501
|
+
const keptTaskGlobalIds = new Set(keptTasks.map(t => t.globalId));
|
|
502
|
+
// Drop sequences that pointed at removed tasks so the STEP never ends
|
|
503
|
+
// up with a dangling IfcRelSequence referencing a deleted task.
|
|
504
|
+
const keptSequences = current.sequences.filter(
|
|
505
|
+
s => keptTaskGlobalIds.has(s.relatingTaskGlobalId)
|
|
506
|
+
&& keptTaskGlobalIds.has(s.relatedTaskGlobalId),
|
|
507
|
+
);
|
|
508
|
+
// Work schedules are authored per-generation — if we have no tasks
|
|
509
|
+
// left, drop them too; otherwise keep whichever ones still control a
|
|
510
|
+
// surviving task.
|
|
511
|
+
const keptSchedules = keptTasks.length === 0
|
|
512
|
+
? []
|
|
513
|
+
: current.workSchedules.filter(ws =>
|
|
514
|
+
keptTasks.some(t => t.controllingScheduleGlobalIds.includes(ws.globalId)),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const next: ScheduleExtraction = keptTasks.length === 0
|
|
518
|
+
? { hasSchedule: false, workSchedules: [], tasks: [], sequences: [] }
|
|
519
|
+
: { hasSchedule: true, workSchedules: keptSchedules, tasks: keptTasks, sequences: keptSequences };
|
|
520
|
+
|
|
521
|
+
const nextRange = computeScheduleRange(keptTasks.length === 0 ? null : next);
|
|
522
|
+
const sourceModelId = get().scheduleSourceModelId;
|
|
523
|
+
|
|
524
|
+
set(state => {
|
|
525
|
+
// Only remove the model from `dirtyModels` if this was its ONLY
|
|
526
|
+
// outstanding edit — property / georef mutations keep it dirty.
|
|
527
|
+
const newDirty = new Set(state.dirtyModels);
|
|
528
|
+
if (sourceModelId) {
|
|
529
|
+
const hasPropertyEdits = state.mutationViews?.has(sourceModelId) ?? false;
|
|
530
|
+
const hasGeorefEdits = state.georefMutations?.has(sourceModelId) ?? false;
|
|
531
|
+
if (!hasPropertyEdits && !hasGeorefEdits) {
|
|
532
|
+
newDirty.delete(sourceModelId);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const bump = (state.mutationVersion ?? 0) + 1;
|
|
536
|
+
return {
|
|
537
|
+
scheduleData: keptTasks.length === 0 ? null : next,
|
|
538
|
+
scheduleRange: nextRange,
|
|
539
|
+
scheduleSourceModelId: keptTasks.length === 0 ? null : sourceModelId,
|
|
540
|
+
playbackTime: nextRange?.start ?? 0,
|
|
541
|
+
playbackIsPlaying: false,
|
|
542
|
+
selectedTaskGlobalIds: new Set(),
|
|
543
|
+
hoveredTaskGlobalId: null,
|
|
544
|
+
// Discarding generated tasks resets the edit state to "clean"
|
|
545
|
+
// for the remaining parsed schedule. Any undo history from
|
|
546
|
+
// prior edits is also dropped — you can't undo a discard.
|
|
547
|
+
scheduleIsEdited: false,
|
|
548
|
+
scheduleUndoStack: [],
|
|
549
|
+
scheduleRedoStack: [],
|
|
550
|
+
scheduleTransaction: { active: false, label: '', pushedAt: -1 },
|
|
551
|
+
dirtyModels: newDirty,
|
|
552
|
+
mutationVersion: bump,
|
|
553
|
+
} as Partial<ScheduleSlice>;
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
return removed;
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
560
|
+
// Schedule editing (P1)
|
|
561
|
+
//
|
|
562
|
+
// All mutators funnel through the same pattern:
|
|
563
|
+
// 1. If not inside a transaction, push a pre-mutation snapshot.
|
|
564
|
+
// 2. Clone + patch `scheduleData` (deep enough — tasks/sequences/ws
|
|
565
|
+
// are shallow-patched, but the containing collection is rebuilt so
|
|
566
|
+
// Zustand's identity-based subscriptions fire).
|
|
567
|
+
// 3. Recompute `scheduleRange` if time-affecting fields changed.
|
|
568
|
+
// 4. Set `scheduleIsEdited = true`, mark the source model dirty, bump
|
|
569
|
+
// `mutationVersion` via the cross-slice cast so the export badge
|
|
570
|
+
// re-renders.
|
|
571
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
572
|
+
|
|
573
|
+
updateTask: (globalId, patch) => {
|
|
574
|
+
const current = get().scheduleData;
|
|
575
|
+
if (!current) return;
|
|
576
|
+
const idx = current.tasks.findIndex(t => t.globalId === globalId);
|
|
577
|
+
if (idx < 0) return;
|
|
578
|
+
|
|
579
|
+
// Which fields the patch actually touches — we snapshot exactly
|
|
580
|
+
// these + `taskTime` if the milestone-collapse path applies, so
|
|
581
|
+
// undo restores the minimum needed to reverse the edit.
|
|
582
|
+
const touchedKeys: Array<keyof ScheduleTaskInfo> = [];
|
|
583
|
+
if (patch.name !== undefined) touchedKeys.push('name');
|
|
584
|
+
if (patch.identification !== undefined) touchedKeys.push('identification');
|
|
585
|
+
if (patch.description !== undefined) touchedKeys.push('description');
|
|
586
|
+
if (patch.longDescription !== undefined) touchedKeys.push('longDescription');
|
|
587
|
+
if (patch.objectType !== undefined) touchedKeys.push('objectType');
|
|
588
|
+
if (patch.predefinedType !== undefined) touchedKeys.push('predefinedType');
|
|
589
|
+
if (patch.isMilestone !== undefined) {
|
|
590
|
+
touchedKeys.push('isMilestone');
|
|
591
|
+
if (patch.isMilestone) touchedKeys.push('taskTime');
|
|
592
|
+
}
|
|
593
|
+
if (touchedKeys.length === 0) return;
|
|
594
|
+
|
|
595
|
+
const beforeFields = pickExistingFields(current.tasks[idx], touchedKeys);
|
|
596
|
+
pushFieldPatchSnapshot(
|
|
597
|
+
get, set,
|
|
598
|
+
`Edit task: ${current.tasks[idx].name || 'untitled'}`,
|
|
599
|
+
globalId,
|
|
600
|
+
beforeFields,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
const next = cloneExtraction(current);
|
|
604
|
+
const t = next.tasks[idx];
|
|
605
|
+
if (patch.name !== undefined) t.name = patch.name;
|
|
606
|
+
if (patch.identification !== undefined) t.identification = patch.identification;
|
|
607
|
+
if (patch.description !== undefined) t.description = patch.description;
|
|
608
|
+
if (patch.longDescription !== undefined) t.longDescription = patch.longDescription;
|
|
609
|
+
if (patch.objectType !== undefined) t.objectType = patch.objectType;
|
|
610
|
+
if (patch.predefinedType !== undefined) t.predefinedType = patch.predefinedType;
|
|
611
|
+
if (patch.isMilestone !== undefined) {
|
|
612
|
+
t.isMilestone = patch.isMilestone;
|
|
613
|
+
if (patch.isMilestone && t.taskTime) {
|
|
614
|
+
// Milestones have zero duration — collapse finish to start and set
|
|
615
|
+
// PT0S explicitly so the serializer emits it verbatim on export.
|
|
616
|
+
t.taskTime = {
|
|
617
|
+
...t.taskTime,
|
|
618
|
+
scheduleFinish: t.taskTime.scheduleStart ?? t.taskTime.scheduleFinish,
|
|
619
|
+
scheduleDuration: 'PT0S',
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
commitEdit(get, set, next);
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
updateTaskTime: (globalId, patch) => {
|
|
627
|
+
const current = get().scheduleData;
|
|
628
|
+
if (!current) return;
|
|
629
|
+
const idx = current.tasks.findIndex(t => t.globalId === globalId);
|
|
630
|
+
if (idx < 0) return;
|
|
631
|
+
|
|
632
|
+
// Validate BEFORE pushing a snapshot. Previously we'd push then
|
|
633
|
+
// pop-on-reject, which was correct but left the rejected snapshot
|
|
634
|
+
// briefly on the stack and forced the stack-length observers to
|
|
635
|
+
// re-render. With field-patch snapshots we only need the `taskTime`
|
|
636
|
+
// field's prior state, so compute the validation check against a
|
|
637
|
+
// dry-run merge first.
|
|
638
|
+
const prevTimeProbe = current.tasks[idx].taskTime ?? {};
|
|
639
|
+
const mergedProbe = { ...prevTimeProbe, ...patch };
|
|
640
|
+
const reconciledProbe = reconcileTaskTime(mergedProbe);
|
|
641
|
+
if (!reconciledProbe) return; // finish < start — silent reject
|
|
642
|
+
|
|
643
|
+
const beforeFields = pickExistingFields(current.tasks[idx], ['taskTime']);
|
|
644
|
+
pushFieldPatchSnapshot(
|
|
645
|
+
get, set,
|
|
646
|
+
`Edit task time: ${current.tasks[idx].name || 'untitled'}`,
|
|
647
|
+
globalId,
|
|
648
|
+
beforeFields,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
const next = cloneExtraction(current);
|
|
652
|
+
const t = next.tasks[idx];
|
|
653
|
+
const prevTime = t.taskTime ?? {};
|
|
654
|
+
// Combine prior + patch; then reconcile start/finish/duration so
|
|
655
|
+
// whichever pair the user supplied wins and the third is derived.
|
|
656
|
+
const merged = { ...prevTime, ...patch };
|
|
657
|
+
const reconciled = reconcileTaskTime(merged);
|
|
658
|
+
if (!reconciled) {
|
|
659
|
+
// Defensive — the probe above already caught this case, so this
|
|
660
|
+
// branch shouldn't be reachable. Left in so we never commit a
|
|
661
|
+
// malformed taskTime.
|
|
662
|
+
const s = get();
|
|
663
|
+
if (s.scheduleUndoStack.length > 0) {
|
|
664
|
+
const popped = s.scheduleUndoStack.slice(0, -1);
|
|
665
|
+
set({ scheduleUndoStack: popped });
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
t.taskTime = reconciled;
|
|
670
|
+
commitEdit(get, set, next);
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
assignProductsToTask: (taskGlobalId, globalProductIds) => {
|
|
674
|
+
if (globalProductIds.length === 0) return;
|
|
675
|
+
const current = get().scheduleData;
|
|
676
|
+
if (!current) return;
|
|
677
|
+
const idx = current.tasks.findIndex(t => t.globalId === taskGlobalId);
|
|
678
|
+
if (idx < 0) return;
|
|
679
|
+
|
|
680
|
+
// Federation: convert incoming globals → local expressIds using the
|
|
681
|
+
// schedule's source model. When there's no source yet (purely parsed
|
|
682
|
+
// schedule) fall back to the single-model assumption.
|
|
683
|
+
const s = get();
|
|
684
|
+
const sourceModelId = s.scheduleSourceModelId ?? resolveSingleModelId(s);
|
|
685
|
+
const idOffset = resolveIdOffset(s, sourceModelId);
|
|
686
|
+
const toLocal = (g: number): number => g - idOffset;
|
|
687
|
+
|
|
688
|
+
pushScheduleSnapshot(get, set, `Assign ${globalProductIds.length} product(s)`);
|
|
689
|
+
const next = cloneExtraction(current);
|
|
690
|
+
const t = next.tasks[idx];
|
|
691
|
+
const existingLocal = new Set(t.productExpressIds);
|
|
692
|
+
const existingGlobal = new Set(t.productGlobalIds);
|
|
693
|
+
for (const g of globalProductIds) {
|
|
694
|
+
const local = toLocal(g);
|
|
695
|
+
if (!existingLocal.has(local)) {
|
|
696
|
+
t.productExpressIds.push(local);
|
|
697
|
+
existingLocal.add(local);
|
|
698
|
+
}
|
|
699
|
+
const gs = String(g);
|
|
700
|
+
if (!existingGlobal.has(gs)) {
|
|
701
|
+
t.productGlobalIds.push(gs);
|
|
702
|
+
existingGlobal.add(gs);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
commitEdit(get, set, next);
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
unassignProductsFromTask: (taskGlobalId, globalProductIds) => {
|
|
709
|
+
if (globalProductIds.length === 0) return;
|
|
710
|
+
const current = get().scheduleData;
|
|
711
|
+
if (!current) return;
|
|
712
|
+
const idx = current.tasks.findIndex(t => t.globalId === taskGlobalId);
|
|
713
|
+
if (idx < 0) return;
|
|
714
|
+
|
|
715
|
+
const s = get();
|
|
716
|
+
const sourceModelId = s.scheduleSourceModelId ?? resolveSingleModelId(s);
|
|
717
|
+
const idOffset = resolveIdOffset(s, sourceModelId);
|
|
718
|
+
const localsToDrop = new Set(globalProductIds.map(g => g - idOffset));
|
|
719
|
+
const globalsToDrop = new Set(globalProductIds.map(g => String(g)));
|
|
720
|
+
|
|
721
|
+
pushScheduleSnapshot(get, set, `Remove ${globalProductIds.length} product(s)`);
|
|
722
|
+
const next = cloneExtraction(current);
|
|
723
|
+
const t = next.tasks[idx];
|
|
724
|
+
t.productExpressIds = t.productExpressIds.filter(id => !localsToDrop.has(id));
|
|
725
|
+
t.productGlobalIds = t.productGlobalIds.filter(gid => !globalsToDrop.has(gid));
|
|
726
|
+
commitEdit(get, set, next);
|
|
727
|
+
},
|
|
728
|
+
|
|
729
|
+
deleteTask: (globalId) => {
|
|
730
|
+
const current = get().scheduleData;
|
|
731
|
+
if (!current) return;
|
|
732
|
+
const target = current.tasks.find(t => t.globalId === globalId);
|
|
733
|
+
if (!target) return;
|
|
734
|
+
|
|
735
|
+
pushScheduleSnapshot(get, set, `Delete task: ${target.name || 'untitled'}`);
|
|
736
|
+
const next = cloneExtraction(current);
|
|
737
|
+
|
|
738
|
+
// Collect every descendant so we can also remove their tasks and the
|
|
739
|
+
// sequences that reference them (cycle-safe BFS — we trust the tree
|
|
740
|
+
// but don't assume it).
|
|
741
|
+
const byId = new Map(next.tasks.map(t => [t.globalId, t] as const));
|
|
742
|
+
const doomedIds = new Set<string>();
|
|
743
|
+
const queue: string[] = [globalId];
|
|
744
|
+
while (queue.length > 0) {
|
|
745
|
+
const g = queue.shift()!;
|
|
746
|
+
if (doomedIds.has(g)) continue;
|
|
747
|
+
doomedIds.add(g);
|
|
748
|
+
const task = byId.get(g);
|
|
749
|
+
if (!task) continue;
|
|
750
|
+
for (const child of task.childGlobalIds) {
|
|
751
|
+
if (!doomedIds.has(child)) queue.push(child);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Reparent any survivors that reference a doomed parent (shouldn't
|
|
756
|
+
// happen if the tree is well-formed, but doomedIds covers descendants
|
|
757
|
+
// so this only catches weird multi-parent edges if they exist).
|
|
758
|
+
for (const t of next.tasks) {
|
|
759
|
+
if (doomedIds.has(t.globalId)) continue;
|
|
760
|
+
if (t.parentGlobalId && doomedIds.has(t.parentGlobalId)) {
|
|
761
|
+
t.parentGlobalId = target.parentGlobalId;
|
|
762
|
+
}
|
|
763
|
+
// Remove doomed children from surviving tasks' child lists.
|
|
764
|
+
if (t.childGlobalIds.some(c => doomedIds.has(c))) {
|
|
765
|
+
t.childGlobalIds = t.childGlobalIds.filter(c => !doomedIds.has(c));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
next.tasks = next.tasks.filter(t => !doomedIds.has(t.globalId));
|
|
770
|
+
next.sequences = next.sequences.filter(s =>
|
|
771
|
+
!doomedIds.has(s.relatingTaskGlobalId) && !doomedIds.has(s.relatedTaskGlobalId),
|
|
772
|
+
);
|
|
773
|
+
// Remove doomed task ids from any work schedule's taskGlobalIds.
|
|
774
|
+
for (const ws of next.workSchedules) {
|
|
775
|
+
if (ws.taskGlobalIds.some(g => doomedIds.has(g))) {
|
|
776
|
+
ws.taskGlobalIds = ws.taskGlobalIds.filter(g => !doomedIds.has(g));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (next.tasks.length === 0) next.hasSchedule = false;
|
|
780
|
+
|
|
781
|
+
// Also drop the deleted ids from the selection set so the Inspector
|
|
782
|
+
// Task card dismisses cleanly.
|
|
783
|
+
const selected = new Set(get().selectedTaskGlobalIds);
|
|
784
|
+
let selectionChanged = false;
|
|
785
|
+
for (const g of doomedIds) {
|
|
786
|
+
if (selected.delete(g)) selectionChanged = true;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
commitEdit(get, set, next, selectionChanged ? { selectedTaskGlobalIds: selected } : undefined);
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
addTask: (options) => {
|
|
793
|
+
const current = get().scheduleData;
|
|
794
|
+
const now = Date.now();
|
|
795
|
+
// Fresh globalId: deterministic hash of timestamp + task count. The
|
|
796
|
+
// two-stream 128-bit hash guarantees no collision even when the user
|
|
797
|
+
// spams "Add" rapidly.
|
|
798
|
+
const seed = `user-add|${now}|${current?.tasks.length ?? 0}|${Math.random().toString(36).slice(2, 8)}`;
|
|
799
|
+
const newGid = deterministicGlobalId(seed);
|
|
800
|
+
const afterGid = options?.afterGlobalId;
|
|
801
|
+
const durationDays = Math.max(0.5, options?.durationDays ?? 5);
|
|
802
|
+
const name = options?.nameDefault ?? 'New task';
|
|
803
|
+
const predefinedType = options?.predefinedTypeDefault ?? 'CONSTRUCTION';
|
|
804
|
+
|
|
805
|
+
pushScheduleSnapshot(get, set, `Add task: ${name}`);
|
|
806
|
+
const next = current
|
|
807
|
+
? cloneExtraction(current)
|
|
808
|
+
: { hasSchedule: true, workSchedules: [], sequences: [], tasks: [] } as ScheduleExtraction;
|
|
809
|
+
|
|
810
|
+
// Derive default start: after the predecessor's finish when we have
|
|
811
|
+
// one, otherwise the schedule range start, otherwise today at 08:00.
|
|
812
|
+
const predIdx = afterGid ? next.tasks.findIndex(t => t.globalId === afterGid) : -1;
|
|
813
|
+
let startIso: string;
|
|
814
|
+
if (predIdx >= 0) {
|
|
815
|
+
const predFinish = parseIsoDate(next.tasks[predIdx].taskTime?.scheduleFinish);
|
|
816
|
+
startIso = predFinish !== undefined
|
|
817
|
+
? toIsoUtc(predFinish)
|
|
818
|
+
: (next.tasks[predIdx].taskTime?.scheduleStart ?? isoNowAt8());
|
|
819
|
+
} else {
|
|
820
|
+
const rangeStart = computeScheduleRange(next)?.start;
|
|
821
|
+
startIso = rangeStart !== undefined ? toIsoUtc(rangeStart) : isoNowAt8();
|
|
822
|
+
}
|
|
823
|
+
const startMs = parseIsoDate(startIso) ?? Date.now();
|
|
824
|
+
const finishMs = startMs + durationDays * 86_400_000;
|
|
825
|
+
|
|
826
|
+
const newTask: ScheduleTaskInfo = {
|
|
827
|
+
expressId: 0,
|
|
828
|
+
globalId: newGid,
|
|
829
|
+
name,
|
|
830
|
+
isMilestone: false,
|
|
831
|
+
predefinedType,
|
|
832
|
+
childGlobalIds: [],
|
|
833
|
+
productExpressIds: [],
|
|
834
|
+
productGlobalIds: [],
|
|
835
|
+
controllingScheduleGlobalIds: next.workSchedules[0]
|
|
836
|
+
? [next.workSchedules[0].globalId]
|
|
837
|
+
: [],
|
|
838
|
+
taskTime: {
|
|
839
|
+
scheduleStart: startIso,
|
|
840
|
+
scheduleFinish: toIsoUtc(finishMs),
|
|
841
|
+
scheduleDuration: msToIsoDuration(finishMs - startMs),
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
// Insert in the tasks array after the predecessor (or at end).
|
|
846
|
+
if (predIdx >= 0) next.tasks.splice(predIdx + 1, 0, newTask);
|
|
847
|
+
else next.tasks.push(newTask);
|
|
848
|
+
|
|
849
|
+
// Mirror insertion in the owning work-schedule's taskGlobalIds list
|
|
850
|
+
// so renderers that walk via work-schedule see the new task. If no
|
|
851
|
+
// work schedule exists, synthesise one — can't emit a schedule of
|
|
852
|
+
// orphan tasks.
|
|
853
|
+
if (next.workSchedules.length === 0) {
|
|
854
|
+
next.workSchedules.push({
|
|
855
|
+
expressId: 0,
|
|
856
|
+
globalId: deterministicGlobalId(`user-add|ws|${now}`),
|
|
857
|
+
kind: 'WorkSchedule',
|
|
858
|
+
name: 'Construction schedule',
|
|
859
|
+
description: 'User-authored',
|
|
860
|
+
creationDate: startIso,
|
|
861
|
+
startTime: startIso,
|
|
862
|
+
finishTime: toIsoUtc(finishMs),
|
|
863
|
+
predefinedType: 'PLANNED',
|
|
864
|
+
taskGlobalIds: [newGid],
|
|
865
|
+
});
|
|
866
|
+
newTask.controllingScheduleGlobalIds = [next.workSchedules[0].globalId];
|
|
867
|
+
} else {
|
|
868
|
+
const ws = next.workSchedules[0];
|
|
869
|
+
const wsPredIdx = afterGid ? ws.taskGlobalIds.indexOf(afterGid) : -1;
|
|
870
|
+
if (wsPredIdx >= 0) ws.taskGlobalIds.splice(wsPredIdx + 1, 0, newGid);
|
|
871
|
+
else ws.taskGlobalIds.push(newGid);
|
|
872
|
+
}
|
|
873
|
+
next.hasSchedule = true;
|
|
874
|
+
|
|
875
|
+
// Auto-select the new task so the Inspector's Task card lights up
|
|
876
|
+
// for immediate rename.
|
|
877
|
+
const nextSelected = new Set<string>([newGid]);
|
|
878
|
+
commitEdit(get, set, next, { selectedTaskGlobalIds: nextSelected });
|
|
879
|
+
|
|
880
|
+
return newGid;
|
|
881
|
+
},
|
|
882
|
+
|
|
883
|
+
moveTask: (globalId, newIndex) => {
|
|
884
|
+
const current = get().scheduleData;
|
|
885
|
+
if (!current) return;
|
|
886
|
+
const srcIdx = current.tasks.findIndex(t => t.globalId === globalId);
|
|
887
|
+
if (srcIdx < 0) return;
|
|
888
|
+
|
|
889
|
+
pushScheduleSnapshot(get, set, `Move task: ${current.tasks[srcIdx].name || 'untitled'}`);
|
|
890
|
+
const next = cloneExtraction(current);
|
|
891
|
+
|
|
892
|
+
// Move in the tasks array. Clamp to valid bounds.
|
|
893
|
+
const safeNewIdx = Math.max(0, Math.min(next.tasks.length - 1, newIndex));
|
|
894
|
+
const [moved] = next.tasks.splice(srcIdx, 1);
|
|
895
|
+
// Adjust target index when moving downward: the splice above removed
|
|
896
|
+
// one element before the drop position, so the effective target
|
|
897
|
+
// shifts by one.
|
|
898
|
+
const targetIdx = srcIdx < safeNewIdx ? safeNewIdx : safeNewIdx;
|
|
899
|
+
next.tasks.splice(targetIdx, 0, moved);
|
|
900
|
+
|
|
901
|
+
// Mirror the move in every work-schedule that owns the task so
|
|
902
|
+
// STEP round-trip preserves order.
|
|
903
|
+
for (const ws of next.workSchedules) {
|
|
904
|
+
const wsIdx = ws.taskGlobalIds.indexOf(globalId);
|
|
905
|
+
if (wsIdx < 0) continue;
|
|
906
|
+
const wsTarget = srcIdx < safeNewIdx ? safeNewIdx : safeNewIdx;
|
|
907
|
+
ws.taskGlobalIds.splice(wsIdx, 1);
|
|
908
|
+
ws.taskGlobalIds.splice(Math.max(0, Math.min(ws.taskGlobalIds.length, wsTarget)), 0, globalId);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
commitEdit(get, set, next);
|
|
912
|
+
},
|
|
913
|
+
|
|
914
|
+
undoScheduleEdit: () => {
|
|
915
|
+
const s = get();
|
|
916
|
+
if (s.scheduleUndoStack.length === 0) return;
|
|
917
|
+
const top = s.scheduleUndoStack[s.scheduleUndoStack.length - 1];
|
|
918
|
+
const newUndo = s.scheduleUndoStack.slice(0, -1);
|
|
919
|
+
// Capture current state for redo BEFORE restoring — matching the
|
|
920
|
+
// popped entry's kind so redo can symmetrically re-apply.
|
|
921
|
+
const redoEntry = captureInverseSnapshot(s, top);
|
|
922
|
+
const newRedo = [...s.scheduleRedoStack, redoEntry].slice(-SCHEDULE_STACK_MAX);
|
|
923
|
+
applySnapshot(get, set, top, newUndo, newRedo);
|
|
924
|
+
},
|
|
925
|
+
|
|
926
|
+
redoScheduleEdit: () => {
|
|
927
|
+
const s = get();
|
|
928
|
+
if (s.scheduleRedoStack.length === 0) return;
|
|
929
|
+
const top = s.scheduleRedoStack[s.scheduleRedoStack.length - 1];
|
|
930
|
+
const newRedo = s.scheduleRedoStack.slice(0, -1);
|
|
931
|
+
const undoEntry = captureInverseSnapshot(s, top);
|
|
932
|
+
const newUndo = [...s.scheduleUndoStack, undoEntry].slice(-SCHEDULE_STACK_MAX);
|
|
933
|
+
applySnapshot(get, set, top, newUndo, newRedo);
|
|
934
|
+
},
|
|
935
|
+
|
|
936
|
+
beginScheduleTransaction: (label) => {
|
|
937
|
+
// One snapshot for the whole transaction. Skip if one's already open.
|
|
938
|
+
const s = get();
|
|
939
|
+
if (s.scheduleTransaction.active) return;
|
|
940
|
+
set({
|
|
941
|
+
scheduleTransaction: {
|
|
942
|
+
active: true,
|
|
943
|
+
label,
|
|
944
|
+
pushedAt: s.scheduleUndoStack.length,
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
pushScheduleSnapshot(get, set, label, /* forceIgnoreTxn */ true);
|
|
948
|
+
},
|
|
949
|
+
|
|
950
|
+
endScheduleTransaction: () => {
|
|
951
|
+
set({ scheduleTransaction: { active: false, label: '', pushedAt: -1 } });
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
abortScheduleTransaction: () => {
|
|
955
|
+
const s = get();
|
|
956
|
+
const txn = s.scheduleTransaction;
|
|
957
|
+
if (!txn.active) return;
|
|
958
|
+
// Pop the snapshot we pushed at begin — only if it's still the top.
|
|
959
|
+
if (s.scheduleUndoStack.length === txn.pushedAt + 1) {
|
|
960
|
+
const entry = s.scheduleUndoStack[s.scheduleUndoStack.length - 1];
|
|
961
|
+
if (entry.label === txn.label) {
|
|
962
|
+
// Restoring the snapshot also reverts any mutations we made
|
|
963
|
+
// during the transaction.
|
|
964
|
+
const newUndo = s.scheduleUndoStack.slice(0, -1);
|
|
965
|
+
applySnapshot(get, set, entry, newUndo, s.scheduleRedoStack);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
set({ scheduleTransaction: { active: false, label: '', pushedAt: -1 } });
|
|
969
|
+
},
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
973
|
+
// Internal helpers for schedule editing
|
|
974
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
975
|
+
|
|
976
|
+
const SCHEDULE_STACK_MAX = 50;
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Push a pre-mutation FULL snapshot onto the undo stack, clear the redo
|
|
980
|
+
* stack (as is standard for undo UIs — any new edit after an undo forks
|
|
981
|
+
* history). Skipped when a transaction is active unless the caller
|
|
982
|
+
* passes `forceIgnoreTxn`.
|
|
983
|
+
*
|
|
984
|
+
* Structural edits (add / delete / move / assign / unassign) use this.
|
|
985
|
+
* For lightweight field edits on a single task, prefer
|
|
986
|
+
* {@link pushFieldPatchSnapshot} — same semantics, ~200× smaller entry.
|
|
987
|
+
*/
|
|
988
|
+
function pushScheduleSnapshot(
|
|
989
|
+
get: () => ScheduleSlice & ScheduleCrossSliceReads,
|
|
990
|
+
set: (
|
|
991
|
+
patch:
|
|
992
|
+
| Partial<ScheduleSlice>
|
|
993
|
+
| ((state: ScheduleSlice & ScheduleCrossSliceReads) => Partial<ScheduleSlice>),
|
|
994
|
+
) => void,
|
|
995
|
+
label: string,
|
|
996
|
+
forceIgnoreTxn = false,
|
|
997
|
+
): void {
|
|
998
|
+
const s = get();
|
|
999
|
+
if (s.scheduleTransaction.active && !forceIgnoreTxn) return;
|
|
1000
|
+
if (!s.scheduleData) return;
|
|
1001
|
+
const entry: ScheduleFullSnapshot = {
|
|
1002
|
+
kind: 'full',
|
|
1003
|
+
label,
|
|
1004
|
+
data: cloneExtraction(s.scheduleData),
|
|
1005
|
+
range: s.scheduleRange,
|
|
1006
|
+
isEdited: s.scheduleIsEdited,
|
|
1007
|
+
};
|
|
1008
|
+
const nextUndo = [...s.scheduleUndoStack, entry].slice(-SCHEDULE_STACK_MAX);
|
|
1009
|
+
set({
|
|
1010
|
+
scheduleUndoStack: nextUndo,
|
|
1011
|
+
scheduleRedoStack: [], // fork — clear redo
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Push a pre-mutation FIELD-PATCH snapshot for a single-task edit.
|
|
1017
|
+
* Captures only the task's globalId + the before-state of fields that
|
|
1018
|
+
* will change. ~100 bytes per entry regardless of schedule size.
|
|
1019
|
+
*
|
|
1020
|
+
* `beforeFields` should be the EXACT set of keys the mutator is about
|
|
1021
|
+
* to overwrite — if the patch isn't a strict subset of what's mutated,
|
|
1022
|
+
* undo replays from the wrong baseline. Use `pickExistingFields` to
|
|
1023
|
+
* capture from the live task.
|
|
1024
|
+
*/
|
|
1025
|
+
function pushFieldPatchSnapshot(
|
|
1026
|
+
get: () => ScheduleSlice & ScheduleCrossSliceReads,
|
|
1027
|
+
set: (
|
|
1028
|
+
patch:
|
|
1029
|
+
| Partial<ScheduleSlice>
|
|
1030
|
+
| ((state: ScheduleSlice & ScheduleCrossSliceReads) => Partial<ScheduleSlice>),
|
|
1031
|
+
) => void,
|
|
1032
|
+
label: string,
|
|
1033
|
+
taskGlobalId: string,
|
|
1034
|
+
beforeFields: Partial<ScheduleTaskInfo>,
|
|
1035
|
+
): void {
|
|
1036
|
+
const s = get();
|
|
1037
|
+
if (s.scheduleTransaction.active) return;
|
|
1038
|
+
if (!s.scheduleData) return;
|
|
1039
|
+
const entry: ScheduleFieldPatchSnapshot = {
|
|
1040
|
+
kind: 'fieldPatch',
|
|
1041
|
+
label,
|
|
1042
|
+
taskGlobalId,
|
|
1043
|
+
before: beforeFields,
|
|
1044
|
+
priorRange: s.scheduleRange,
|
|
1045
|
+
priorIsEdited: s.scheduleIsEdited,
|
|
1046
|
+
};
|
|
1047
|
+
const nextUndo = [...s.scheduleUndoStack, entry].slice(-SCHEDULE_STACK_MAX);
|
|
1048
|
+
set({
|
|
1049
|
+
scheduleUndoStack: nextUndo,
|
|
1050
|
+
scheduleRedoStack: [], // fork — clear redo
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Capture the current value of the given fields from a task so we can
|
|
1056
|
+
* restore them on undo. Deep-copies arrays / nested structs (taskTime)
|
|
1057
|
+
* so later mutations can't alias the snapshot.
|
|
1058
|
+
*/
|
|
1059
|
+
function pickExistingFields(
|
|
1060
|
+
task: ScheduleTaskInfo,
|
|
1061
|
+
keys: ReadonlyArray<keyof ScheduleTaskInfo>,
|
|
1062
|
+
): Partial<ScheduleTaskInfo> {
|
|
1063
|
+
const out: Partial<ScheduleTaskInfo> = {};
|
|
1064
|
+
for (const k of keys) {
|
|
1065
|
+
const v = task[k];
|
|
1066
|
+
if (v === undefined) {
|
|
1067
|
+
(out as Record<string, unknown>)[k] = undefined;
|
|
1068
|
+
} else if (typeof v === 'object' && v !== null) {
|
|
1069
|
+
// Plain object or array — deep clone to break aliasing.
|
|
1070
|
+
(out as Record<string, unknown>)[k] = structuredClone(v);
|
|
1071
|
+
} else {
|
|
1072
|
+
(out as Record<string, unknown>)[k] = v;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Finalise an edit: replace `scheduleData`, recompute range, flip the
|
|
1080
|
+
* edited flag, cross-slice-mark dirty, bump mutation version.
|
|
1081
|
+
*/
|
|
1082
|
+
function commitEdit(
|
|
1083
|
+
get: () => ScheduleSlice & ScheduleCrossSliceReads,
|
|
1084
|
+
set: (
|
|
1085
|
+
patch:
|
|
1086
|
+
| Partial<ScheduleSlice>
|
|
1087
|
+
| ((state: ScheduleSlice & ScheduleCrossSliceReads) => Partial<ScheduleSlice>),
|
|
1088
|
+
) => void,
|
|
1089
|
+
next: ScheduleExtraction,
|
|
1090
|
+
extra?: Partial<ScheduleSlice>,
|
|
1091
|
+
): void {
|
|
1092
|
+
const range = computeScheduleRange(next);
|
|
1093
|
+
set(state => {
|
|
1094
|
+
const sourceModelId = state.scheduleSourceModelId;
|
|
1095
|
+
const newDirty = new Set(state.dirtyModels);
|
|
1096
|
+
if (sourceModelId) newDirty.add(sourceModelId);
|
|
1097
|
+
const bump = (state.mutationVersion ?? 0) + 1;
|
|
1098
|
+
return {
|
|
1099
|
+
...(extra ?? {}),
|
|
1100
|
+
scheduleData: next,
|
|
1101
|
+
scheduleRange: range,
|
|
1102
|
+
scheduleIsEdited: true,
|
|
1103
|
+
dirtyModels: newDirty,
|
|
1104
|
+
mutationVersion: bump,
|
|
1105
|
+
} as Partial<ScheduleSlice>;
|
|
1106
|
+
});
|
|
1107
|
+
// Touch `get` so the linter doesn't complain about the unused arg.
|
|
1108
|
+
// Reading here (post-set) is also cheap and keeps the signature
|
|
1109
|
+
// symmetric with the other helpers.
|
|
1110
|
+
void get();
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Build an "inverse snapshot" of the current state that matches the
|
|
1115
|
+
* shape of the entry we're about to pop. For `full` entries we deep-
|
|
1116
|
+
* clone the entire extraction; for `fieldPatch` entries we capture
|
|
1117
|
+
* the task's current field values so a later redo can re-apply them.
|
|
1118
|
+
*
|
|
1119
|
+
* Preserving the kind symmetry guarantees that undo → redo → undo
|
|
1120
|
+
* brings the state back to byte-identical (the symmetry test in
|
|
1121
|
+
* scheduleSlice.test.ts locks this down).
|
|
1122
|
+
*/
|
|
1123
|
+
function captureInverseSnapshot(
|
|
1124
|
+
state: ScheduleSlice,
|
|
1125
|
+
entry: ScheduleSnapshot,
|
|
1126
|
+
): ScheduleSnapshot {
|
|
1127
|
+
if (entry.kind === 'full') {
|
|
1128
|
+
return {
|
|
1129
|
+
kind: 'full',
|
|
1130
|
+
label: entry.label,
|
|
1131
|
+
data: state.scheduleData ? cloneExtraction(state.scheduleData) : null,
|
|
1132
|
+
range: state.scheduleRange,
|
|
1133
|
+
isEdited: state.scheduleIsEdited,
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
// fieldPatch — mirror by capturing current values of the same keys
|
|
1137
|
+
// and the current range / edited flag.
|
|
1138
|
+
const task = state.scheduleData?.tasks.find(t => t.globalId === entry.taskGlobalId);
|
|
1139
|
+
const keys = Object.keys(entry.before) as Array<keyof ScheduleTaskInfo>;
|
|
1140
|
+
const currentFields = task ? pickExistingFields(task, keys) : {};
|
|
1141
|
+
return {
|
|
1142
|
+
kind: 'fieldPatch',
|
|
1143
|
+
label: entry.label,
|
|
1144
|
+
taskGlobalId: entry.taskGlobalId,
|
|
1145
|
+
before: currentFields,
|
|
1146
|
+
priorRange: state.scheduleRange,
|
|
1147
|
+
priorIsEdited: state.scheduleIsEdited,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Restore a snapshot (shared by undo + redo + abort). Keeps the undo /
|
|
1153
|
+
* redo stacks the caller decided on, rather than deriving from `top`.
|
|
1154
|
+
*/
|
|
1155
|
+
function applySnapshot(
|
|
1156
|
+
get: () => ScheduleSlice & ScheduleCrossSliceReads,
|
|
1157
|
+
set: (
|
|
1158
|
+
patch:
|
|
1159
|
+
| Partial<ScheduleSlice>
|
|
1160
|
+
| ((state: ScheduleSlice & ScheduleCrossSliceReads) => Partial<ScheduleSlice>),
|
|
1161
|
+
) => void,
|
|
1162
|
+
snap: ScheduleSnapshot,
|
|
1163
|
+
newUndo: ScheduleSnapshot[],
|
|
1164
|
+
newRedo: ScheduleSnapshot[],
|
|
1165
|
+
): void {
|
|
1166
|
+
if (snap.kind === 'full') {
|
|
1167
|
+
set(state => {
|
|
1168
|
+
const sourceModelId = state.scheduleSourceModelId;
|
|
1169
|
+
const newDirty = new Set(state.dirtyModels);
|
|
1170
|
+
if (sourceModelId) {
|
|
1171
|
+
if (snap.isEdited) newDirty.add(sourceModelId);
|
|
1172
|
+
else newDirty.delete(sourceModelId);
|
|
1173
|
+
}
|
|
1174
|
+
const bump = (state.mutationVersion ?? 0) + 1;
|
|
1175
|
+
return {
|
|
1176
|
+
scheduleData: snap.data,
|
|
1177
|
+
scheduleRange: snap.range,
|
|
1178
|
+
scheduleIsEdited: snap.isEdited,
|
|
1179
|
+
scheduleUndoStack: newUndo,
|
|
1180
|
+
scheduleRedoStack: newRedo,
|
|
1181
|
+
dirtyModels: newDirty,
|
|
1182
|
+
mutationVersion: bump,
|
|
1183
|
+
} as Partial<ScheduleSlice>;
|
|
1184
|
+
});
|
|
1185
|
+
void get();
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Field-patch restore: find the task by globalId and overwrite only
|
|
1190
|
+
// the fields captured in `before`. The rest of `scheduleData` is
|
|
1191
|
+
// untouched — structurally we don't need a full clone.
|
|
1192
|
+
set(state => {
|
|
1193
|
+
const sourceModelId = state.scheduleSourceModelId;
|
|
1194
|
+
const newDirty = new Set(state.dirtyModels);
|
|
1195
|
+
if (sourceModelId) {
|
|
1196
|
+
if (snap.priorIsEdited) newDirty.add(sourceModelId);
|
|
1197
|
+
else newDirty.delete(sourceModelId);
|
|
1198
|
+
}
|
|
1199
|
+
const bump = (state.mutationVersion ?? 0) + 1;
|
|
1200
|
+
|
|
1201
|
+
// Patch the single affected task. Clone the extraction shallowly so
|
|
1202
|
+
// Zustand identity-based subscriptions fire; deeper structures that
|
|
1203
|
+
// weren't touched stay referentially stable.
|
|
1204
|
+
const current = state.scheduleData;
|
|
1205
|
+
let nextData: ScheduleExtraction | null = current;
|
|
1206
|
+
if (current) {
|
|
1207
|
+
const idx = current.tasks.findIndex(t => t.globalId === snap.taskGlobalId);
|
|
1208
|
+
if (idx >= 0) {
|
|
1209
|
+
const nextTasks = current.tasks.slice();
|
|
1210
|
+
nextTasks[idx] = { ...current.tasks[idx], ...snap.before };
|
|
1211
|
+
nextData = { ...current, tasks: nextTasks };
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return {
|
|
1216
|
+
scheduleData: nextData,
|
|
1217
|
+
scheduleRange: snap.priorRange,
|
|
1218
|
+
scheduleIsEdited: snap.priorIsEdited,
|
|
1219
|
+
scheduleUndoStack: newUndo,
|
|
1220
|
+
scheduleRedoStack: newRedo,
|
|
1221
|
+
dirtyModels: newDirty,
|
|
1222
|
+
mutationVersion: bump,
|
|
1223
|
+
} as Partial<ScheduleSlice>;
|
|
1224
|
+
});
|
|
1225
|
+
void get();
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// ── Derived selectors ────────────────────────────────────────────────────
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* True when the task participates in the given work-schedule filter.
|
|
1232
|
+
*
|
|
1233
|
+
* An empty or null `scheduleGlobalId` means "no filter" — every task passes.
|
|
1234
|
+
* Tasks whose `controllingScheduleGlobalIds` is empty are treated as
|
|
1235
|
+
* always-visible so they still contribute to playback when a schedule is
|
|
1236
|
+
* selected but the extractor didn't record controlling-schedule info.
|
|
1237
|
+
*/
|
|
1238
|
+
function taskMatchesScheduleFilter(
|
|
1239
|
+
task: ScheduleTaskInfo,
|
|
1240
|
+
scheduleGlobalId: string | null | undefined,
|
|
1241
|
+
): boolean {
|
|
1242
|
+
if (!scheduleGlobalId) return true;
|
|
1243
|
+
if (task.controllingScheduleGlobalIds.length === 0) return true;
|
|
1244
|
+
return task.controllingScheduleGlobalIds.includes(scheduleGlobalId);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Compute the set of product expressIds that should be hidden at the given
|
|
1249
|
+
* playback time. A product is hidden when every task that assigns it has
|
|
1250
|
+
* `scheduleStart > playbackTime`. Products with no controlling task are
|
|
1251
|
+
* always shown.
|
|
1252
|
+
*
|
|
1253
|
+
* `scheduleGlobalId` (optional) restricts evaluation to tasks controlled by
|
|
1254
|
+
* that IfcWorkSchedule / IfcWorkPlan. Pass `null`/`undefined`/`''` to treat
|
|
1255
|
+
* all tasks as in-scope. Federation-aware ID translation is the caller's
|
|
1256
|
+
* responsibility — these selectors stay pure and return local expressIds.
|
|
1257
|
+
*/
|
|
1258
|
+
export function computeHiddenProductIds(
|
|
1259
|
+
data: ScheduleExtraction | null,
|
|
1260
|
+
playbackTime: number,
|
|
1261
|
+
scheduleGlobalId?: string | null,
|
|
1262
|
+
): Set<number> {
|
|
1263
|
+
const hidden = new Set<number>();
|
|
1264
|
+
if (!data) return hidden;
|
|
1265
|
+
/** product expressId -> true iff it was revealed by at least one task. */
|
|
1266
|
+
const revealed = new Map<number, boolean>();
|
|
1267
|
+
for (const task of data.tasks) {
|
|
1268
|
+
if (!taskMatchesScheduleFilter(task, scheduleGlobalId)) continue;
|
|
1269
|
+
const start = taskStartEpoch(task);
|
|
1270
|
+
if (task.productExpressIds.length === 0) continue;
|
|
1271
|
+
// If no scheduled start, treat the task as always-active (don't hide its products).
|
|
1272
|
+
const isRevealed = start === undefined ? true : start <= playbackTime;
|
|
1273
|
+
for (const id of task.productExpressIds) {
|
|
1274
|
+
if (isRevealed) {
|
|
1275
|
+
revealed.set(id, true);
|
|
1276
|
+
} else if (!revealed.has(id)) {
|
|
1277
|
+
revealed.set(id, false);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
for (const [id, isRevealed] of revealed) {
|
|
1282
|
+
if (!isRevealed) hidden.add(id);
|
|
1283
|
+
}
|
|
1284
|
+
return hidden;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Compute product expressIds that are currently part of an in-progress task —
|
|
1289
|
+
* useful for highlighting the "active construction front" during playback.
|
|
1290
|
+
*
|
|
1291
|
+
* `scheduleGlobalId` semantics mirror {@link computeHiddenProductIds}.
|
|
1292
|
+
*/
|
|
1293
|
+
export function computeActiveProductIds(
|
|
1294
|
+
data: ScheduleExtraction | null,
|
|
1295
|
+
playbackTime: number,
|
|
1296
|
+
scheduleGlobalId?: string | null,
|
|
1297
|
+
): Set<number> {
|
|
1298
|
+
const active = new Set<number>();
|
|
1299
|
+
if (!data) return active;
|
|
1300
|
+
for (const task of data.tasks) {
|
|
1301
|
+
if (!taskMatchesScheduleFilter(task, scheduleGlobalId)) continue;
|
|
1302
|
+
const start = taskStartEpoch(task);
|
|
1303
|
+
const finish = taskFinishEpoch(task);
|
|
1304
|
+
if (start === undefined || finish === undefined) continue;
|
|
1305
|
+
if (playbackTime >= start && playbackTime <= finish) {
|
|
1306
|
+
for (const id of task.productExpressIds) active.add(id);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return active;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export { taskStartEpoch, taskFinishEpoch, parseIsoDate };
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Count tasks that the user generated locally — tasks with no existing
|
|
1316
|
+
* `expressId` in the host STEP file. These are the "pending schedule
|
|
1317
|
+
* edits" equivalent of property mutations: they need to be serialized
|
|
1318
|
+
* and spliced into the STEP on export.
|
|
1319
|
+
*
|
|
1320
|
+
* Matches the partitioning rule in `export-adapter.injectScheduleIntoStep`
|
|
1321
|
+
* so the count, dirty flag, and export path agree on what counts.
|
|
1322
|
+
*/
|
|
1323
|
+
export function countGeneratedTasks(data: ScheduleExtraction | null | undefined): number {
|
|
1324
|
+
if (!data || data.tasks.length === 0) return 0;
|
|
1325
|
+
let n = 0;
|
|
1326
|
+
for (const t of data.tasks) {
|
|
1327
|
+
if (!t.expressId || t.expressId <= 0) n++;
|
|
1328
|
+
}
|
|
1329
|
+
return n;
|
|
1330
|
+
}
|