@ifc-lite/viewer 1.17.4 → 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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,224 @@
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
+ * ScheduleCard — surface 4D / construction-schedule data in the Inspector.
7
+ *
8
+ * Two complementary views, picked automatically based on the selection:
9
+ * • Selected entity is a *product* controlled by one or more IfcTasks →
10
+ * "Construction Schedule" card listing each controlling task with its
11
+ * start/finish/duration and parent work-schedule name.
12
+ * • Selected entity is itself an IfcTask / IfcWorkSchedule (rare in
13
+ * practice — these typically aren't pickable in the 3D view) → show
14
+ * its time data directly.
15
+ *
16
+ * The card pulls from the viewer's `scheduleSlice` (which holds both parsed
17
+ * and locally-generated schedules), so it lights up automatically the moment
18
+ * the user generates a schedule via the Gantt panel — no separate fetch.
19
+ */
20
+
21
+ import { useMemo } from 'react';
22
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
23
+ import { CalendarClock, Diamond, Flag } from 'lucide-react';
24
+ import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser';
25
+
26
+ interface ScheduleCardProps {
27
+ /** Schedule data from the viewer's slice (parsed or generated). */
28
+ scheduleData: ScheduleExtraction | null;
29
+ /** Selected entity's local express ID. */
30
+ selectedExpressId: number | null;
31
+ /** Selected entity's globalId (used as a fallback when expressId === 0). */
32
+ selectedGlobalId?: string | null;
33
+ /**
34
+ * When true, the schedule was created via the Gantt panel's "Generate
35
+ * from storeys" dialog and isn't yet baked into the source IFC. We render
36
+ * a small "Generated locally" badge so users know the schedule will be
37
+ * spliced in on the next IFC export.
38
+ */
39
+ isGenerated: boolean;
40
+ }
41
+
42
+ export function ScheduleCard({
43
+ scheduleData,
44
+ selectedExpressId,
45
+ selectedGlobalId,
46
+ isGenerated,
47
+ }: ScheduleCardProps) {
48
+ const tasks = useMemo(
49
+ () => findControllingTasks(scheduleData, selectedExpressId, selectedGlobalId),
50
+ [scheduleData, selectedExpressId, selectedGlobalId],
51
+ );
52
+ const scheduleNames = useMemo(
53
+ () => buildScheduleNameLookup(scheduleData),
54
+ [scheduleData],
55
+ );
56
+
57
+ if (tasks.length === 0) return null;
58
+
59
+ return (
60
+ <Collapsible
61
+ defaultOpen
62
+ className="border-2 border-sky-200 dark:border-sky-800 bg-sky-50/20 dark:bg-sky-950/20 w-full max-w-full overflow-hidden"
63
+ >
64
+ <CollapsibleTrigger className="flex items-center gap-2 w-full p-2.5 hover:bg-sky-50 dark:hover:bg-sky-900/30 text-left transition-colors overflow-hidden">
65
+ <CalendarClock className="h-3.5 w-3.5 text-sky-600 dark:text-sky-400 shrink-0" />
66
+ <span className="font-bold text-xs text-sky-700 dark:text-sky-400 truncate flex-1 min-w-0">
67
+ Construction Schedule
68
+ </span>
69
+ {isGenerated && (
70
+ <span
71
+ className="flex items-center gap-1 text-[10px] font-medium bg-amber-100 dark:bg-amber-900/40 px-1.5 py-0.5 border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 shrink-0"
72
+ title="Pending schedule edits — included on IFC export"
73
+ >
74
+ <span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
75
+ Pending
76
+ </span>
77
+ )}
78
+ <span className="text-[10px] font-mono bg-sky-100 dark:bg-sky-900/50 px-1.5 py-0.5 border border-sky-200 dark:border-sky-800 text-sky-700 dark:text-sky-300 shrink-0">
79
+ {tasks.length} {tasks.length === 1 ? 'task' : 'tasks'}
80
+ </span>
81
+ </CollapsibleTrigger>
82
+ <CollapsibleContent>
83
+ <div className="border-t-2 border-sky-200 dark:border-sky-800">
84
+ {isGenerated && (
85
+ <div className="px-3 py-1.5 text-[10px] text-amber-700 dark:text-amber-300 bg-amber-50/80 dark:bg-amber-900/20 border-b border-amber-200/60 dark:border-amber-800/50">
86
+ Generated locally — will be spliced into the next IFC export.
87
+ </div>
88
+ )}
89
+ <div className="divide-y divide-sky-100 dark:divide-sky-900/30">
90
+ {tasks.map((task) => (
91
+ <TaskRow key={task.globalId} task={task} scheduleNames={scheduleNames} />
92
+ ))}
93
+ </div>
94
+ </div>
95
+ </CollapsibleContent>
96
+ </Collapsible>
97
+ );
98
+ }
99
+
100
+ interface TaskRowProps {
101
+ task: ScheduleTaskInfo;
102
+ scheduleNames: Map<string, string>;
103
+ }
104
+
105
+ function TaskRow({ task, scheduleNames }: TaskRowProps) {
106
+ const start = formatDate(task.taskTime?.scheduleStart);
107
+ const finish = formatDate(task.taskTime?.scheduleFinish);
108
+ const duration = task.taskTime?.scheduleDuration;
109
+ const completion = task.taskTime?.completion;
110
+ const isCritical = task.taskTime?.isCritical === true;
111
+ const scheduleLabels = task.controllingScheduleGlobalIds
112
+ .map(gid => scheduleNames.get(gid))
113
+ .filter((s): s is string => Boolean(s));
114
+
115
+ return (
116
+ <div className="px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
117
+ <div className="flex items-center gap-1.5 mb-1">
118
+ {task.isMilestone ? (
119
+ <Diamond className="h-3 w-3 text-amber-500 fill-amber-500 shrink-0" />
120
+ ) : isCritical ? (
121
+ <Flag className="h-3 w-3 text-red-500 fill-red-500 shrink-0" />
122
+ ) : null}
123
+ <span
124
+ className={
125
+ 'font-medium text-foreground truncate ' + (isCritical ? 'text-red-600 dark:text-red-400' : '')
126
+ }
127
+ title={task.name}
128
+ >
129
+ {task.name || task.identification || task.globalId.slice(0, 12)}
130
+ </span>
131
+ {task.predefinedType && (
132
+ <span className="text-[9px] font-mono bg-sky-100 dark:bg-sky-900/50 px-1 py-0.5 border border-sky-200 dark:border-sky-800 text-sky-600 dark:text-sky-300 shrink-0">
133
+ {task.predefinedType}
134
+ </span>
135
+ )}
136
+ </div>
137
+ <div className="grid grid-cols-[minmax(60px,auto)_1fr] gap-x-2 gap-y-0.5 ml-1 text-[11px]">
138
+ {start && (
139
+ <>
140
+ <span className="text-muted-foreground">Start</span>
141
+ <span className="font-mono text-foreground/90">{start}</span>
142
+ </>
143
+ )}
144
+ {finish && (
145
+ <>
146
+ <span className="text-muted-foreground">Finish</span>
147
+ <span className="font-mono text-foreground/90">{finish}</span>
148
+ </>
149
+ )}
150
+ {duration && (
151
+ <>
152
+ <span className="text-muted-foreground">Duration</span>
153
+ <span className="font-mono text-foreground/90">{duration}</span>
154
+ </>
155
+ )}
156
+ {completion !== undefined && (
157
+ <>
158
+ <span className="text-muted-foreground">Complete</span>
159
+ <span className="font-mono text-foreground/90">{Math.round(completion)}%</span>
160
+ </>
161
+ )}
162
+ {scheduleLabels.length > 0 && (
163
+ <>
164
+ <span className="text-muted-foreground">Schedule</span>
165
+ <span className="text-foreground/90 truncate" title={scheduleLabels.join(', ')}>
166
+ {scheduleLabels.join(', ')}
167
+ </span>
168
+ </>
169
+ )}
170
+ </div>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Find all tasks whose products include the selected entity.
177
+ *
178
+ * Federation-aware: prefer `productGlobalIds` whenever we know the entity's
179
+ * globalId (and the task carries globalIds of its own) — local expressIds can
180
+ * collide across federated models, so matching by globalId first is the safe
181
+ * default. Fall back to `productExpressIds` only for schedules that never
182
+ * recorded globalIds (legacy / headless extraction paths).
183
+ */
184
+ function findControllingTasks(
185
+ data: ScheduleExtraction | null,
186
+ selectedExpressId: number | null,
187
+ selectedGlobalId: string | null | undefined,
188
+ ): ScheduleTaskInfo[] {
189
+ if (!data || data.tasks.length === 0) return [];
190
+ if (selectedExpressId === null && !selectedGlobalId) return [];
191
+ const out: ScheduleTaskInfo[] = [];
192
+ for (const task of data.tasks) {
193
+ const taskHasGlobalIds = task.productGlobalIds.some(Boolean);
194
+ if (selectedGlobalId && taskHasGlobalIds) {
195
+ if (task.productGlobalIds.includes(selectedGlobalId)) out.push(task);
196
+ // When globalIds are the authoritative side, do NOT also match on
197
+ // expressId — a collision across models would produce a false positive.
198
+ continue;
199
+ }
200
+ if (selectedExpressId !== null && selectedExpressId > 0
201
+ && task.productExpressIds.includes(selectedExpressId)) {
202
+ out.push(task);
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function buildScheduleNameLookup(data: ScheduleExtraction | null): Map<string, string> {
209
+ const map = new Map<string, string>();
210
+ if (!data) return map;
211
+ for (const ws of data.workSchedules) {
212
+ if (ws.globalId && ws.name) map.set(ws.globalId, ws.name);
213
+ }
214
+ return map;
215
+ }
216
+
217
+ function formatDate(iso: string | undefined): string | undefined {
218
+ if (!iso) return undefined;
219
+ const t = Date.parse(iso);
220
+ if (Number.isNaN(t)) return iso;
221
+ return new Date(t).toLocaleDateString(undefined, {
222
+ year: 'numeric', month: 'short', day: 'numeric',
223
+ });
224
+ }