@heyramzi/ui 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/COMPONENTS.md +1 -1
  2. package/package.json +7 -8
  3. package/scripts/sync-shadcn.mjs +3 -4
  4. package/src/agents/agent-avatar.svelte +604 -575
  5. package/src/agents/agent-cast.svelte +179 -183
  6. package/src/agents/demo.svelte +125 -113
  7. package/src/agents/helpers.ts +23 -17
  8. package/src/agents/index.ts +4 -4
  9. package/src/agents/page-state.ts +6 -6
  10. package/src/agents/types.ts +6 -6
  11. package/src/ai/ai-button/AiButton.svelte +4 -12
  12. package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
  13. package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
  14. package/src/ai/response/Response.css +2 -3
  15. package/src/animations/avatar-group-hover.css +2 -16
  16. package/src/animations/card-tilt.css +3 -6
  17. package/src/animations/digit-pop-in.css +2 -15
  18. package/src/animations/dropdown.css +4 -17
  19. package/src/animations/input-clear-dissolve.css +5 -18
  20. package/src/animations/input-shake.css +9 -41
  21. package/src/animations/learn-more-hover.css +17 -9
  22. package/src/animations/like-button.css +2 -4
  23. package/src/animations/page-side-by-side.css +2 -10
  24. package/src/animations/plus-menu-morph.css +2 -5
  25. package/src/animations/shimmer-text.css +5 -18
  26. package/src/animations/spinning-counter.css +2 -6
  27. package/src/animations/success-check.css +2 -15
  28. package/src/animations/thinking-states.css +2 -7
  29. package/src/brand/brand.test.ts +21 -5
  30. package/src/brand/index.ts +4 -10
  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/canvas-cleanup.test.ts +1 -1
  49. package/src/canvas/headless/svg-figure.test.ts +5 -1
  50. package/src/canvas/lod/lod-switch.svelte +111 -106
  51. package/src/canvas/ops/ops.ts +1 -1
  52. package/src/canvas/pen/PenField.svelte +87 -81
  53. package/src/canvas/pen/PenOverlay.svelte +127 -127
  54. package/src/canvas/pen/ShapeNode.svelte +31 -32
  55. package/src/canvas/pen/TextNode.svelte +54 -53
  56. package/src/canvas/pen/host-field.test.svelte +7 -7
  57. package/src/canvas/pen/shapes.ts +7 -1
  58. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  59. package/src/canvas/screen/PartView.svelte +138 -72
  60. package/src/canvas/screen/ScreenBody.svelte +81 -60
  61. package/src/canvas/screen/ScreenCard.svelte +20 -20
  62. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  63. package/src/canvas/ui/FrameLayer.svelte +59 -59
  64. package/src/canvas/ui/PenLayer.svelte +39 -30
  65. package/src/canvas/ui/StylePanel.svelte +119 -102
  66. package/src/canvas/ui/canvas-grid.svelte +137 -129
  67. package/src/charts/animated-value.svelte +1 -1
  68. package/src/charts/area-chart.css +2 -5
  69. package/src/charts/area-chart.svelte +1 -1
  70. package/src/charts/bar-chart.css +2 -3
  71. package/src/charts/bar-chart.svelte +1 -1
  72. package/src/charts/charts.css +5 -17
  73. package/src/charts/crosshair.svelte +1 -1
  74. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  75. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  76. package/src/charts/dashboard/data-panel.svelte +50 -50
  77. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  78. package/src/charts/dashboard/data-table.svelte +138 -138
  79. package/src/charts/dashboard/panel-grid.svelte +30 -30
  80. package/src/charts/dashboard/period-filter.svelte +25 -25
  81. package/src/charts/dashboard/rating-chip.svelte +19 -19
  82. package/src/charts/dashboard/stat-band.svelte +32 -32
  83. package/src/charts/dashboard/stat-tile.svelte +106 -99
  84. package/src/charts/donut-chart.svelte +1 -1
  85. package/src/charts/edge-fade.svelte +1 -1
  86. package/src/charts/figure.svelte +1 -1
  87. package/src/charts/gauge-chart.css +4 -7
  88. package/src/charts/gauge-chart.svelte +1 -1
  89. package/src/charts/hover-focus.svelte +1 -1
  90. package/src/charts/legend.css +1 -3
  91. package/src/charts/legend.svelte +1 -1
  92. package/src/charts/line-chart.svelte +1 -1
  93. package/src/charts/number-chart.css +6 -9
  94. package/src/charts/number-chart.svelte +1 -1
  95. package/src/charts/reference-line.svelte +19 -19
  96. package/src/charts/reveal.svelte +1 -1
  97. package/src/charts/ring-chart.css +2 -4
  98. package/src/charts/ring-chart.svelte +1 -1
  99. package/src/charts/sparkline.svelte +5 -2
  100. package/src/charts/stat-card.svelte +1 -1
  101. package/src/charts/time-series-chart.css +2 -5
  102. package/src/charts/trend-pill.css +2 -4
  103. package/src/charts/trend-pill.svelte +1 -1
  104. package/src/components/copy/copy-button.svelte +93 -98
  105. package/src/components/copy/copy-button.test.ts +10 -2
  106. package/src/components/data-table/infinite-scroll.svelte +42 -42
  107. package/src/components/data-table/pagination.svelte +34 -22
  108. package/src/components/data-table/row-arrow.svelte +19 -16
  109. package/src/components/data-table/row-context-menu.svelte +52 -52
  110. package/src/components/data-table/sortable-head.svelte +25 -25
  111. package/src/components/data-table/table-skeleton.svelte +41 -36
  112. package/src/components/docs/doc-page.svelte +18 -9
  113. package/src/components/docs/on-this-page.svelte +16 -3
  114. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  115. package/src/components/field-row/field-row.svelte +10 -8
  116. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  117. package/src/components/icon/icon.svelte +14 -14
  118. package/src/components/language-flag/language-flag.svelte +3 -1
  119. package/src/components/list-page/detail-empty.svelte +34 -34
  120. package/src/components/list-page/detail-header.svelte +55 -47
  121. package/src/components/list-page/detail-panel.svelte +51 -59
  122. package/src/components/list-page/detail-row.svelte +19 -19
  123. package/src/components/list-page/detail-stack.svelte +21 -21
  124. package/src/components/list-page/empty-state.svelte +30 -30
  125. package/src/components/list-page/filter-item.svelte +20 -20
  126. package/src/components/list-page/filter-popover.svelte +60 -55
  127. package/src/components/list-page/grouped-list.svelte +4 -2
  128. package/src/components/list-page/index.ts +5 -1
  129. package/src/components/list-page/list-page-shell.svelte +2 -5
  130. package/src/components/list-page/master-detail.svelte +87 -88
  131. package/src/components/list-page/notice-bar.svelte +38 -38
  132. package/src/components/list-page/prev-next.svelte +63 -63
  133. package/src/components/list-page/rail-body.svelte +18 -18
  134. package/src/components/list-page/rail-empty.svelte +23 -30
  135. package/src/components/list-page/rail-head.svelte +24 -24
  136. package/src/components/list-page/rail-row.svelte +77 -73
  137. package/src/components/list-page/section-host.ts +2 -3
  138. package/src/components/locale-switch/locale-switch.svelte +41 -41
  139. package/src/components/mark/mark.svelte +34 -38
  140. package/src/components/open-in/index.ts +1 -5
  141. package/src/components/open-in/open-in.svelte +40 -40
  142. package/src/components/picker/mark-picker.svelte +49 -49
  143. package/src/components/picker/picker.svelte +5 -3
  144. package/src/components/section-header/section-header.test.ts +7 -1
  145. package/src/components/section-header/section-label.svelte +20 -20
  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/index.ts +1 -1
  158. package/src/components/table-view/table-view.svelte +352 -13
  159. package/src/components/table-view/table-view.test.ts +108 -1
  160. package/src/components/table-view/types.ts +22 -11
  161. package/src/components/tags-input/index.ts +13 -0
  162. package/src/components/tags-input/tags-input.svelte +305 -0
  163. package/src/components/tags-input/tags.test.ts +64 -0
  164. package/src/components/tags-input/tags.ts +142 -0
  165. package/src/components/theme/index.ts +2 -4
  166. package/src/components/theme/theme-provider.svelte +1 -3
  167. package/src/components/toolbar/toolbar-search.svelte +25 -25
  168. package/src/components/toolbar/toolbar.svelte +11 -7
  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/icons/trades.ts +1 -1
  191. package/src/inbox/README.md +1 -3
  192. package/src/inbox/inbox.test.ts +181 -138
  193. package/src/inbox/index.ts +20 -20
  194. package/src/inbox/logic.ts +114 -111
  195. package/src/inbox/thread-inbox.svelte +227 -151
  196. package/src/inbox/types.ts +130 -108
  197. package/src/lib/theme-transition.ts +2 -4
  198. package/src/lib/tone.ts +2 -4
  199. package/src/lib/utils.ts +2 -2
  200. package/src/modules.test.ts +79 -69
  201. package/src/onboarding/define-tours.test.ts +55 -40
  202. package/src/onboarding/define-tours.ts +23 -23
  203. package/src/onboarding/demo.svelte +24 -5
  204. package/src/onboarding/index.ts +1 -8
  205. package/src/onboarding/onboarding-provider.svelte +19 -7
  206. package/src/onboarding/onboarding.svelte.ts +16 -16
  207. package/src/onboarding/pick.test.ts +61 -52
  208. package/src/onboarding/pick.ts +16 -13
  209. package/src/onboarding/stores.test.ts +80 -80
  210. package/src/onboarding/stores.ts +41 -39
  211. package/src/onboarding/tour-card.svelte +30 -5
  212. package/src/onboarding/tour-runner.svelte +12 -3
  213. package/src/onboarding/types.ts +20 -12
  214. package/src/onboarding/wait.ts +22 -22
  215. package/src/panel/side-panel.svelte +94 -93
  216. package/src/record/demo-data.ts +291 -225
  217. package/src/record/field-section.svelte +38 -34
  218. package/src/record/fields.ts +148 -96
  219. package/src/record/format.ts +63 -56
  220. package/src/record/index.ts +49 -35
  221. package/src/record/info-list.svelte +145 -114
  222. package/src/record/kinds.ts +31 -31
  223. package/src/record/logic.ts +216 -214
  224. package/src/record/next-up.svelte +45 -34
  225. package/src/record/project-list.svelte +56 -50
  226. package/src/record/record-demo.svelte +130 -110
  227. package/src/record/record-header.svelte +37 -28
  228. package/src/record/record-shell.svelte +54 -54
  229. package/src/record/record-stats.svelte +25 -25
  230. package/src/record/record-timeline.svelte +134 -114
  231. package/src/record/record.test.ts +430 -309
  232. package/src/record/steps-rail.svelte +225 -216
  233. package/src/record/types.ts +234 -215
  234. package/src/record/where-we-are.svelte +62 -58
  235. package/src/shadcn/button/button.svelte +1 -1
  236. package/src/shadcn/card/card-title.svelte +1 -5
  237. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  238. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  239. package/src/shadcn/table/table-cell.svelte +2 -3
  240. package/src/shadcn/utilities.css +3 -8
  241. package/src/slides/Controls.svelte +150 -124
  242. package/src/slides/Deck.svelte +243 -238
  243. package/src/slides/Fragment.svelte +64 -57
  244. package/src/slides/Overview.svelte +106 -101
  245. package/src/slides/Presenter.svelte +300 -266
  246. package/src/slides/Slide.svelte +185 -170
  247. package/src/slides/SlideList.svelte +16 -12
  248. package/src/slides/ambient.d.ts +1 -1
  249. package/src/slides/bridges.ts +42 -23
  250. package/src/slides/deck-controller.svelte.ts +47 -35
  251. package/src/slides/demo-slides.svelte +330 -306
  252. package/src/slides/demo.svelte +164 -160
  253. package/src/slides/export/capture.ts +20 -16
  254. package/src/slides/export/ffmpeg.ts +43 -38
  255. package/src/slides/export/index.ts +5 -5
  256. package/src/slides/export/launch.ts +3 -1
  257. package/src/slides/export/pdf.ts +30 -27
  258. package/src/slides/export/types.ts +10 -8
  259. package/src/slides/index.ts +11 -11
  260. package/src/slides/labels.ts +22 -19
  261. package/src/slides/layout.ts +9 -2
  262. package/src/slides/model.ts +4 -4
  263. package/src/slides/navigation.ts +43 -31
  264. package/src/slides/theme.css +5 -7
  265. package/src/slides/types.ts +9 -10
  266. package/src/status/define-statuses.ts +17 -24
  267. package/src/status/index.ts +5 -5
  268. package/src/status/status-badge.svelte +3 -1
  269. package/src/status/status-dot.svelte +1 -3
  270. package/src/status/status-icon.svelte +1 -1
  271. package/src/status/status-types.ts +21 -21
  272. package/src/tasks/board-order.ts +2 -4
  273. package/src/tasks/demo.svelte +189 -31
  274. package/src/tasks/detail/detail-demo.svelte +73 -13
  275. package/src/tasks/detail/detail-types.ts +45 -45
  276. package/src/tasks/detail/task-detail.svelte +40 -10
  277. package/src/tasks/detail/task-property.svelte +8 -2
  278. package/src/tasks/detail/task-thread.svelte +53 -12
  279. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  280. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  281. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  282. package/src/tasks/gantt/gantt-header.svelte +9 -3
  283. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  284. package/src/tasks/gantt/gantt.css +2 -1
  285. package/src/tasks/gantt/gantt.test.ts +3 -1
  286. package/src/tasks/gantt/logic.test.ts +110 -20
  287. package/src/tasks/gantt/logic.ts +42 -6
  288. package/src/tasks/inbox/inbox-card.svelte +13 -5
  289. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  290. package/src/tasks/inbox/inbox-logic.ts +6 -40
  291. package/src/tasks/inbox/inbox-statuses.ts +23 -23
  292. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  293. package/src/tasks/inbox/inbox-types.ts +54 -58
  294. package/src/tasks/inbox/index.ts +22 -21
  295. package/src/tasks/index.ts +49 -52
  296. package/src/tasks/kanban-board.svelte +16 -12
  297. package/src/tasks/kanban-types.ts +2 -3
  298. package/src/tasks/presets/sales.ts +6 -6
  299. package/src/tasks/task-gantt.svelte +28 -8
  300. package/src/tasks/task-list.svelte +41 -17
  301. package/src/tasks/time/duration.ts +40 -40
  302. package/src/tasks/time/time-chip.svelte +9 -2
  303. package/src/tasks/time/time-dot.svelte +1 -1
  304. package/src/tasks/time/time-ring.svelte +11 -3
  305. package/src/tasks/time/time-tracker.svelte +57 -15
  306. package/src/tasks/time/time-types.ts +57 -57
  307. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  308. package/src/thread/index.ts +7 -7
  309. package/src/thread/logic.ts +36 -33
  310. package/src/thread/thread.svelte +82 -16
  311. package/src/thread/thread.test.ts +58 -47
  312. package/src/thread/types.ts +68 -68
  313. package/src/tokens/craft.css +17 -53
  314. package/src/tokens/index.css +3 -13
  315. package/src/tokens/palette.css +13 -11
  316. package/src/tokens/primitives.css +10 -23
  317. package/src/tasks/inbox/inbox.svelte +0 -203
@@ -1,350 +1,471 @@
1
1
  import { describe, expect, it } from "vite-plus/test";
2
2
  import {
3
- activeProjects,
4
- dayRelation,
5
- eventsForFilter,
6
- filterCounts,
7
- foldGroups,
8
- groupEventsByDay,
9
- groupSteps,
10
- latestWhereWeAre,
11
- mergeClientEvents,
12
- nextAcrossProjects,
13
- nudgeStep,
14
- reorderIds,
15
- rollupProjects,
16
- stepMarkers,
17
- stepProgress,
3
+ activeProjects,
4
+ dayRelation,
5
+ eventsForFilter,
6
+ filterCounts,
7
+ foldGroups,
8
+ groupEventsByDay,
9
+ groupSteps,
10
+ latestWhereWeAre,
11
+ mergeClientEvents,
12
+ nextAcrossProjects,
13
+ nudgeStep,
14
+ reorderIds,
15
+ rollupProjects,
16
+ stepMarkers,
17
+ stepProgress,
18
18
  } from "./logic.js";
19
19
  import { formatRange } from "./format.js";
20
- import { fieldRow, groupFields, isFieldVisible, parseSaved, pickableOptions } from "./fields.js";
20
+ import {
21
+ fieldRow,
22
+ groupFields,
23
+ isFieldVisible,
24
+ parseSaved,
25
+ pickableOptions,
26
+ } from "./fields.js";
21
27
  import type { FieldDef, RecordEvent, RecordSummary, StageDef, StepDef } from "./types.js";
22
28
 
23
29
  const ev = (id: string, kind: string, occurredAt: string): RecordEvent => ({
24
- id,
25
- kind,
26
- occurredAt,
27
- title: id,
30
+ id,
31
+ kind,
32
+ occurredAt,
33
+ title: id,
28
34
  });
29
35
 
30
36
  const stages: StageDef[] = [
31
- { key: "lead", label: "Lead", tone: "neutral", category: "open" },
32
- { key: "sent", label: "Proposal sent", tone: "info", category: "proposal" },
33
- { key: "build", label: "In build", tone: "accent", category: "ongoing" },
34
- { key: "paid", label: "Paid", tone: "success", category: "completed" },
35
- { key: "lost", label: "Lost", tone: "destructive", category: "lost" },
37
+ { key: "lead", label: "Lead", tone: "neutral", category: "open" },
38
+ { key: "sent", label: "Proposal sent", tone: "info", category: "proposal" },
39
+ { key: "build", label: "In build", tone: "accent", category: "ongoing" },
40
+ { key: "paid", label: "Paid", tone: "success", category: "completed" },
41
+ { key: "lost", label: "Lost", tone: "destructive", category: "lost" },
36
42
  ];
37
43
 
38
44
  const project = (over: Partial<RecordSummary> & { id: string }): RecordSummary => ({
39
- name: over.id,
40
- stageKey: "lead",
41
- ...over,
45
+ name: over.id,
46
+ stageKey: "lead",
47
+ ...over,
42
48
  });
43
49
 
44
50
  describe("groupEventsByDay", () => {
45
- it("groups by calendar day, newest day and newest event first", () => {
46
- const groups = groupEventsByDay(
47
- [
48
- ev("a", "note", "2026-10-20T09:00:00Z"),
49
- ev("b", "call", "2026-10-21T08:00:00Z"),
50
- ev("c", "note", "2026-10-21T16:00:00Z"),
51
- ],
52
- "UTC",
53
- );
54
- expect(groups.map((g) => g.day)).toEqual(["2026-10-21", "2026-10-20"]);
55
- expect(groups[0]?.events.map((e) => e.id)).toEqual(["c", "b"]);
56
- });
57
-
58
- it("reads the day in the given zone, so late evening UTC is tomorrow in Tokyo", () => {
59
- const events = [ev("a", "note", "2026-10-21T20:00:00Z")];
60
- expect(groupEventsByDay(events, "UTC")[0]?.day).toBe("2026-10-21");
61
- expect(groupEventsByDay(events, "Asia/Tokyo")[0]?.day).toBe("2026-10-22");
62
- });
63
-
64
- it("returns nothing for no events and does not mutate its input", () => {
65
- expect(groupEventsByDay([], "UTC")).toEqual([]);
66
- const input = [ev("a", "note", "2026-10-20T09:00:00Z"), ev("b", "note", "2026-10-21T09:00:00Z")];
67
- groupEventsByDay(input, "UTC");
68
- expect(input.map((e) => e.id)).toEqual(["a", "b"]);
69
- });
51
+ it("groups by calendar day, newest day and newest event first", () => {
52
+ const groups = groupEventsByDay(
53
+ [
54
+ ev("a", "note", "2026-10-20T09:00:00Z"),
55
+ ev("b", "call", "2026-10-21T08:00:00Z"),
56
+ ev("c", "note", "2026-10-21T16:00:00Z"),
57
+ ],
58
+ "UTC",
59
+ );
60
+ expect(groups.map((g) => g.day)).toEqual(["2026-10-21", "2026-10-20"]);
61
+ expect(groups[0]?.events.map((e) => e.id)).toEqual(["c", "b"]);
62
+ });
63
+
64
+ it("reads the day in the given zone, so late evening UTC is tomorrow in Tokyo", () => {
65
+ const events = [ev("a", "note", "2026-10-21T20:00:00Z")];
66
+ expect(groupEventsByDay(events, "UTC")[0]?.day).toBe("2026-10-21");
67
+ expect(groupEventsByDay(events, "Asia/Tokyo")[0]?.day).toBe("2026-10-22");
68
+ });
69
+
70
+ it("returns nothing for no events and does not mutate its input", () => {
71
+ expect(groupEventsByDay([], "UTC")).toEqual([]);
72
+ const input = [
73
+ ev("a", "note", "2026-10-20T09:00:00Z"),
74
+ ev("b", "note", "2026-10-21T09:00:00Z"),
75
+ ];
76
+ groupEventsByDay(input, "UTC");
77
+ expect(input.map((e) => e.id)).toEqual(["a", "b"]);
78
+ });
70
79
  });
71
80
 
72
81
  describe("dayRelation", () => {
73
- it("says today, yesterday, or date", () => {
74
- expect(dayRelation("2026-10-21", "2026-10-21")).toBe("today");
75
- expect(dayRelation("2026-10-20", "2026-10-21")).toBe("yesterday");
76
- expect(dayRelation("2026-10-19", "2026-10-21")).toBe("date");
77
- expect(dayRelation("2026-09-30", "2026-10-01")).toBe("yesterday");
78
- });
82
+ it("says today, yesterday, or date", () => {
83
+ expect(dayRelation("2026-10-21", "2026-10-21")).toBe("today");
84
+ expect(dayRelation("2026-10-20", "2026-10-21")).toBe("yesterday");
85
+ expect(dayRelation("2026-10-19", "2026-10-21")).toBe("date");
86
+ expect(dayRelation("2026-09-30", "2026-10-01")).toBe("yesterday");
87
+ });
79
88
  });
80
89
 
81
90
  describe("filters", () => {
82
- const filters = [
83
- { key: "calls", label: "Calls", kinds: ["call", "meeting"] },
84
- { key: "notes", label: "Notes", kinds: ["note"] },
85
- ];
86
- const events = [ev("a", "call", "2026-10-21T08:00:00Z"), ev("b", "note", "2026-10-21T09:00:00Z"), ev("c", "x:custom", "2026-10-21T10:00:00Z")];
87
-
88
- it("keeps only the kinds of the chosen chip", () => {
89
- expect(eventsForFilter(events, "calls", filters).map((e) => e.id)).toEqual(["a"]);
90
- });
91
- it("shows everything for all or an unknown key", () => {
92
- expect(eventsForFilter(events, "all", filters)).toHaveLength(3);
93
- expect(eventsForFilter(events, "nope", filters)).toHaveLength(3);
94
- });
95
- it("counts per chip", () => {
96
- expect(filterCounts(events, filters)).toEqual({ calls: 1, notes: 1 });
97
- });
91
+ const filters = [
92
+ { key: "calls", label: "Calls", kinds: ["call", "meeting"] },
93
+ { key: "notes", label: "Notes", kinds: ["note"] },
94
+ ];
95
+ const events = [
96
+ ev("a", "call", "2026-10-21T08:00:00Z"),
97
+ ev("b", "note", "2026-10-21T09:00:00Z"),
98
+ ev("c", "x:custom", "2026-10-21T10:00:00Z"),
99
+ ];
100
+
101
+ it("keeps only the kinds of the chosen chip", () => {
102
+ expect(eventsForFilter(events, "calls", filters).map((e) => e.id)).toEqual(["a"]);
103
+ });
104
+ it("shows everything for all or an unknown key", () => {
105
+ expect(eventsForFilter(events, "all", filters)).toHaveLength(3);
106
+ expect(eventsForFilter(events, "nope", filters)).toHaveLength(3);
107
+ });
108
+ it("counts per chip", () => {
109
+ expect(filterCounts(events, filters)).toEqual({ calls: 1, notes: 1 });
110
+ });
98
111
  });
99
112
 
100
113
  describe("foldGroups", () => {
101
- const groups = groupEventsByDay(
102
- [
103
- ev("a", "note", "2026-10-21T09:00:00Z"),
104
- ev("b", "note", "2026-10-21T08:00:00Z"),
105
- ev("c", "note", "2026-10-20T08:00:00Z"),
106
- ev("d", "note", "2026-10-19T08:00:00Z"),
107
- ],
108
- "UTC",
109
- );
110
- it("cuts inside a day when the limit falls there and counts the rest", () => {
111
- const folded = foldGroups(groups, 1);
112
- expect(folded.groups.map((g) => g.events.length)).toEqual([1]);
113
- expect(folded.hidden).toBe(3);
114
- });
115
- it("hides nothing when the limit covers it", () => {
116
- expect(foldGroups(groups, 10)).toEqual({ groups, hidden: 0 });
117
- });
118
- it("spans days up to the limit", () => {
119
- const folded = foldGroups(groups, 3);
120
- expect(folded.groups.map((g) => g.day)).toEqual(["2026-10-21", "2026-10-20"]);
121
- expect(folded.hidden).toBe(1);
122
- });
114
+ const groups = groupEventsByDay(
115
+ [
116
+ ev("a", "note", "2026-10-21T09:00:00Z"),
117
+ ev("b", "note", "2026-10-21T08:00:00Z"),
118
+ ev("c", "note", "2026-10-20T08:00:00Z"),
119
+ ev("d", "note", "2026-10-19T08:00:00Z"),
120
+ ],
121
+ "UTC",
122
+ );
123
+ it("cuts inside a day when the limit falls there and counts the rest", () => {
124
+ const folded = foldGroups(groups, 1);
125
+ expect(folded.groups.map((g) => g.events.length)).toEqual([1]);
126
+ expect(folded.hidden).toBe(3);
127
+ });
128
+ it("hides nothing when the limit covers it", () => {
129
+ expect(foldGroups(groups, 10)).toEqual({ groups, hidden: 0 });
130
+ });
131
+ it("spans days up to the limit", () => {
132
+ const folded = foldGroups(groups, 3);
133
+ expect(folded.groups.map((g) => g.day)).toEqual(["2026-10-21", "2026-10-20"]);
134
+ expect(folded.hidden).toBe(1);
135
+ });
123
136
  });
124
137
 
125
138
  describe("mergeClientEvents", () => {
126
- it("tags project events with their project and keeps client events untagged", () => {
127
- const merged = mergeClientEvents(
128
- [ev("form", "form", "2026-10-01T10:00:00Z")],
129
- [
130
- { id: "p1", name: "Website", href: "/p/1", events: [ev("a", "call", "2026-10-03T10:00:00Z")] },
131
- { id: "p2", name: "Retainer", events: [ev("b", "note", "2026-10-02T10:00:00Z")] },
132
- ],
133
- );
134
- expect(merged.map((e) => e.id)).toEqual(["a", "b", "form"]);
135
- expect(merged[0]).toMatchObject({ projectId: "p1", projectName: "Website", projectHref: "/p/1" });
136
- expect(merged[1]).toMatchObject({ projectId: "p2", projectHref: null });
137
- expect(merged[2]).toMatchObject({ projectId: null, projectName: null });
138
- });
139
-
140
- it("draws an id seen twice once", () => {
141
- const shared = ev("same", "call", "2026-10-03T10:00:00Z");
142
- const merged = mergeClientEvents([shared], [{ id: "p1", name: "Website", events: [shared] }]);
143
- expect(merged).toHaveLength(1);
144
- expect(merged[0]?.projectId).toBe("p1");
145
- });
146
-
147
- it("handles a client with no projects", () => {
148
- expect(mergeClientEvents([ev("a", "note", "2026-10-03T10:00:00Z")], [])).toHaveLength(1);
149
- expect(mergeClientEvents([], [])).toEqual([]);
150
- });
139
+ it("tags project events with their project and keeps client events untagged", () => {
140
+ const merged = mergeClientEvents(
141
+ [ev("form", "form", "2026-10-01T10:00:00Z")],
142
+ [
143
+ {
144
+ id: "p1",
145
+ name: "Website",
146
+ href: "/p/1",
147
+ events: [ev("a", "call", "2026-10-03T10:00:00Z")],
148
+ },
149
+ { id: "p2", name: "Retainer", events: [ev("b", "note", "2026-10-02T10:00:00Z")] },
150
+ ],
151
+ );
152
+ expect(merged.map((e) => e.id)).toEqual(["a", "b", "form"]);
153
+ expect(merged[0]).toMatchObject({
154
+ projectId: "p1",
155
+ projectName: "Website",
156
+ projectHref: "/p/1",
157
+ });
158
+ expect(merged[1]).toMatchObject({ projectId: "p2", projectHref: null });
159
+ expect(merged[2]).toMatchObject({ projectId: null, projectName: null });
160
+ });
161
+
162
+ it("draws an id seen twice once", () => {
163
+ const shared = ev("same", "call", "2026-10-03T10:00:00Z");
164
+ const merged = mergeClientEvents([shared], [{ id: "p1", name: "Website", events: [shared] }]);
165
+ expect(merged).toHaveLength(1);
166
+ expect(merged[0]?.projectId).toBe("p1");
167
+ });
168
+
169
+ it("handles a client with no projects", () => {
170
+ expect(mergeClientEvents([ev("a", "note", "2026-10-03T10:00:00Z")], [])).toHaveLength(1);
171
+ expect(mergeClientEvents([], [])).toEqual([]);
172
+ });
151
173
  });
152
174
 
153
175
  describe("client rollup", () => {
154
- const projects = [
155
- project({ id: "lost", stageKey: "lost", value: 9000, lastCallAt: "2026-09-01T10:00:00Z" }),
156
- project({ id: "paid", stageKey: "paid", value: 12000, paid: 12000, lastCallAt: "2026-10-10T10:00:00Z" }),
157
- project({ id: "live", stageKey: "sent", value: 8000, next: { label: "Kickoff", date: "2026-10-26" }, whereWeAre: { text: "Waiting on legal", at: "2026-10-18T10:00:00Z" } }),
158
- project({ id: "late", stageKey: "build", value: 5000, paid: 1000, next: { label: "Review", date: "2026-10-23" }, whereWeAre: { text: "Building", at: "2026-10-20T10:00:00Z" } }),
159
- project({ id: "closed", stageKey: "build", closed: true, next: { label: "Old", date: "2026-10-01" } }),
160
- ];
161
-
162
- it("counts a lost project in no total and sums each currency apart", () => {
163
- const rollup = rollupProjects(
164
- [...projects, project({ id: "usd", stageKey: "lead", value: 700, currency: "USD" })],
165
- stages,
166
- );
167
- expect(rollup.totals.EUR).toEqual({ pipeline: 8000, won: 17000, paid: 13000 });
168
- expect(rollup.totals.USD).toEqual({ pipeline: 700, won: 0, paid: 0 });
169
- expect(rollup.count).toBe(6);
170
- expect(rollup.lastCallAt).toBe("2026-10-10T10:00:00Z");
171
- });
172
-
173
- it("keeps lost, completed and closed out of the active list", () => {
174
- expect(activeProjects(projects, stages).map((p) => p.id)).toEqual(["live", "late"]);
175
- });
176
-
177
- it("picks the earliest dated next across active projects, skipping closed ones", () => {
178
- expect(nextAcrossProjects(projects, stages)).toMatchObject({ label: "Review", projectName: "late" });
179
- });
180
-
181
- it("puts a dated next ahead of an undated one", () => {
182
- const next = nextAcrossProjects(
183
- [
184
- project({ id: "a", next: { label: "Someday" } }),
185
- project({ id: "b", next: { label: "Dated", date: "2027-01-01" } }),
186
- ],
187
- stages,
188
- );
189
- expect(next?.label).toBe("Dated");
190
- });
191
-
192
- it("returns null when nothing is next", () => {
193
- expect(nextAcrossProjects([project({ id: "a" })], stages)).toBeNull();
194
- expect(nextAcrossProjects([], stages)).toBeNull();
195
- });
196
-
197
- it("takes the newest where-we-are, even from a closed project", () => {
198
- expect(latestWhereWeAre(projects)).toMatchObject({ text: "Building", projectName: "late" });
199
- expect(latestWhereWeAre([project({ id: "a" })])).toBeNull();
200
- });
176
+ const projects = [
177
+ project({ id: "lost", stageKey: "lost", value: 9000, lastCallAt: "2026-09-01T10:00:00Z" }),
178
+ project({
179
+ id: "paid",
180
+ stageKey: "paid",
181
+ value: 12000,
182
+ paid: 12000,
183
+ lastCallAt: "2026-10-10T10:00:00Z",
184
+ }),
185
+ project({
186
+ id: "live",
187
+ stageKey: "sent",
188
+ value: 8000,
189
+ next: { label: "Kickoff", date: "2026-10-26" },
190
+ whereWeAre: { text: "Waiting on legal", at: "2026-10-18T10:00:00Z" },
191
+ }),
192
+ project({
193
+ id: "late",
194
+ stageKey: "build",
195
+ value: 5000,
196
+ paid: 1000,
197
+ next: { label: "Review", date: "2026-10-23" },
198
+ whereWeAre: { text: "Building", at: "2026-10-20T10:00:00Z" },
199
+ }),
200
+ project({
201
+ id: "closed",
202
+ stageKey: "build",
203
+ closed: true,
204
+ next: { label: "Old", date: "2026-10-01" },
205
+ }),
206
+ ];
207
+
208
+ it("counts a lost project in no total and sums each currency apart", () => {
209
+ const rollup = rollupProjects(
210
+ [...projects, project({ id: "usd", stageKey: "lead", value: 700, currency: "USD" })],
211
+ stages,
212
+ );
213
+ expect(rollup.totals.EUR).toEqual({ pipeline: 8000, won: 17000, paid: 13000 });
214
+ expect(rollup.totals.USD).toEqual({ pipeline: 700, won: 0, paid: 0 });
215
+ expect(rollup.count).toBe(6);
216
+ expect(rollup.lastCallAt).toBe("2026-10-10T10:00:00Z");
217
+ });
218
+
219
+ it("keeps lost, completed and closed out of the active list", () => {
220
+ expect(activeProjects(projects, stages).map((p) => p.id)).toEqual(["live", "late"]);
221
+ });
222
+
223
+ it("picks the earliest dated next across active projects, skipping closed ones", () => {
224
+ expect(nextAcrossProjects(projects, stages)).toMatchObject({
225
+ label: "Review",
226
+ projectName: "late",
227
+ });
228
+ });
229
+
230
+ it("puts a dated next ahead of an undated one", () => {
231
+ const next = nextAcrossProjects(
232
+ [
233
+ project({ id: "a", next: { label: "Someday" } }),
234
+ project({ id: "b", next: { label: "Dated", date: "2027-01-01" } }),
235
+ ],
236
+ stages,
237
+ );
238
+ expect(next?.label).toBe("Dated");
239
+ });
240
+
241
+ it("returns null when nothing is next", () => {
242
+ expect(nextAcrossProjects([project({ id: "a" })], stages)).toBeNull();
243
+ expect(nextAcrossProjects([], stages)).toBeNull();
244
+ });
245
+
246
+ it("takes the newest where-we-are, even from a closed project", () => {
247
+ expect(latestWhereWeAre(projects)).toMatchObject({ text: "Building", projectName: "late" });
248
+ expect(latestWhereWeAre([project({ id: "a" })])).toBeNull();
249
+ });
201
250
  });
202
251
 
203
252
  describe("steps", () => {
204
- const step = (id: string, over: Partial<StepDef> = {}): StepDef => ({ id, label: id, done: false, ...over });
205
-
206
- it("marks done, the first open one current, earlier-skipped and overdue ones late", () => {
207
- const steps = [
208
- step("a", { done: true }),
209
- step("b"),
210
- step("c", { end: "2026-10-01" }),
211
- step("d", { start: "2026-12-01" }),
212
- step("e"),
213
- ];
214
- const m = stepMarkers(steps, "2026-10-21");
215
- expect([...m.entries()]).toEqual([
216
- ["a", "complete"],
217
- ["b", "current"],
218
- ["c", "late"],
219
- ["d", "pending"],
220
- ["e", "pending"],
221
- ]);
222
- });
223
-
224
- it("calls an open step before the current one late, when an earlier one is open but out of order", () => {
225
- // c done, b open: b is current (first open), nothing before it is open.
226
- const m = stepMarkers([step("a", { hidden: true }), step("b"), step("c", { done: true })], "2026-10-21");
227
- expect(m.has("a")).toBe(false);
228
- expect(m.get("b")).toBe("current");
229
- expect(m.get("c")).toBe("complete");
230
- });
231
-
232
- it("has no current step when all are done", () => {
233
- const m = stepMarkers([step("a", { done: true })], "2026-10-21");
234
- expect([...m.values()]).toEqual(["complete"]);
235
- });
236
-
237
- it("counts progress over visible steps only", () => {
238
- expect(stepProgress([step("a", { done: true }), step("b"), step("c", { hidden: true, done: true })])).toEqual({ done: 1, total: 2 });
239
- });
240
-
241
- it("buckets by group in declared order and puts ungrouped steps last, headerless", () => {
242
- const buckets = groupSteps(
243
- [step("a", { group: "delivery" }), step("b", { group: "sales" }), step("c"), step("d", { group: "delivery" }), step("e", { group: "gone" })],
244
- [
245
- { key: "sales", label: "Sales" },
246
- { key: "delivery", label: "Delivery" },
247
- { key: "empty", label: "Empty" },
248
- ],
249
- );
250
- expect(buckets.map((b) => [b.key, b.steps.map((s) => s.id)])).toEqual([
251
- ["sales", ["b"]],
252
- ["delivery", ["a", "d"]],
253
- ["", ["c", "e"]],
254
- ]);
255
- expect(buckets[2]?.label).toBeNull();
256
- });
257
-
258
- it("reorders before or after a target and leaves the list on a no-op", () => {
259
- const ids = ["a", "b", "c", "d"];
260
- expect(reorderIds(ids, "d", "b", true)).toEqual(["a", "d", "b", "c"]);
261
- expect(reorderIds(ids, "a", "c", false)).toEqual(["b", "c", "a", "d"]);
262
- expect(reorderIds(ids, "a", "a", true)).toEqual(ids);
263
- expect(reorderIds(ids, "zz", "a", true)).toEqual(ids);
264
- expect(reorderIds(ids, "a", "zz", true)).toEqual(ids);
265
- });
266
-
267
- it("nudges within a group and stops at its edge", () => {
268
- const steps = [step("a", { group: "s" }), step("x", { group: "t" }), step("b", { group: "s" })];
269
- expect(nudgeStep(steps, "a", 1)).toEqual(["b", "x", "a"]);
270
- expect(nudgeStep(steps, "a", -1)).toEqual(["a", "x", "b"]);
271
- expect(nudgeStep(steps, "b", 1)).toEqual(["a", "x", "b"]);
272
- expect(nudgeStep(steps, "nope", 1)).toEqual(["a", "x", "b"]);
273
- });
253
+ const step = (id: string, over: Partial<StepDef> = {}): StepDef => ({
254
+ id,
255
+ label: id,
256
+ done: false,
257
+ ...over,
258
+ });
259
+
260
+ it("marks done, the first open one current, earlier-skipped and overdue ones late", () => {
261
+ const steps = [
262
+ step("a", { done: true }),
263
+ step("b"),
264
+ step("c", { end: "2026-10-01" }),
265
+ step("d", { start: "2026-12-01" }),
266
+ step("e"),
267
+ ];
268
+ const m = stepMarkers(steps, "2026-10-21");
269
+ expect([...m.entries()]).toEqual([
270
+ ["a", "complete"],
271
+ ["b", "current"],
272
+ ["c", "late"],
273
+ ["d", "pending"],
274
+ ["e", "pending"],
275
+ ]);
276
+ });
277
+
278
+ it("calls an open step before the current one late, when an earlier one is open but out of order", () => {
279
+ // c done, b open: b is current (first open), nothing before it is open.
280
+ const m = stepMarkers(
281
+ [step("a", { hidden: true }), step("b"), step("c", { done: true })],
282
+ "2026-10-21",
283
+ );
284
+ expect(m.has("a")).toBe(false);
285
+ expect(m.get("b")).toBe("current");
286
+ expect(m.get("c")).toBe("complete");
287
+ });
288
+
289
+ it("has no current step when all are done", () => {
290
+ const m = stepMarkers([step("a", { done: true })], "2026-10-21");
291
+ expect([...m.values()]).toEqual(["complete"]);
292
+ });
293
+
294
+ it("counts progress over visible steps only", () => {
295
+ expect(
296
+ stepProgress([step("a", { done: true }), step("b"), step("c", { hidden: true, done: true })]),
297
+ ).toEqual({ done: 1, total: 2 });
298
+ });
299
+
300
+ it("buckets by group in declared order and puts ungrouped steps last, headerless", () => {
301
+ const buckets = groupSteps(
302
+ [
303
+ step("a", { group: "delivery" }),
304
+ step("b", { group: "sales" }),
305
+ step("c"),
306
+ step("d", { group: "delivery" }),
307
+ step("e", { group: "gone" }),
308
+ ],
309
+ [
310
+ { key: "sales", label: "Sales" },
311
+ { key: "delivery", label: "Delivery" },
312
+ { key: "empty", label: "Empty" },
313
+ ],
314
+ );
315
+ expect(buckets.map((b) => [b.key, b.steps.map((s) => s.id)])).toEqual([
316
+ ["sales", ["b"]],
317
+ ["delivery", ["a", "d"]],
318
+ ["", ["c", "e"]],
319
+ ]);
320
+ expect(buckets[2]?.label).toBeNull();
321
+ });
322
+
323
+ it("reorders before or after a target and leaves the list on a no-op", () => {
324
+ const ids = ["a", "b", "c", "d"];
325
+ expect(reorderIds(ids, "d", "b", true)).toEqual(["a", "d", "b", "c"]);
326
+ expect(reorderIds(ids, "a", "c", false)).toEqual(["b", "c", "a", "d"]);
327
+ expect(reorderIds(ids, "a", "a", true)).toEqual(ids);
328
+ expect(reorderIds(ids, "zz", "a", true)).toEqual(ids);
329
+ expect(reorderIds(ids, "a", "zz", true)).toEqual(ids);
330
+ });
331
+
332
+ it("nudges within a group and stops at its edge", () => {
333
+ const steps = [step("a", { group: "s" }), step("x", { group: "t" }), step("b", { group: "s" })];
334
+ expect(nudgeStep(steps, "a", 1)).toEqual(["b", "x", "a"]);
335
+ expect(nudgeStep(steps, "a", -1)).toEqual(["a", "x", "b"]);
336
+ expect(nudgeStep(steps, "b", 1)).toEqual(["a", "x", "b"]);
337
+ expect(nudgeStep(steps, "nope", 1)).toEqual(["a", "x", "b"]);
338
+ });
274
339
  });
275
340
 
276
341
  describe("formatRange", () => {
277
- it("draws a day, a same-month range and a cross-month range", () => {
278
- expect(formatRange("2026-10-24", null)).toBe("Sat 24 Oct");
279
- expect(formatRange("2026-10-24", "2026-10-24")).toBe("Sat 24 Oct");
280
- expect(formatRange("2026-10-24", "2026-10-25")).toBe("Sat 24 to Sun 25 Oct");
281
- expect(formatRange("2026-10-31", "2026-11-01")).toBe("Sat 31 Oct to Sun 1 Nov");
282
- });
342
+ it("draws a day, a same-month range and a cross-month range", () => {
343
+ expect(formatRange("2026-10-24", null)).toBe("Sat 24 Oct");
344
+ expect(formatRange("2026-10-24", "2026-10-24")).toBe("Sat 24 Oct");
345
+ expect(formatRange("2026-10-24", "2026-10-25")).toBe("Sat 24 to Sun 25 Oct");
346
+ expect(formatRange("2026-10-31", "2026-11-01")).toBe("Sat 31 Oct to Sun 1 Nov");
347
+ });
283
348
  });
284
349
 
285
350
  describe("fields", () => {
286
- const reason: FieldDef = {
287
- key: "reason",
288
- label: "Reason",
289
- type: "choice",
290
- section: "loss",
291
- options: [
292
- { key: "price", label: "Price" },
293
- { key: "old", label: "Old reason", archived: true },
294
- { key: "ghost", label: "Unresponsive" },
295
- ],
296
- };
297
- const words: FieldDef = { key: "words", label: "In their words", type: "text", section: "loss", showWhen: { key: "reason", equals: ["price", "ghost"] } };
298
- const size: FieldDef = { key: "size", label: "Size", type: "choice", options: [{ key: "s", label: "Small" }] };
299
- const sections = [{ key: "loss", label: "Loss" }, { key: "empty", label: "Empty" }];
300
-
301
- it("hides a field whose showWhen isn't met and shows it once it is", () => {
302
- expect(isFieldVisible(words, {})).toBe(false);
303
- expect(isFieldVisible(words, { reason: "old" })).toBe(false);
304
- expect(isFieldVisible(words, { reason: "price" })).toBe(true);
305
- expect(isFieldVisible({ ...words, showWhen: { key: "reason" } }, { reason: "old" })).toBe(true);
306
- expect(isFieldVisible({ ...words, showWhen: { key: "reason" } }, { reason: "" })).toBe(false);
307
- });
308
-
309
- it("drops a section with no visible field and puts unsectioned fields last", () => {
310
- const groups = groupFields([reason, words, size], sections, { reason: "price" });
311
- expect(groups.map((g) => [g.key, g.fields.map((f) => f.key)])).toEqual([
312
- ["loss", ["reason", "words"]],
313
- ["", ["size"]],
314
- ]);
315
- expect(groupFields([words], sections, {})).toEqual([]);
316
- });
317
-
318
- it("keeps an archived option readable on a record that holds it, but never offers it", () => {
319
- expect(pickableOptions(reason, "price").map((o) => o.key)).toEqual(["price", "ghost"]);
320
- expect(pickableOptions(reason, "old").map((o) => o.key)).toEqual(["price", "old", "ghost"]);
321
- expect(fieldRow(reason, "old").value).toBe("Old reason");
322
- });
323
-
324
- it("reads each type back as the type it declares", () => {
325
- expect(parseSaved({ key: "v", label: "V", type: "money" }, "12,5")).toBe(12.5);
326
- expect(parseSaved({ key: "v", label: "V", type: "money" }, "abc")).toBeNull();
327
- expect(parseSaved(words, " hi ")).toBe("hi");
328
- expect(parseSaved(words, " ")).toBeNull();
329
- expect(parseSaved({ key: "m", label: "M", type: "multi" }, ["a", "b"])).toEqual(["a", "b"]);
330
- });
331
-
332
- it("builds a row that saves under its key with the parsed value", async () => {
333
- const saved: [string, unknown][] = [];
334
- const row = fieldRow({ key: "budget", label: "Budget", type: "money" }, 12000, {
335
- currency: "EUR",
336
- onSave: (k, v) => void saved.push([k, v]),
337
- });
338
- expect(row.value).toBe("€12,000");
339
- expect(row.editValue).toBe("12000");
340
- await row.onSave?.("9000");
341
- expect(saved).toEqual([["budget", 9000]]);
342
- });
343
-
344
- it("leaves a row read-only when no onSave is given, and empty values undrawn", () => {
345
- const row = fieldRow(size, null);
346
- expect(row.onSave).toBeUndefined();
347
- expect(row.value).toBeUndefined();
348
- expect(row.editValue).toBe("");
349
- });
351
+ const reason: FieldDef = {
352
+ key: "reason",
353
+ label: "Reason",
354
+ type: "choice",
355
+ section: "loss",
356
+ options: [
357
+ { key: "price", label: "Price" },
358
+ { key: "old", label: "Old reason", archived: true },
359
+ { key: "ghost", label: "Unresponsive" },
360
+ ],
361
+ };
362
+ const words: FieldDef = {
363
+ key: "words",
364
+ label: "In their words",
365
+ type: "text",
366
+ section: "loss",
367
+ showWhen: { key: "reason", equals: ["price", "ghost"] },
368
+ };
369
+ const size: FieldDef = {
370
+ key: "size",
371
+ label: "Size",
372
+ type: "choice",
373
+ options: [{ key: "s", label: "Small" }],
374
+ };
375
+ const sections = [
376
+ { key: "loss", label: "Loss" },
377
+ { key: "empty", label: "Empty" },
378
+ ];
379
+
380
+ it("hides a field whose showWhen isn't met and shows it once it is", () => {
381
+ expect(isFieldVisible(words, {})).toBe(false);
382
+ expect(isFieldVisible(words, { reason: "old" })).toBe(false);
383
+ expect(isFieldVisible(words, { reason: "price" })).toBe(true);
384
+ expect(isFieldVisible({ ...words, showWhen: { key: "reason" } }, { reason: "old" })).toBe(true);
385
+ expect(isFieldVisible({ ...words, showWhen: { key: "reason" } }, { reason: "" })).toBe(false);
386
+ });
387
+
388
+ it("drops a section with no visible field and puts unsectioned fields last", () => {
389
+ const groups = groupFields([reason, words, size], sections, { reason: "price" });
390
+ expect(groups.map((g) => [g.key, g.fields.map((f) => f.key)])).toEqual([
391
+ ["loss", ["reason", "words"]],
392
+ ["", ["size"]],
393
+ ]);
394
+ expect(groupFields([words], sections, {})).toEqual([]);
395
+ });
396
+
397
+ it("keeps an archived option readable on a record that holds it, but never offers it", () => {
398
+ expect(pickableOptions(reason, "price").map((o) => o.key)).toEqual(["price", "ghost"]);
399
+ expect(pickableOptions(reason, "old").map((o) => o.key)).toEqual(["price", "old", "ghost"]);
400
+ expect(fieldRow(reason, "old").value).toBe("Old reason");
401
+ });
402
+
403
+ it("reads each type back as the type it declares", () => {
404
+ expect(parseSaved({ key: "v", label: "V", type: "money" }, "12,5")).toBe(12.5);
405
+ expect(parseSaved({ key: "v", label: "V", type: "money" }, "abc")).toBeNull();
406
+ expect(parseSaved(words, " hi ")).toBe("hi");
407
+ expect(parseSaved(words, " ")).toBeNull();
408
+ expect(parseSaved({ key: "m", label: "M", type: "multi", options: [] }, ["a", "b"])).toEqual([
409
+ "a",
410
+ "b",
411
+ ]);
412
+ });
413
+
414
+ it("builds a row that saves under its key with the parsed value", async () => {
415
+ const saved: [string, unknown][] = [];
416
+ const row = fieldRow({ key: "budget", label: "Budget", type: "money" }, 12000, {
417
+ currency: "EUR",
418
+ onSave: (k, v) => void saved.push([k, v]),
419
+ });
420
+ expect(row.value).toBe("€12,000");
421
+ expect(row.editValue).toBe("12000");
422
+ await row.onSave?.("9000");
423
+ expect(saved).toEqual([["budget", 9000]]);
424
+ });
425
+
426
+ it("leaves a row read-only when no onSave is given, and empty values undrawn", () => {
427
+ const row = fieldRow(size, null);
428
+ expect(row.onSave).toBeUndefined();
429
+ expect(row.value).toBeUndefined();
430
+ expect(row.editValue).toBe("");
431
+ });
432
+ });
433
+
434
+ describe("tags fields", () => {
435
+ const tools: FieldDef = {
436
+ key: "tools",
437
+ label: "Tools",
438
+ type: "tags",
439
+ options: "existing-values",
440
+ allowCreate: true,
441
+ max: 5,
442
+ };
443
+
444
+ it("draws the labels as chips and hands the editor its options", () => {
445
+ const row = fieldRow(tools, ["Airtable", "Make"], {
446
+ tagOptions: { tools: ["Airtable", "Make", "Zapier"] },
447
+ });
448
+ expect(row.input).toBe("tags");
449
+ expect(row.chips?.map((c) => c.label)).toEqual(["Airtable", "Make"]);
450
+ expect(row.tags).toEqual({
451
+ options: ["Airtable", "Make", "Zapier"],
452
+ allowCreate: true,
453
+ max: 5,
454
+ });
455
+ expect(row.editValue).toEqual(["Airtable", "Make"]);
456
+ });
457
+
458
+ it("uses a fixed list as its options and keeps a closed set closed", () => {
459
+ const plan: FieldDef = {
460
+ key: "plan",
461
+ label: "Plan",
462
+ type: "tags",
463
+ options: ["Basic", "Pro"],
464
+ allowCreate: false,
465
+ };
466
+ expect(fieldRow(plan, null).tags).toMatchObject({
467
+ options: ["Basic", "Pro"],
468
+ allowCreate: false,
469
+ });
470
+ });
350
471
  });