@ifc-lite/viewer 1.17.6 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. 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
+ }