@heyramzi/ui 2.0.1 → 2.2.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 (341) hide show
  1. package/package.json +48 -24
  2. package/scripts/sync-shadcn.mjs +3 -4
  3. package/src/agents/agent-avatar.svelte +604 -575
  4. package/src/agents/agent-cast.svelte +179 -183
  5. package/src/agents/demo.svelte +125 -113
  6. package/src/agents/helpers.ts +23 -17
  7. package/src/agents/index.ts +4 -4
  8. package/src/agents/page-state.ts +6 -6
  9. package/src/agents/types.ts +6 -6
  10. package/src/ai/ai-button/AiButton.svelte +4 -12
  11. package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
  12. package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
  13. package/src/ai/response/Response.css +2 -3
  14. package/src/animations/avatar-group-hover.css +2 -16
  15. package/src/animations/card-tilt.css +3 -6
  16. package/src/animations/digit-pop-in.css +2 -15
  17. package/src/animations/dropdown.css +4 -17
  18. package/src/animations/input-clear-dissolve.css +5 -18
  19. package/src/animations/input-shake.css +9 -41
  20. package/src/animations/learn-more-hover.css +17 -9
  21. package/src/animations/like-button.css +2 -4
  22. package/src/animations/page-side-by-side.css +2 -10
  23. package/src/animations/plus-menu-morph.css +2 -5
  24. package/src/animations/shimmer-text.css +5 -18
  25. package/src/animations/spinning-counter.css +2 -6
  26. package/src/animations/success-check.css +2 -15
  27. package/src/animations/thinking-states.css +2 -7
  28. package/src/brand/brand.test.ts +21 -5
  29. package/src/brand/index.ts +4 -10
  30. package/src/canvas/README.md +9 -12
  31. package/src/canvas/card/card-frame.svelte +43 -39
  32. package/src/canvas/card/resize-handles.svelte +89 -80
  33. package/src/canvas/chrome/chrome-zoom.svelte +101 -100
  34. package/src/canvas/chrome/lock-badge.svelte +8 -8
  35. package/src/canvas/chrome/side-handles.svelte +24 -19
  36. package/src/canvas/chrome/size-chip.svelte +30 -30
  37. package/src/canvas/chrome/snap-guides.svelte +20 -20
  38. package/src/canvas/components/icons/ToolIcon.svelte +8 -8
  39. package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
  40. package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
  41. package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
  42. package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
  43. package/src/canvas/edges/bend-handle.svelte +46 -46
  44. package/src/canvas/edges/edge-gradient.svelte +10 -10
  45. package/src/canvas/edges/gradient-edge.svelte +45 -29
  46. package/src/canvas/editor/lock.test.ts +6 -1
  47. package/src/canvas/editor/style.ts +7 -1
  48. package/src/canvas/headless/svg-figure.test.ts +5 -1
  49. package/src/canvas/lod/lod-switch.svelte +111 -106
  50. package/src/canvas/pen/PenField.svelte +87 -81
  51. package/src/canvas/pen/PenOverlay.svelte +127 -127
  52. package/src/canvas/pen/ShapeNode.svelte +31 -32
  53. package/src/canvas/pen/TextNode.svelte +54 -53
  54. package/src/canvas/pen/host-field.test.svelte +7 -7
  55. package/src/canvas/pen/shapes.ts +7 -1
  56. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  57. package/src/canvas/screen/PartView.svelte +138 -72
  58. package/src/canvas/screen/ScreenBody.svelte +81 -60
  59. package/src/canvas/screen/ScreenCard.svelte +20 -20
  60. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  61. package/src/canvas/ui/FrameLayer.svelte +59 -59
  62. package/src/canvas/ui/PenLayer.svelte +39 -30
  63. package/src/canvas/ui/StylePanel.svelte +119 -102
  64. package/src/canvas/ui/canvas-grid.svelte +137 -129
  65. package/src/charts/animated-value.svelte +1 -1
  66. package/src/charts/area-chart.css +2 -5
  67. package/src/charts/area-chart.svelte +1 -1
  68. package/src/charts/bar-chart.css +2 -3
  69. package/src/charts/bar-chart.svelte +1 -1
  70. package/src/charts/charts.css +5 -17
  71. package/src/charts/crosshair.svelte +1 -1
  72. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  73. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  74. package/src/charts/dashboard/data-panel.svelte +50 -50
  75. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  76. package/src/charts/dashboard/data-table.svelte +138 -138
  77. package/src/charts/dashboard/panel-grid.svelte +30 -30
  78. package/src/charts/dashboard/period-filter.svelte +25 -25
  79. package/src/charts/dashboard/rating-chip.svelte +19 -19
  80. package/src/charts/dashboard/stat-band.svelte +32 -32
  81. package/src/charts/dashboard/stat-tile.svelte +106 -99
  82. package/src/charts/donut-chart.svelte +1 -1
  83. package/src/charts/edge-fade.svelte +1 -1
  84. package/src/charts/figure.svelte +1 -1
  85. package/src/charts/gauge-chart.css +4 -7
  86. package/src/charts/gauge-chart.svelte +1 -1
  87. package/src/charts/hover-focus.svelte +1 -1
  88. package/src/charts/legend.css +1 -3
  89. package/src/charts/legend.svelte +1 -1
  90. package/src/charts/line-chart.svelte +1 -1
  91. package/src/charts/number-chart.css +6 -9
  92. package/src/charts/number-chart.svelte +1 -1
  93. package/src/charts/reference-line.svelte +19 -19
  94. package/src/charts/reveal.svelte +1 -1
  95. package/src/charts/ring-chart.css +2 -4
  96. package/src/charts/ring-chart.svelte +1 -1
  97. package/src/charts/sparkline.svelte +5 -2
  98. package/src/charts/stat-card.svelte +1 -1
  99. package/src/charts/time-series-chart.css +2 -5
  100. package/src/charts/trend-pill.css +2 -4
  101. package/src/charts/trend-pill.svelte +1 -1
  102. package/src/components/copy/copy-button.svelte +93 -98
  103. package/src/components/copy/copy-button.test.ts +10 -2
  104. package/src/components/data-table/infinite-scroll.svelte +42 -42
  105. package/src/components/data-table/pagination.svelte +34 -22
  106. package/src/components/data-table/row-arrow.svelte +19 -16
  107. package/src/components/data-table/row-context-menu.svelte +52 -52
  108. package/src/components/data-table/sortable-head.svelte +25 -25
  109. package/src/components/data-table/table-skeleton.svelte +41 -36
  110. package/src/components/docs/doc-page.svelte +18 -9
  111. package/src/components/docs/on-this-page.svelte +16 -3
  112. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  113. package/src/components/field-row/field-row.svelte +10 -8
  114. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  115. package/src/components/icon/icon.svelte +14 -14
  116. package/src/components/language-flag/language-flag.svelte +3 -1
  117. package/src/components/list-page/detail-empty.svelte +34 -34
  118. package/src/components/list-page/detail-header.svelte +55 -47
  119. package/src/components/list-page/detail-panel.svelte +51 -59
  120. package/src/components/list-page/detail-row.svelte +19 -19
  121. package/src/components/list-page/detail-stack.svelte +21 -21
  122. package/src/components/list-page/empty-state.svelte +30 -30
  123. package/src/components/list-page/filter-item.svelte +20 -20
  124. package/src/components/list-page/filter-popover.svelte +60 -55
  125. package/src/components/list-page/grouped-list.svelte +4 -2
  126. package/src/components/list-page/index.ts +8 -4
  127. package/src/components/list-page/list-page-shell.svelte +2 -5
  128. package/src/components/list-page/master-detail.svelte +87 -88
  129. package/src/components/list-page/notice-bar.svelte +38 -38
  130. package/src/components/list-page/prev-next.svelte +63 -63
  131. package/src/components/list-page/rail-body.svelte +18 -18
  132. package/src/components/list-page/rail-empty.svelte +23 -30
  133. package/src/components/list-page/rail-head.svelte +24 -24
  134. package/src/components/list-page/rail-row.svelte +77 -73
  135. package/src/components/list-page/section-host.ts +2 -3
  136. package/src/components/locale-switch/locale-switch.svelte +41 -41
  137. package/src/components/mark/mark.svelte +34 -38
  138. package/src/components/open-in/index.ts +1 -5
  139. package/src/components/open-in/open-in.svelte +40 -40
  140. package/src/components/picker/mark-picker.svelte +49 -49
  141. package/src/components/picker/picker.svelte +5 -3
  142. package/src/components/section-header/index.ts +1 -0
  143. package/src/components/section-header/section-header.svelte +1 -1
  144. package/src/components/section-header/section-header.test.ts +7 -1
  145. package/src/components/section-header/section-label.svelte +39 -0
  146. package/src/components/segmented/animated-tabs.svelte +31 -2
  147. package/src/components/segmented/collapse.test.ts +2 -1
  148. package/src/components/segmented/index.ts +2 -0
  149. package/src/components/segmented/segment.svelte +52 -49
  150. package/src/components/segmented/segmented-filter.svelte +79 -80
  151. package/src/components/segmented/segmented-nav.svelte +34 -40
  152. package/src/components/segmented/segmented-overflow.svelte +53 -50
  153. package/src/components/segmented/segmented-track.svelte +62 -46
  154. package/src/components/segmented/segmented.ts +2 -3
  155. package/src/components/star-rating-input/star-rating-input.svelte +103 -103
  156. package/src/components/svg/svg.svelte +4 -4
  157. package/src/components/table-view/table-view.svelte +17 -2
  158. package/src/components/table-view/table-view.test.ts +7 -1
  159. package/src/components/table-view/types.ts +11 -11
  160. package/src/components/tags-input/index.ts +13 -0
  161. package/src/components/tags-input/tags-input.svelte +305 -0
  162. package/src/components/tags-input/tags.test.ts +64 -0
  163. package/src/components/tags-input/tags.ts +142 -0
  164. package/src/components/theme/index.ts +2 -4
  165. package/src/components/theme/theme-provider.svelte +1 -3
  166. package/src/components/toolbar/index.ts +2 -0
  167. package/src/components/{list-page → toolbar}/toolbar-search.svelte +25 -25
  168. package/src/components/toolbar/toolbar.svelte +13 -9
  169. package/src/components/toolbar/toolbar.test.ts +10 -1
  170. package/src/design/index.css +21 -78
  171. package/src/design/polish.css +26 -35
  172. package/src/editor/editor-toolbar.svelte +104 -99
  173. package/src/editor/editor.svelte +296 -266
  174. package/src/editor/image-toolbar.svelte +20 -22
  175. package/src/editor/index.ts +1 -4
  176. package/src/editor/inline-editor.svelte +280 -238
  177. package/src/editor/markdown.ts +15 -15
  178. package/src/editor/renderer.svelte +71 -71
  179. package/src/editor/slash-menu.svelte +24 -27
  180. package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
  181. package/src/effects/image-comparison/ImageComparison.svelte +11 -1
  182. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
  183. package/src/effects/magic-card/MagicCard.css +14 -7
  184. package/src/effects/marquee/Marquee.svelte +3 -5
  185. package/src/effects/reveal/observe.ts +0 -3
  186. package/src/effects/spotlight/Spotlight.svelte +1 -1
  187. package/src/effects/theme-toggle/ThemeToggle.css +25 -22
  188. package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
  189. package/src/gallery.test.ts +14 -4
  190. package/src/inbox/README.md +27 -0
  191. package/src/inbox/inbox.test.ts +214 -0
  192. package/src/inbox/index.ts +31 -0
  193. package/src/inbox/logic.ts +169 -0
  194. package/src/inbox/thread-inbox.svelte +439 -0
  195. package/src/inbox/types.ts +161 -0
  196. package/src/lib/theme-transition.ts +2 -4
  197. package/src/lib/tone.ts +2 -4
  198. package/src/lib/utils.ts +2 -2
  199. package/src/modules.test.ts +105 -0
  200. package/src/onboarding/define-tours.test.ts +55 -40
  201. package/src/onboarding/define-tours.ts +23 -23
  202. package/src/onboarding/demo.svelte +24 -5
  203. package/src/onboarding/index.ts +1 -8
  204. package/src/onboarding/onboarding-provider.svelte +19 -7
  205. package/src/onboarding/onboarding.svelte.ts +16 -16
  206. package/src/onboarding/pick.test.ts +61 -52
  207. package/src/onboarding/pick.ts +16 -13
  208. package/src/onboarding/stores.test.ts +80 -80
  209. package/src/onboarding/stores.ts +41 -39
  210. package/src/onboarding/tour-card.svelte +30 -5
  211. package/src/onboarding/tour-runner.svelte +12 -3
  212. package/src/onboarding/types.ts +20 -12
  213. package/src/onboarding/wait.ts +22 -22
  214. package/src/panel/README.md +5 -0
  215. package/src/panel/index.ts +2 -0
  216. package/src/panel/side-panel.svelte +194 -0
  217. package/src/record/README.md +3 -0
  218. package/src/record/demo-data.ts +352 -0
  219. package/src/record/field-section.svelte +57 -0
  220. package/src/record/fields.ts +194 -0
  221. package/src/record/format.ts +94 -0
  222. package/src/{tasks/record → record}/index.ts +49 -35
  223. package/src/{tasks/record → record}/info-list.svelte +145 -114
  224. package/src/record/kinds.ts +42 -0
  225. package/src/record/logic.ts +326 -0
  226. package/src/record/next-up.svelte +81 -0
  227. package/src/{tasks/record → record}/project-list.svelte +56 -50
  228. package/src/{tasks/record → record}/record-demo.svelte +130 -110
  229. package/src/{tasks/record → record}/record-header.svelte +37 -28
  230. package/src/{tasks/record → record}/record-shell.svelte +54 -54
  231. package/src/record/record-stats.svelte +48 -0
  232. package/src/record/record-timeline.svelte +251 -0
  233. package/src/record/record.test.ts +471 -0
  234. package/src/{tasks/record → record}/steps-rail.svelte +225 -216
  235. package/src/record/types.ts +299 -0
  236. package/src/record/where-we-are.svelte +125 -0
  237. package/src/shadcn/button/button.svelte +1 -1
  238. package/src/shadcn/card/card-title.svelte +1 -5
  239. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  240. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  241. package/src/shadcn/table/table-cell.svelte +2 -3
  242. package/src/shadcn/utilities.css +3 -8
  243. package/src/slides/Controls.svelte +150 -124
  244. package/src/slides/Deck.svelte +243 -238
  245. package/src/slides/Fragment.svelte +64 -57
  246. package/src/slides/Overview.svelte +106 -101
  247. package/src/slides/Presenter.svelte +300 -266
  248. package/src/slides/Slide.svelte +185 -170
  249. package/src/slides/SlideList.svelte +16 -12
  250. package/src/slides/ambient.d.ts +1 -1
  251. package/src/slides/bridges.ts +42 -23
  252. package/src/slides/deck-controller.svelte.ts +47 -35
  253. package/src/slides/demo-slides.svelte +330 -306
  254. package/src/slides/demo.svelte +164 -160
  255. package/src/slides/export/capture.ts +20 -16
  256. package/src/slides/export/ffmpeg.ts +43 -38
  257. package/src/slides/export/index.ts +5 -5
  258. package/src/slides/export/launch.ts +3 -1
  259. package/src/slides/export/pdf.ts +30 -27
  260. package/src/slides/export/types.ts +10 -8
  261. package/src/slides/index.ts +11 -11
  262. package/src/slides/labels.ts +22 -19
  263. package/src/slides/layout.ts +9 -2
  264. package/src/slides/model.ts +4 -4
  265. package/src/slides/navigation.ts +43 -31
  266. package/src/slides/theme.css +5 -7
  267. package/src/slides/types.ts +9 -10
  268. package/src/status/README.md +18 -0
  269. package/src/status/define-statuses.ts +25 -0
  270. package/src/status/index.ts +11 -0
  271. package/src/{tasks → status}/status-badge.svelte +3 -1
  272. package/src/{tasks → status}/status-dot.svelte +1 -3
  273. package/src/{tasks → status}/status-icon.svelte +1 -1
  274. package/src/status/status-types.ts +40 -0
  275. package/src/tasks/README.md +1 -1
  276. package/src/tasks/board-order.ts +2 -4
  277. package/src/tasks/demo.svelte +192 -34
  278. package/src/tasks/detail/detail-demo.svelte +75 -15
  279. package/src/tasks/detail/detail-types.ts +45 -45
  280. package/src/tasks/detail/task-detail.svelte +40 -10
  281. package/src/tasks/detail/task-property.svelte +8 -2
  282. package/src/tasks/detail/task-thread.svelte +53 -12
  283. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  284. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  285. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  286. package/src/tasks/gantt/gantt-header.svelte +9 -3
  287. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  288. package/src/tasks/gantt/gantt.css +2 -1
  289. package/src/tasks/gantt/gantt.test.ts +4 -2
  290. package/src/tasks/gantt/logic.test.ts +110 -20
  291. package/src/tasks/gantt/logic.ts +42 -6
  292. package/src/tasks/inbox/inbox-card.svelte +15 -7
  293. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  294. package/src/tasks/inbox/inbox-logic.ts +6 -40
  295. package/src/tasks/inbox/inbox-statuses.ts +24 -24
  296. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  297. package/src/tasks/inbox/inbox-types.ts +54 -58
  298. package/src/tasks/inbox/index.ts +22 -21
  299. package/src/tasks/index.ts +52 -53
  300. package/src/tasks/kanban-board.svelte +16 -12
  301. package/src/tasks/kanban-types.ts +2 -3
  302. package/src/tasks/presets/sales.ts +7 -7
  303. package/src/tasks/task-gantt.svelte +28 -8
  304. package/src/tasks/task-list.svelte +41 -17
  305. package/src/tasks/time/duration.ts +40 -40
  306. package/src/tasks/time/time-chip.svelte +9 -2
  307. package/src/tasks/time/time-dot.svelte +1 -1
  308. package/src/tasks/time/time-ring.svelte +11 -3
  309. package/src/tasks/time/time-tracker.svelte +57 -15
  310. package/src/tasks/time/time-types.ts +57 -57
  311. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  312. package/src/thread/README.md +31 -0
  313. package/src/thread/index.ts +12 -0
  314. package/src/thread/logic.ts +61 -0
  315. package/src/thread/thread.svelte +432 -0
  316. package/src/thread/thread.test.ts +73 -0
  317. package/src/thread/types.ts +93 -0
  318. package/src/tokens/craft.css +17 -53
  319. package/src/tokens/index.css +3 -13
  320. package/src/tokens/palette.css +13 -11
  321. package/src/tokens/primitives.css +10 -23
  322. package/src/components/list-page/section-label.svelte +0 -39
  323. package/src/tasks/define-statuses.ts +0 -32
  324. package/src/tasks/inbox/inbox.svelte +0 -203
  325. package/src/tasks/record/demo-data.ts +0 -286
  326. package/src/tasks/record/field-section.svelte +0 -53
  327. package/src/tasks/record/fields.ts +0 -142
  328. package/src/tasks/record/format.ts +0 -87
  329. package/src/tasks/record/kinds.ts +0 -42
  330. package/src/tasks/record/logic.ts +0 -324
  331. package/src/tasks/record/next-up.svelte +0 -70
  332. package/src/tasks/record/record-stats.svelte +0 -48
  333. package/src/tasks/record/record-timeline.svelte +0 -231
  334. package/src/tasks/record/record.test.ts +0 -350
  335. package/src/tasks/record/types.ts +0 -280
  336. package/src/tasks/record/where-we-are.svelte +0 -121
  337. package/src/tasks/side-panel.svelte +0 -193
  338. package/src/tasks/status-types.ts +0 -40
  339. /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
  340. /package/src/{tasks → panel}/side-panel-types.ts +0 -0
  341. /package/src/{tasks/record → record}/gutter.ts +0 -0
@@ -0,0 +1,326 @@
1
+ // The record's pure logic: grouping, merging, picking. No DOM, no clock of its own.
2
+ import type {
3
+ RecordEvent,
4
+ RecordFilterDef,
5
+ RecordNext,
6
+ RecordSummary,
7
+ StageDef,
8
+ StepDef,
9
+ StepGroupDef,
10
+ StepMarker,
11
+ } from "./types.js";
12
+
13
+ // =========================
14
+ // EVENTS
15
+ // =========================
16
+
17
+ /** The calendar day of a timestamp as `YYYY-MM-DD`, in the given zone or the viewer's own. */
18
+ export function localDay(iso: string, timeZone?: string): string {
19
+ return new Intl.DateTimeFormat("en-CA", {
20
+ year: "numeric",
21
+ month: "2-digit",
22
+ day: "2-digit",
23
+ timeZone,
24
+ }).format(new Date(iso));
25
+ }
26
+
27
+ export type DayGroup = { day: string; events: RecordEvent[] };
28
+
29
+ /** Newest day first, newest event first inside a day. */
30
+ export function groupEventsByDay(events: readonly RecordEvent[], timeZone?: string): DayGroup[] {
31
+ const sorted = [...events].sort(
32
+ (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
33
+ );
34
+ const groups: DayGroup[] = [];
35
+ for (const event of sorted) {
36
+ const day = localDay(event.occurredAt, timeZone);
37
+ const last = groups[groups.length - 1];
38
+ if (last && last.day === day) last.events.push(event);
39
+ else groups.push({ day, events: [event] });
40
+ }
41
+ return groups;
42
+ }
43
+
44
+ /** `today`, `yesterday` or `date`, by calendar distance from `today`. */
45
+ export function dayRelation(day: string, today: string): "today" | "yesterday" | "date" {
46
+ if (day === today) return "today";
47
+ const gap = (Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) / 86_400_000;
48
+ return gap === 1 ? "yesterday" : "date";
49
+ }
50
+
51
+ /** The events one filter chip shows. An unknown or empty key shows everything. */
52
+ export function eventsForFilter(
53
+ events: readonly RecordEvent[],
54
+ filterKey: string,
55
+ filters: readonly RecordFilterDef[],
56
+ ): RecordEvent[] {
57
+ const filter = filters.find((f) => f.key === filterKey);
58
+ if (!filter) return [...events];
59
+ const kinds = new Set(filter.kinds);
60
+ return events.filter((e) => kinds.has(e.kind));
61
+ }
62
+
63
+ /** Per-chip counts. Chips with no events are the host's to hide. */
64
+ export function filterCounts(
65
+ events: readonly RecordEvent[],
66
+ filters: readonly RecordFilterDef[],
67
+ ): Record<string, number> {
68
+ const counts: Record<string, number> = {};
69
+ for (const filter of filters) {
70
+ const kinds = new Set(filter.kinds);
71
+ counts[filter.key] = events.filter((e) => kinds.has(e.kind)).length;
72
+ }
73
+ return counts;
74
+ }
75
+
76
+ /** Keep the newest `limit` events and count the rest, cutting a day group where the limit falls. */
77
+ export function foldGroups(
78
+ groups: readonly DayGroup[],
79
+ limit: number,
80
+ ): { groups: DayGroup[]; hidden: number } {
81
+ const kept: DayGroup[] = [];
82
+ let used = 0;
83
+ let total = 0;
84
+ for (const group of groups) total += group.events.length;
85
+ for (const group of groups) {
86
+ if (used >= limit) break;
87
+ const room = limit - used;
88
+ const events = group.events.slice(0, room);
89
+ kept.push({ day: group.day, events });
90
+ used += events.length;
91
+ }
92
+ return { groups: kept, hidden: total - used };
93
+ }
94
+
95
+ export type ProjectEvents = {
96
+ id: string;
97
+ name: string;
98
+ href?: string | null;
99
+ events: readonly RecordEvent[];
100
+ };
101
+
102
+ /**
103
+ * One client's timeline, newest first, with project events tagged. An id seen twice keeps the
104
+ * first, so a source passed in both lists draws once.
105
+ */
106
+ export function mergeClientEvents(
107
+ clientEvents: readonly RecordEvent[],
108
+ projects: readonly ProjectEvents[],
109
+ ): RecordEvent[] {
110
+ const seen = new Set<string>();
111
+ const merged: RecordEvent[] = [];
112
+ const add = (event: RecordEvent) => {
113
+ if (seen.has(event.id)) return;
114
+ seen.add(event.id);
115
+ merged.push(event);
116
+ };
117
+ for (const project of projects) {
118
+ for (const event of project.events) {
119
+ add({
120
+ ...event,
121
+ projectId: project.id,
122
+ projectName: project.name,
123
+ projectHref: project.href ?? null,
124
+ });
125
+ }
126
+ }
127
+ for (const event of clientEvents)
128
+ add({ ...event, projectId: null, projectName: null, projectHref: null });
129
+ return merged.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
130
+ }
131
+
132
+ // =========================
133
+ // PROJECTS
134
+ // =========================
135
+
136
+ const ACTIVE: ReadonlySet<string> = new Set(["open", "proposal", "ongoing"]);
137
+
138
+ /** Projects still in play: not closed, and in a stage that isn't won-and-done or lost. */
139
+ export function activeProjects(
140
+ projects: readonly RecordSummary[],
141
+ stages: readonly StageDef[],
142
+ ): RecordSummary[] {
143
+ const category = new Map(stages.map((s) => [s.key, s.category]));
144
+ return projects.filter((p) => {
145
+ if (p.closed) return false;
146
+ const c = category.get(p.stageKey);
147
+ return c === undefined || ACTIVE.has(c);
148
+ });
149
+ }
150
+
151
+ /** The earliest dated next across active projects, undated ones after, ties by list order. */
152
+ export function nextAcrossProjects(
153
+ projects: readonly RecordSummary[],
154
+ stages: readonly StageDef[],
155
+ ): RecordNext | null {
156
+ let best: RecordNext | null = null;
157
+ for (const project of activeProjects(projects, stages)) {
158
+ const next = project.next;
159
+ if (!next) continue;
160
+ const candidate: RecordNext = { ...next, projectId: project.id, projectName: project.name };
161
+ if (!best) {
162
+ best = candidate;
163
+ continue;
164
+ }
165
+ if (!best.date && candidate.date) best = candidate;
166
+ else if (best.date && candidate.date && candidate.date < best.date) best = candidate;
167
+ }
168
+ return best;
169
+ }
170
+
171
+ export type LatestStatus = NonNullable<RecordSummary["whereWeAre"]> & {
172
+ projectId: string;
173
+ projectName: string;
174
+ };
175
+
176
+ /** The newest "where we are" across projects, tagged with its project. */
177
+ export function latestWhereWeAre(projects: readonly RecordSummary[]): LatestStatus | null {
178
+ let best: LatestStatus | null = null;
179
+ for (const project of projects) {
180
+ const status = project.whereWeAre;
181
+ if (!status || !status.text) continue;
182
+ const at = status.at ? new Date(status.at).getTime() : 0;
183
+ const bestAt = best?.at ? new Date(best.at).getTime() : -1;
184
+ if (!best || at > bestAt)
185
+ best = { ...status, projectId: project.id, projectName: project.name };
186
+ }
187
+ return best;
188
+ }
189
+
190
+ export type MoneyTotals = Record<string, { pipeline: number; won: number; paid: number }>;
191
+
192
+ export type ClientRollup = {
193
+ count: number;
194
+ activeCount: number;
195
+ totals: MoneyTotals;
196
+ lastCallAt: string | null;
197
+ };
198
+
199
+ /**
200
+ * The client's pipeline, won and paid totals, kept per currency because a sum across two
201
+ * currencies is a lie.
202
+ */
203
+ export function rollupProjects(
204
+ projects: readonly RecordSummary[],
205
+ stages: readonly StageDef[],
206
+ fallbackCurrency = "EUR",
207
+ ): ClientRollup {
208
+ const category = new Map(stages.map((s) => [s.key, s.category]));
209
+ const totals: MoneyTotals = {};
210
+ let lastCallAt: string | null = null;
211
+ for (const project of projects) {
212
+ const currency = project.currency ?? fallbackCurrency;
213
+ const bucket = (totals[currency] ??= { pipeline: 0, won: 0, paid: 0 });
214
+ const c = category.get(project.stageKey);
215
+ const value = project.value ?? 0;
216
+ if (c === "open" || c === "proposal") bucket.pipeline += value;
217
+ if (c === "ongoing" || c === "completed") bucket.won += value;
218
+ bucket.paid += project.paid ?? 0;
219
+ if (project.lastCallAt && (!lastCallAt || project.lastCallAt > lastCallAt))
220
+ lastCallAt = project.lastCallAt;
221
+ }
222
+ return {
223
+ count: projects.length,
224
+ activeCount: activeProjects(projects, stages).length,
225
+ totals,
226
+ lastCallAt,
227
+ };
228
+ }
229
+
230
+ // =========================
231
+ // STEPS
232
+ // =========================
233
+
234
+ /** A step is visible unless hidden. */
235
+ export function visibleSteps(steps: readonly StepDef[]): StepDef[] {
236
+ return steps.filter((s) => !s.hidden);
237
+ }
238
+
239
+ export function stepProgress(steps: readonly StepDef[]): { done: number; total: number } {
240
+ const visible = visibleSteps(steps);
241
+ return { done: visible.filter((s) => s.done).length, total: visible.length };
242
+ }
243
+
244
+ /**
245
+ * One marker per step. Only an open step before the current one, or past its own date, is
246
+ * late. `today` is an ISO date.
247
+ */
248
+ export function stepMarkers(steps: readonly StepDef[], today: string): Map<string, StepMarker> {
249
+ const visible = visibleSteps(steps);
250
+ const currentId = visible.find((s) => !s.done)?.id ?? null;
251
+ const markers = new Map<string, StepMarker>();
252
+ let beforeCurrent = currentId !== null;
253
+ for (const step of visible) {
254
+ if (step.done) {
255
+ markers.set(step.id, "complete");
256
+ continue;
257
+ }
258
+ if (step.id === currentId) {
259
+ markers.set(step.id, "current");
260
+ beforeCurrent = false;
261
+ continue;
262
+ }
263
+ const due = (step.end ?? step.start)?.slice(0, 10);
264
+ const overdue = due !== undefined && due < today;
265
+ markers.set(step.id, beforeCurrent || overdue ? "late" : "pending");
266
+ }
267
+ return markers;
268
+ }
269
+
270
+ export type StepBucket = { key: string; label: string | null; steps: StepDef[] };
271
+
272
+ /**
273
+ * Buckets by declared group order, not consecutive runs, so a reordered rail still draws one
274
+ * header per group. Ungrouped steps go last.
275
+ */
276
+ export function groupSteps(
277
+ steps: readonly StepDef[],
278
+ groups: readonly StepGroupDef[] = [],
279
+ ): StepBucket[] {
280
+ const known = new Set(groups.map((g) => g.key));
281
+ const buckets: StepBucket[] = groups.map((g) => ({ key: g.key, label: g.label, steps: [] }));
282
+ const loose: StepBucket = { key: "", label: null, steps: [] };
283
+ for (const step of steps) {
284
+ const bucket = buckets.find((b) => b.key === step.group);
285
+ (step.group && known.has(step.group) && bucket ? bucket : loose).steps.push(step);
286
+ }
287
+ const result = buckets.filter((b) => b.steps.length > 0);
288
+ if (loose.steps.length > 0) result.push(loose);
289
+ return result;
290
+ }
291
+
292
+ /** Move `movedId` next to `targetId` inside the id list. Same list back when nothing moves. */
293
+ export function reorderIds(
294
+ ids: readonly string[],
295
+ movedId: string,
296
+ targetId: string,
297
+ before: boolean,
298
+ ): string[] {
299
+ if (movedId === targetId) return [...ids];
300
+ const from = ids.indexOf(movedId);
301
+ if (from < 0 || !ids.includes(targetId)) return [...ids];
302
+ const next = [...ids];
303
+ next.splice(from, 1);
304
+ const at = next.indexOf(targetId);
305
+ next.splice(before ? at : at + 1, 0, movedId);
306
+ return next;
307
+ }
308
+
309
+ /** Swap a step with its neighbour in its own group. Same list back at an edge. */
310
+ export function nudgeStep(steps: readonly StepDef[], id: string, direction: -1 | 1): string[] {
311
+ const ids = steps.map((s) => s.id);
312
+ const step = steps.find((s) => s.id === id);
313
+ if (!step) return ids;
314
+ const mates = steps.filter((s) => s.group === step.group && !s.hidden);
315
+ const at = mates.findIndex((s) => s.id === id);
316
+ const neighbour = mates[at + direction];
317
+ if (!neighbour) return ids;
318
+ const a = ids.indexOf(id);
319
+ const b = ids.indexOf(neighbour.id);
320
+ const first = ids[a];
321
+ const second = ids[b];
322
+ if (first === undefined || second === undefined) return ids;
323
+ ids[a] = second;
324
+ ids[b] = first;
325
+ return ids;
326
+ }
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { ArrowRight } from "phosphor-svelte";
6
+ import { cn } from "../lib/utils.js";
7
+ import { TONE_TEXT } from "../lib/tone.js";
8
+ import StatusBadge from "../status/status-badge.svelte";
9
+ import { gutterClass } from "./gutter.js";
10
+ import { formatRange } from "./format.js";
11
+ import { todayIso } from "../tasks/dates.js";
12
+ import { resolveLabels, type RecordLabels, type RecordNext } from "./types.js";
13
+
14
+ // =========================
15
+ // PROPS
16
+ // =========================
17
+ interface Props {
18
+ next?: RecordNext | null;
19
+ /** Client mode: say which project the next step belongs to. */
20
+ showProject?: boolean;
21
+ labels?: Partial<RecordLabels>;
22
+ locale?: string;
23
+ /** ISO date. Decides "late". Defaults to the viewer's today. */
24
+ today?: string;
25
+ class?: string;
26
+ }
27
+ let {
28
+ next,
29
+ showProject = false,
30
+ labels,
31
+ locale,
32
+ today = todayIso(),
33
+ class: className,
34
+ }: Props = $props();
35
+
36
+ // =========================
37
+ // DERIVED
38
+ // =========================
39
+ let t = $derived(resolveLabels(labels));
40
+ let due = $derived((next?.endDate ?? next?.date)?.slice(0, 10));
41
+ let late = $derived(due !== undefined && due < today);
42
+ let when = $derived(next?.date ? formatRange(next.date, next.endDate, locale) : null);
43
+ </script>
44
+
45
+ <!-- =========================== -->
46
+ <!-- MARKUP -->
47
+ <!-- =========================== -->
48
+ {#if next}
49
+ <section
50
+ aria-label={t.next}
51
+ class={cn(
52
+ "flex flex-wrap items-center gap-x-2.5 gap-y-1 border-b border-border py-2 text-sm",
53
+ gutterClass,
54
+ className,
55
+ )}
56
+ >
57
+ <ArrowRight class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
58
+ <span class="font-medium">{t.next}</span>
59
+ {#if when}
60
+ <span class={cn("tabular-nums", late ? TONE_TEXT.warning : "text-muted-foreground")}
61
+ >{when}</span
62
+ >
63
+ {/if}
64
+ {#if next.href}
65
+ <a href={next.href} class="min-w-0 truncate underline-offset-2 hover:underline"
66
+ >{next.label}</a
67
+ >
68
+ {:else}
69
+ <span class="min-w-0 truncate">{next.label}</span>
70
+ {/if}
71
+ {#if next.owner}
72
+ <span class="text-muted-foreground">{next.owner}</span>
73
+ {/if}
74
+ {#if showProject && next.projectName}
75
+ <span class="text-muted-foreground">{next.projectName}</span>
76
+ {/if}
77
+ {#if late}
78
+ <StatusBadge tone="warning" capitalize={false}>{t.late}</StatusBadge>
79
+ {/if}
80
+ </section>
81
+ {/if}
@@ -1,53 +1,53 @@
1
1
  <script lang="ts">
2
- // =========================
3
- // IMPORTS
4
- // =========================
5
- import { cn } from "../../lib/utils.js";
6
- import { Progress } from "../../shadcn/progress/index.js";
7
- import { TONE_TEXT } from "../../lib/tone.js";
8
- import StatusBadge from "../status-badge.svelte";
9
- import { todayIso } from "../dates.js";
10
- import { formatMoney, formatRange } from "./format.js";
11
- import {
12
- resolveLabels,
13
- type RecordLabels,
14
- type RecordSummary,
15
- type StageDef,
16
- } from "./types.js";
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { cn } from "../lib/utils.js";
6
+ import { Progress } from "../shadcn/progress/index.js";
7
+ import { TONE_TEXT } from "../lib/tone.js";
8
+ import StatusBadge from "../status/status-badge.svelte";
9
+ import { todayIso } from "../tasks/dates.js";
10
+ import { formatMoney, formatRange } from "./format.js";
11
+ import {
12
+ resolveLabels,
13
+ type RecordLabels,
14
+ type RecordSummary,
15
+ type StageDef,
16
+ } from "./types.js";
17
17
 
18
- // =========================
19
- // PROPS
20
- // =========================
21
- interface Props {
22
- projects: RecordSummary[];
23
- /** The agency's stages: each project draws its stage's label and tone. */
24
- stages: StageDef[];
25
- /** A project being looked at, marked in the list. */
26
- activeId?: string | null;
27
- /** Money shown when a project has none of its own. */
28
- currency?: string;
29
- labels?: Partial<RecordLabels>;
30
- locale?: string;
31
- /** ISO date. Decides "late". Defaults to the viewer's today. */
32
- today?: string;
33
- class?: string;
34
- }
35
- let {
36
- projects,
37
- stages,
38
- activeId = null,
39
- currency = "EUR",
40
- labels,
41
- locale,
42
- today = todayIso(),
43
- class: className,
44
- }: Props = $props();
18
+ // =========================
19
+ // PROPS
20
+ // =========================
21
+ interface Props {
22
+ projects: RecordSummary[];
23
+ /** The agency's stages: each project draws its stage's label and tone. */
24
+ stages: StageDef[];
25
+ /** A project being looked at, marked in the list. */
26
+ activeId?: string | null;
27
+ /** Money shown when a project has none of its own. */
28
+ currency?: string;
29
+ labels?: Partial<RecordLabels>;
30
+ locale?: string;
31
+ /** ISO date. Decides "late". Defaults to the viewer's today. */
32
+ today?: string;
33
+ class?: string;
34
+ }
35
+ let {
36
+ projects,
37
+ stages,
38
+ activeId = null,
39
+ currency = "EUR",
40
+ labels,
41
+ locale,
42
+ today = todayIso(),
43
+ class: className,
44
+ }: Props = $props();
45
45
 
46
- // =========================
47
- // DERIVED
48
- // =========================
49
- let t = $derived(resolveLabels(labels));
50
- let stageByKey = $derived(new Map(stages.map((s) => [s.key, s])));
46
+ // =========================
47
+ // DERIVED
48
+ // =========================
49
+ let t = $derived(resolveLabels(labels));
50
+ let stageByKey = $derived(new Map(stages.map((s) => [s.key, s])));
51
51
  </script>
52
52
 
53
53
  <!-- =========================== -->
@@ -67,7 +67,9 @@ let stageByKey = $derived(new Map(stages.map((s) => [s.key, s])));
67
67
  </span>
68
68
  <span class="flex flex-wrap items-center gap-x-2 gap-y-1">
69
69
  <StatusBadge tone={stage?.tone ?? "neutral"} dot capitalize={false}>
70
- {stage ? stage.label : project.stageKey}{project.stageDetail ? ` · ${project.stageDetail}` : ""}
70
+ {stage ? stage.label : project.stageKey}{project.stageDetail
71
+ ? ` · ${project.stageDetail}`
72
+ : ""}
71
73
  </StatusBadge>
72
74
  {#if project.whereWeAre?.health}
73
75
  <StatusBadge tone={project.whereWeAre.health.tone} capitalize={false}>
@@ -76,7 +78,9 @@ let stageByKey = $derived(new Map(stages.map((s) => [s.key, s])));
76
78
  {/if}
77
79
  </span>
78
80
  {#if project.whereWeAre?.text}
79
- <span class="line-clamp-2 text-xs leading-snug text-muted-foreground">{project.whereWeAre.text}</span>
81
+ <span class="line-clamp-2 text-xs leading-snug text-muted-foreground"
82
+ >{project.whereWeAre.text}</span
83
+ >
80
84
  {/if}
81
85
  {#if project.progress && project.progress.total > 0}
82
86
  <span class="flex items-center gap-2">
@@ -96,7 +100,9 @@ let stageByKey = $derived(new Map(stages.map((s) => [s.key, s])));
96
100
  <span class="flex flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
97
101
  <span>{t.next}</span>
98
102
  {#if next.date}
99
- <span class={cn("tabular-nums", late && TONE_TEXT.warning)}>{formatRange(next.date, next.endDate, locale)}</span>
103
+ <span class={cn("tabular-nums", late && TONE_TEXT.warning)}
104
+ >{formatRange(next.date, next.endDate, locale)}</span
105
+ >
100
106
  {/if}
101
107
  <span class="min-w-0 truncate text-foreground">{next.label}</span>
102
108
  </span>