@heyramzi/ui 2.1.0 → 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 (312) hide show
  1. package/package.json +7 -8
  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/card/card-frame.svelte +43 -39
  31. package/src/canvas/card/resize-handles.svelte +89 -80
  32. package/src/canvas/chrome/chrome-zoom.svelte +101 -100
  33. package/src/canvas/chrome/lock-badge.svelte +8 -8
  34. package/src/canvas/chrome/side-handles.svelte +24 -19
  35. package/src/canvas/chrome/size-chip.svelte +30 -30
  36. package/src/canvas/chrome/snap-guides.svelte +20 -20
  37. package/src/canvas/components/icons/ToolIcon.svelte +8 -8
  38. package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
  39. package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
  40. package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
  41. package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
  42. package/src/canvas/edges/bend-handle.svelte +46 -46
  43. package/src/canvas/edges/edge-gradient.svelte +10 -10
  44. package/src/canvas/edges/gradient-edge.svelte +45 -29
  45. package/src/canvas/editor/lock.test.ts +6 -1
  46. package/src/canvas/editor/style.ts +7 -1
  47. package/src/canvas/headless/svg-figure.test.ts +5 -1
  48. package/src/canvas/lod/lod-switch.svelte +111 -106
  49. package/src/canvas/pen/PenField.svelte +87 -81
  50. package/src/canvas/pen/PenOverlay.svelte +127 -127
  51. package/src/canvas/pen/ShapeNode.svelte +31 -32
  52. package/src/canvas/pen/TextNode.svelte +54 -53
  53. package/src/canvas/pen/host-field.test.svelte +7 -7
  54. package/src/canvas/pen/shapes.ts +7 -1
  55. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  56. package/src/canvas/screen/PartView.svelte +138 -72
  57. package/src/canvas/screen/ScreenBody.svelte +81 -60
  58. package/src/canvas/screen/ScreenCard.svelte +20 -20
  59. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  60. package/src/canvas/ui/FrameLayer.svelte +59 -59
  61. package/src/canvas/ui/PenLayer.svelte +39 -30
  62. package/src/canvas/ui/StylePanel.svelte +119 -102
  63. package/src/canvas/ui/canvas-grid.svelte +137 -129
  64. package/src/charts/animated-value.svelte +1 -1
  65. package/src/charts/area-chart.css +2 -5
  66. package/src/charts/area-chart.svelte +1 -1
  67. package/src/charts/bar-chart.css +2 -3
  68. package/src/charts/bar-chart.svelte +1 -1
  69. package/src/charts/charts.css +5 -17
  70. package/src/charts/crosshair.svelte +1 -1
  71. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  72. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  73. package/src/charts/dashboard/data-panel.svelte +50 -50
  74. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  75. package/src/charts/dashboard/data-table.svelte +138 -138
  76. package/src/charts/dashboard/panel-grid.svelte +30 -30
  77. package/src/charts/dashboard/period-filter.svelte +25 -25
  78. package/src/charts/dashboard/rating-chip.svelte +19 -19
  79. package/src/charts/dashboard/stat-band.svelte +32 -32
  80. package/src/charts/dashboard/stat-tile.svelte +106 -99
  81. package/src/charts/donut-chart.svelte +1 -1
  82. package/src/charts/edge-fade.svelte +1 -1
  83. package/src/charts/figure.svelte +1 -1
  84. package/src/charts/gauge-chart.css +4 -7
  85. package/src/charts/gauge-chart.svelte +1 -1
  86. package/src/charts/hover-focus.svelte +1 -1
  87. package/src/charts/legend.css +1 -3
  88. package/src/charts/legend.svelte +1 -1
  89. package/src/charts/line-chart.svelte +1 -1
  90. package/src/charts/number-chart.css +6 -9
  91. package/src/charts/number-chart.svelte +1 -1
  92. package/src/charts/reference-line.svelte +19 -19
  93. package/src/charts/reveal.svelte +1 -1
  94. package/src/charts/ring-chart.css +2 -4
  95. package/src/charts/ring-chart.svelte +1 -1
  96. package/src/charts/sparkline.svelte +5 -2
  97. package/src/charts/stat-card.svelte +1 -1
  98. package/src/charts/time-series-chart.css +2 -5
  99. package/src/charts/trend-pill.css +2 -4
  100. package/src/charts/trend-pill.svelte +1 -1
  101. package/src/components/copy/copy-button.svelte +93 -98
  102. package/src/components/copy/copy-button.test.ts +10 -2
  103. package/src/components/data-table/infinite-scroll.svelte +42 -42
  104. package/src/components/data-table/pagination.svelte +34 -22
  105. package/src/components/data-table/row-arrow.svelte +19 -16
  106. package/src/components/data-table/row-context-menu.svelte +52 -52
  107. package/src/components/data-table/sortable-head.svelte +25 -25
  108. package/src/components/data-table/table-skeleton.svelte +41 -36
  109. package/src/components/docs/doc-page.svelte +18 -9
  110. package/src/components/docs/on-this-page.svelte +16 -3
  111. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  112. package/src/components/field-row/field-row.svelte +10 -8
  113. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  114. package/src/components/icon/icon.svelte +14 -14
  115. package/src/components/language-flag/language-flag.svelte +3 -1
  116. package/src/components/list-page/detail-empty.svelte +34 -34
  117. package/src/components/list-page/detail-header.svelte +55 -47
  118. package/src/components/list-page/detail-panel.svelte +51 -59
  119. package/src/components/list-page/detail-row.svelte +19 -19
  120. package/src/components/list-page/detail-stack.svelte +21 -21
  121. package/src/components/list-page/empty-state.svelte +30 -30
  122. package/src/components/list-page/filter-item.svelte +20 -20
  123. package/src/components/list-page/filter-popover.svelte +60 -55
  124. package/src/components/list-page/grouped-list.svelte +4 -2
  125. package/src/components/list-page/index.ts +5 -1
  126. package/src/components/list-page/list-page-shell.svelte +2 -5
  127. package/src/components/list-page/master-detail.svelte +87 -88
  128. package/src/components/list-page/notice-bar.svelte +38 -38
  129. package/src/components/list-page/prev-next.svelte +63 -63
  130. package/src/components/list-page/rail-body.svelte +18 -18
  131. package/src/components/list-page/rail-empty.svelte +23 -30
  132. package/src/components/list-page/rail-head.svelte +24 -24
  133. package/src/components/list-page/rail-row.svelte +77 -73
  134. package/src/components/list-page/section-host.ts +2 -3
  135. package/src/components/locale-switch/locale-switch.svelte +41 -41
  136. package/src/components/mark/mark.svelte +34 -38
  137. package/src/components/open-in/index.ts +1 -5
  138. package/src/components/open-in/open-in.svelte +40 -40
  139. package/src/components/picker/mark-picker.svelte +49 -49
  140. package/src/components/picker/picker.svelte +5 -3
  141. package/src/components/section-header/section-header.test.ts +7 -1
  142. package/src/components/section-header/section-label.svelte +20 -20
  143. package/src/components/segmented/animated-tabs.svelte +31 -2
  144. package/src/components/segmented/collapse.test.ts +2 -1
  145. package/src/components/segmented/index.ts +2 -0
  146. package/src/components/segmented/segment.svelte +52 -49
  147. package/src/components/segmented/segmented-filter.svelte +79 -80
  148. package/src/components/segmented/segmented-nav.svelte +34 -40
  149. package/src/components/segmented/segmented-overflow.svelte +53 -50
  150. package/src/components/segmented/segmented-track.svelte +62 -46
  151. package/src/components/segmented/segmented.ts +2 -3
  152. package/src/components/star-rating-input/star-rating-input.svelte +103 -103
  153. package/src/components/svg/svg.svelte +4 -4
  154. package/src/components/table-view/table-view.svelte +17 -2
  155. package/src/components/table-view/table-view.test.ts +7 -1
  156. package/src/components/table-view/types.ts +11 -11
  157. package/src/components/tags-input/index.ts +13 -0
  158. package/src/components/tags-input/tags-input.svelte +305 -0
  159. package/src/components/tags-input/tags.test.ts +64 -0
  160. package/src/components/tags-input/tags.ts +142 -0
  161. package/src/components/theme/index.ts +2 -4
  162. package/src/components/theme/theme-provider.svelte +1 -3
  163. package/src/components/toolbar/toolbar-search.svelte +25 -25
  164. package/src/components/toolbar/toolbar.svelte +11 -7
  165. package/src/components/toolbar/toolbar.test.ts +10 -1
  166. package/src/design/index.css +21 -78
  167. package/src/design/polish.css +26 -35
  168. package/src/editor/editor-toolbar.svelte +104 -99
  169. package/src/editor/editor.svelte +296 -266
  170. package/src/editor/image-toolbar.svelte +20 -22
  171. package/src/editor/index.ts +1 -4
  172. package/src/editor/inline-editor.svelte +280 -238
  173. package/src/editor/markdown.ts +15 -15
  174. package/src/editor/renderer.svelte +71 -71
  175. package/src/editor/slash-menu.svelte +24 -27
  176. package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
  177. package/src/effects/image-comparison/ImageComparison.svelte +11 -1
  178. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
  179. package/src/effects/magic-card/MagicCard.css +14 -7
  180. package/src/effects/marquee/Marquee.svelte +3 -5
  181. package/src/effects/reveal/observe.ts +0 -3
  182. package/src/effects/spotlight/Spotlight.svelte +1 -1
  183. package/src/effects/theme-toggle/ThemeToggle.css +25 -22
  184. package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
  185. package/src/gallery.test.ts +14 -4
  186. package/src/inbox/README.md +1 -3
  187. package/src/inbox/inbox.test.ts +181 -138
  188. package/src/inbox/index.ts +20 -20
  189. package/src/inbox/logic.ts +114 -111
  190. package/src/inbox/thread-inbox.svelte +227 -151
  191. package/src/inbox/types.ts +130 -108
  192. package/src/lib/theme-transition.ts +2 -4
  193. package/src/lib/tone.ts +2 -4
  194. package/src/lib/utils.ts +2 -2
  195. package/src/modules.test.ts +79 -69
  196. package/src/onboarding/define-tours.test.ts +55 -40
  197. package/src/onboarding/define-tours.ts +23 -23
  198. package/src/onboarding/demo.svelte +24 -5
  199. package/src/onboarding/index.ts +1 -8
  200. package/src/onboarding/onboarding-provider.svelte +19 -7
  201. package/src/onboarding/onboarding.svelte.ts +16 -16
  202. package/src/onboarding/pick.test.ts +61 -52
  203. package/src/onboarding/pick.ts +16 -13
  204. package/src/onboarding/stores.test.ts +80 -80
  205. package/src/onboarding/stores.ts +41 -39
  206. package/src/onboarding/tour-card.svelte +30 -5
  207. package/src/onboarding/tour-runner.svelte +12 -3
  208. package/src/onboarding/types.ts +20 -12
  209. package/src/onboarding/wait.ts +22 -22
  210. package/src/panel/side-panel.svelte +94 -93
  211. package/src/record/demo-data.ts +291 -225
  212. package/src/record/field-section.svelte +38 -34
  213. package/src/record/fields.ts +148 -96
  214. package/src/record/format.ts +63 -56
  215. package/src/record/index.ts +49 -35
  216. package/src/record/info-list.svelte +145 -114
  217. package/src/record/kinds.ts +31 -31
  218. package/src/record/logic.ts +216 -214
  219. package/src/record/next-up.svelte +45 -34
  220. package/src/record/project-list.svelte +56 -50
  221. package/src/record/record-demo.svelte +130 -110
  222. package/src/record/record-header.svelte +37 -28
  223. package/src/record/record-shell.svelte +54 -54
  224. package/src/record/record-stats.svelte +25 -25
  225. package/src/record/record-timeline.svelte +134 -114
  226. package/src/record/record.test.ts +430 -309
  227. package/src/record/steps-rail.svelte +225 -216
  228. package/src/record/types.ts +234 -215
  229. package/src/record/where-we-are.svelte +62 -58
  230. package/src/shadcn/button/button.svelte +1 -1
  231. package/src/shadcn/card/card-title.svelte +1 -5
  232. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  233. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  234. package/src/shadcn/table/table-cell.svelte +2 -3
  235. package/src/shadcn/utilities.css +3 -8
  236. package/src/slides/Controls.svelte +150 -124
  237. package/src/slides/Deck.svelte +243 -238
  238. package/src/slides/Fragment.svelte +64 -57
  239. package/src/slides/Overview.svelte +106 -101
  240. package/src/slides/Presenter.svelte +300 -266
  241. package/src/slides/Slide.svelte +185 -170
  242. package/src/slides/SlideList.svelte +16 -12
  243. package/src/slides/ambient.d.ts +1 -1
  244. package/src/slides/bridges.ts +42 -23
  245. package/src/slides/deck-controller.svelte.ts +47 -35
  246. package/src/slides/demo-slides.svelte +330 -306
  247. package/src/slides/demo.svelte +164 -160
  248. package/src/slides/export/capture.ts +20 -16
  249. package/src/slides/export/ffmpeg.ts +43 -38
  250. package/src/slides/export/index.ts +5 -5
  251. package/src/slides/export/launch.ts +3 -1
  252. package/src/slides/export/pdf.ts +30 -27
  253. package/src/slides/export/types.ts +10 -8
  254. package/src/slides/index.ts +11 -11
  255. package/src/slides/labels.ts +22 -19
  256. package/src/slides/layout.ts +9 -2
  257. package/src/slides/model.ts +4 -4
  258. package/src/slides/navigation.ts +43 -31
  259. package/src/slides/theme.css +5 -7
  260. package/src/slides/types.ts +9 -10
  261. package/src/status/define-statuses.ts +17 -24
  262. package/src/status/index.ts +5 -5
  263. package/src/status/status-badge.svelte +3 -1
  264. package/src/status/status-dot.svelte +1 -3
  265. package/src/status/status-icon.svelte +1 -1
  266. package/src/status/status-types.ts +21 -21
  267. package/src/tasks/board-order.ts +2 -4
  268. package/src/tasks/demo.svelte +189 -31
  269. package/src/tasks/detail/detail-demo.svelte +73 -13
  270. package/src/tasks/detail/detail-types.ts +45 -45
  271. package/src/tasks/detail/task-detail.svelte +40 -10
  272. package/src/tasks/detail/task-property.svelte +8 -2
  273. package/src/tasks/detail/task-thread.svelte +53 -12
  274. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  275. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  276. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  277. package/src/tasks/gantt/gantt-header.svelte +9 -3
  278. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  279. package/src/tasks/gantt/gantt.css +2 -1
  280. package/src/tasks/gantt/gantt.test.ts +3 -1
  281. package/src/tasks/gantt/logic.test.ts +110 -20
  282. package/src/tasks/gantt/logic.ts +42 -6
  283. package/src/tasks/inbox/inbox-card.svelte +13 -5
  284. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  285. package/src/tasks/inbox/inbox-logic.ts +6 -40
  286. package/src/tasks/inbox/inbox-statuses.ts +23 -23
  287. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  288. package/src/tasks/inbox/inbox-types.ts +54 -58
  289. package/src/tasks/inbox/index.ts +22 -21
  290. package/src/tasks/index.ts +49 -52
  291. package/src/tasks/kanban-board.svelte +16 -12
  292. package/src/tasks/kanban-types.ts +2 -3
  293. package/src/tasks/presets/sales.ts +6 -6
  294. package/src/tasks/task-gantt.svelte +28 -8
  295. package/src/tasks/task-list.svelte +41 -17
  296. package/src/tasks/time/duration.ts +40 -40
  297. package/src/tasks/time/time-chip.svelte +9 -2
  298. package/src/tasks/time/time-dot.svelte +1 -1
  299. package/src/tasks/time/time-ring.svelte +11 -3
  300. package/src/tasks/time/time-tracker.svelte +57 -15
  301. package/src/tasks/time/time-types.ts +57 -57
  302. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  303. package/src/thread/index.ts +7 -7
  304. package/src/thread/logic.ts +36 -33
  305. package/src/thread/thread.svelte +82 -16
  306. package/src/thread/thread.test.ts +58 -47
  307. package/src/thread/types.ts +68 -68
  308. package/src/tokens/craft.css +17 -53
  309. package/src/tokens/index.css +3 -13
  310. package/src/tokens/palette.css +13 -11
  311. package/src/tokens/primitives.css +10 -23
  312. 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
  });