@heyramzi/ui 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/package.json +48 -24
  2. package/scripts/sync-shadcn.mjs +3 -4
  3. package/src/agents/agent-avatar.svelte +604 -575
  4. package/src/agents/agent-cast.svelte +179 -183
  5. package/src/agents/demo.svelte +125 -113
  6. package/src/agents/helpers.ts +23 -17
  7. package/src/agents/index.ts +4 -4
  8. package/src/agents/page-state.ts +6 -6
  9. package/src/agents/types.ts +6 -6
  10. package/src/ai/ai-button/AiButton.svelte +4 -12
  11. package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
  12. package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
  13. package/src/ai/response/Response.css +2 -3
  14. package/src/animations/avatar-group-hover.css +2 -16
  15. package/src/animations/card-tilt.css +3 -6
  16. package/src/animations/digit-pop-in.css +2 -15
  17. package/src/animations/dropdown.css +4 -17
  18. package/src/animations/input-clear-dissolve.css +5 -18
  19. package/src/animations/input-shake.css +9 -41
  20. package/src/animations/learn-more-hover.css +17 -9
  21. package/src/animations/like-button.css +2 -4
  22. package/src/animations/page-side-by-side.css +2 -10
  23. package/src/animations/plus-menu-morph.css +2 -5
  24. package/src/animations/shimmer-text.css +5 -18
  25. package/src/animations/spinning-counter.css +2 -6
  26. package/src/animations/success-check.css +2 -15
  27. package/src/animations/thinking-states.css +2 -7
  28. package/src/brand/brand.test.ts +21 -5
  29. package/src/brand/index.ts +4 -10
  30. package/src/canvas/README.md +9 -12
  31. package/src/canvas/card/card-frame.svelte +43 -39
  32. package/src/canvas/card/resize-handles.svelte +89 -80
  33. package/src/canvas/chrome/chrome-zoom.svelte +101 -100
  34. package/src/canvas/chrome/lock-badge.svelte +8 -8
  35. package/src/canvas/chrome/side-handles.svelte +24 -19
  36. package/src/canvas/chrome/size-chip.svelte +30 -30
  37. package/src/canvas/chrome/snap-guides.svelte +20 -20
  38. package/src/canvas/components/icons/ToolIcon.svelte +8 -8
  39. package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
  40. package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
  41. package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
  42. package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
  43. package/src/canvas/edges/bend-handle.svelte +46 -46
  44. package/src/canvas/edges/edge-gradient.svelte +10 -10
  45. package/src/canvas/edges/gradient-edge.svelte +45 -29
  46. package/src/canvas/editor/lock.test.ts +6 -1
  47. package/src/canvas/editor/style.ts +7 -1
  48. package/src/canvas/headless/svg-figure.test.ts +5 -1
  49. package/src/canvas/lod/lod-switch.svelte +111 -106
  50. package/src/canvas/pen/PenField.svelte +87 -81
  51. package/src/canvas/pen/PenOverlay.svelte +127 -127
  52. package/src/canvas/pen/ShapeNode.svelte +31 -32
  53. package/src/canvas/pen/TextNode.svelte +54 -53
  54. package/src/canvas/pen/host-field.test.svelte +7 -7
  55. package/src/canvas/pen/shapes.ts +7 -1
  56. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  57. package/src/canvas/screen/PartView.svelte +138 -72
  58. package/src/canvas/screen/ScreenBody.svelte +81 -60
  59. package/src/canvas/screen/ScreenCard.svelte +20 -20
  60. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  61. package/src/canvas/ui/FrameLayer.svelte +59 -59
  62. package/src/canvas/ui/PenLayer.svelte +39 -30
  63. package/src/canvas/ui/StylePanel.svelte +119 -102
  64. package/src/canvas/ui/canvas-grid.svelte +137 -129
  65. package/src/charts/animated-value.svelte +1 -1
  66. package/src/charts/area-chart.css +2 -5
  67. package/src/charts/area-chart.svelte +1 -1
  68. package/src/charts/bar-chart.css +2 -3
  69. package/src/charts/bar-chart.svelte +1 -1
  70. package/src/charts/charts.css +5 -17
  71. package/src/charts/crosshair.svelte +1 -1
  72. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  73. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  74. package/src/charts/dashboard/data-panel.svelte +50 -50
  75. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  76. package/src/charts/dashboard/data-table.svelte +138 -138
  77. package/src/charts/dashboard/panel-grid.svelte +30 -30
  78. package/src/charts/dashboard/period-filter.svelte +25 -25
  79. package/src/charts/dashboard/rating-chip.svelte +19 -19
  80. package/src/charts/dashboard/stat-band.svelte +32 -32
  81. package/src/charts/dashboard/stat-tile.svelte +106 -99
  82. package/src/charts/donut-chart.svelte +1 -1
  83. package/src/charts/edge-fade.svelte +1 -1
  84. package/src/charts/figure.svelte +1 -1
  85. package/src/charts/gauge-chart.css +4 -7
  86. package/src/charts/gauge-chart.svelte +1 -1
  87. package/src/charts/hover-focus.svelte +1 -1
  88. package/src/charts/legend.css +1 -3
  89. package/src/charts/legend.svelte +1 -1
  90. package/src/charts/line-chart.svelte +1 -1
  91. package/src/charts/number-chart.css +6 -9
  92. package/src/charts/number-chart.svelte +1 -1
  93. package/src/charts/reference-line.svelte +19 -19
  94. package/src/charts/reveal.svelte +1 -1
  95. package/src/charts/ring-chart.css +2 -4
  96. package/src/charts/ring-chart.svelte +1 -1
  97. package/src/charts/sparkline.svelte +5 -2
  98. package/src/charts/stat-card.svelte +1 -1
  99. package/src/charts/time-series-chart.css +2 -5
  100. package/src/charts/trend-pill.css +2 -4
  101. package/src/charts/trend-pill.svelte +1 -1
  102. package/src/components/copy/copy-button.svelte +93 -98
  103. package/src/components/copy/copy-button.test.ts +10 -2
  104. package/src/components/data-table/infinite-scroll.svelte +42 -42
  105. package/src/components/data-table/pagination.svelte +34 -22
  106. package/src/components/data-table/row-arrow.svelte +19 -16
  107. package/src/components/data-table/row-context-menu.svelte +52 -52
  108. package/src/components/data-table/sortable-head.svelte +25 -25
  109. package/src/components/data-table/table-skeleton.svelte +41 -36
  110. package/src/components/docs/doc-page.svelte +18 -9
  111. package/src/components/docs/on-this-page.svelte +16 -3
  112. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  113. package/src/components/field-row/field-row.svelte +10 -8
  114. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  115. package/src/components/icon/icon.svelte +14 -14
  116. package/src/components/language-flag/language-flag.svelte +3 -1
  117. package/src/components/list-page/detail-empty.svelte +34 -34
  118. package/src/components/list-page/detail-header.svelte +55 -47
  119. package/src/components/list-page/detail-panel.svelte +51 -59
  120. package/src/components/list-page/detail-row.svelte +19 -19
  121. package/src/components/list-page/detail-stack.svelte +21 -21
  122. package/src/components/list-page/empty-state.svelte +30 -30
  123. package/src/components/list-page/filter-item.svelte +20 -20
  124. package/src/components/list-page/filter-popover.svelte +60 -55
  125. package/src/components/list-page/grouped-list.svelte +4 -2
  126. package/src/components/list-page/index.ts +8 -4
  127. package/src/components/list-page/list-page-shell.svelte +2 -5
  128. package/src/components/list-page/master-detail.svelte +87 -88
  129. package/src/components/list-page/notice-bar.svelte +38 -38
  130. package/src/components/list-page/prev-next.svelte +63 -63
  131. package/src/components/list-page/rail-body.svelte +18 -18
  132. package/src/components/list-page/rail-empty.svelte +23 -30
  133. package/src/components/list-page/rail-head.svelte +24 -24
  134. package/src/components/list-page/rail-row.svelte +77 -73
  135. package/src/components/list-page/section-host.ts +2 -3
  136. package/src/components/locale-switch/locale-switch.svelte +41 -41
  137. package/src/components/mark/mark.svelte +34 -38
  138. package/src/components/open-in/index.ts +1 -5
  139. package/src/components/open-in/open-in.svelte +40 -40
  140. package/src/components/picker/mark-picker.svelte +49 -49
  141. package/src/components/picker/picker.svelte +5 -3
  142. package/src/components/section-header/index.ts +1 -0
  143. package/src/components/section-header/section-header.svelte +1 -1
  144. package/src/components/section-header/section-header.test.ts +7 -1
  145. package/src/components/section-header/section-label.svelte +39 -0
  146. package/src/components/segmented/animated-tabs.svelte +31 -2
  147. package/src/components/segmented/collapse.test.ts +2 -1
  148. package/src/components/segmented/index.ts +2 -0
  149. package/src/components/segmented/segment.svelte +52 -49
  150. package/src/components/segmented/segmented-filter.svelte +79 -80
  151. package/src/components/segmented/segmented-nav.svelte +34 -40
  152. package/src/components/segmented/segmented-overflow.svelte +53 -50
  153. package/src/components/segmented/segmented-track.svelte +62 -46
  154. package/src/components/segmented/segmented.ts +2 -3
  155. package/src/components/star-rating-input/star-rating-input.svelte +103 -103
  156. package/src/components/svg/svg.svelte +4 -4
  157. package/src/components/table-view/table-view.svelte +17 -2
  158. package/src/components/table-view/table-view.test.ts +7 -1
  159. package/src/components/table-view/types.ts +11 -11
  160. package/src/components/tags-input/index.ts +13 -0
  161. package/src/components/tags-input/tags-input.svelte +305 -0
  162. package/src/components/tags-input/tags.test.ts +64 -0
  163. package/src/components/tags-input/tags.ts +142 -0
  164. package/src/components/theme/index.ts +2 -4
  165. package/src/components/theme/theme-provider.svelte +1 -3
  166. package/src/components/toolbar/index.ts +2 -0
  167. package/src/components/{list-page → toolbar}/toolbar-search.svelte +25 -25
  168. package/src/components/toolbar/toolbar.svelte +13 -9
  169. package/src/components/toolbar/toolbar.test.ts +10 -1
  170. package/src/design/index.css +21 -78
  171. package/src/design/polish.css +26 -35
  172. package/src/editor/editor-toolbar.svelte +104 -99
  173. package/src/editor/editor.svelte +296 -266
  174. package/src/editor/image-toolbar.svelte +20 -22
  175. package/src/editor/index.ts +1 -4
  176. package/src/editor/inline-editor.svelte +280 -238
  177. package/src/editor/markdown.ts +15 -15
  178. package/src/editor/renderer.svelte +71 -71
  179. package/src/editor/slash-menu.svelte +24 -27
  180. package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
  181. package/src/effects/image-comparison/ImageComparison.svelte +11 -1
  182. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
  183. package/src/effects/magic-card/MagicCard.css +14 -7
  184. package/src/effects/marquee/Marquee.svelte +3 -5
  185. package/src/effects/reveal/observe.ts +0 -3
  186. package/src/effects/spotlight/Spotlight.svelte +1 -1
  187. package/src/effects/theme-toggle/ThemeToggle.css +25 -22
  188. package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
  189. package/src/gallery.test.ts +14 -4
  190. package/src/inbox/README.md +27 -0
  191. package/src/inbox/inbox.test.ts +214 -0
  192. package/src/inbox/index.ts +31 -0
  193. package/src/inbox/logic.ts +169 -0
  194. package/src/inbox/thread-inbox.svelte +439 -0
  195. package/src/inbox/types.ts +161 -0
  196. package/src/lib/theme-transition.ts +2 -4
  197. package/src/lib/tone.ts +2 -4
  198. package/src/lib/utils.ts +2 -2
  199. package/src/modules.test.ts +105 -0
  200. package/src/onboarding/define-tours.test.ts +55 -40
  201. package/src/onboarding/define-tours.ts +23 -23
  202. package/src/onboarding/demo.svelte +24 -5
  203. package/src/onboarding/index.ts +1 -8
  204. package/src/onboarding/onboarding-provider.svelte +19 -7
  205. package/src/onboarding/onboarding.svelte.ts +16 -16
  206. package/src/onboarding/pick.test.ts +61 -52
  207. package/src/onboarding/pick.ts +16 -13
  208. package/src/onboarding/stores.test.ts +80 -80
  209. package/src/onboarding/stores.ts +41 -39
  210. package/src/onboarding/tour-card.svelte +30 -5
  211. package/src/onboarding/tour-runner.svelte +12 -3
  212. package/src/onboarding/types.ts +20 -12
  213. package/src/onboarding/wait.ts +22 -22
  214. package/src/panel/README.md +5 -0
  215. package/src/panel/index.ts +2 -0
  216. package/src/panel/side-panel.svelte +194 -0
  217. package/src/record/README.md +3 -0
  218. package/src/record/demo-data.ts +352 -0
  219. package/src/record/field-section.svelte +57 -0
  220. package/src/record/fields.ts +194 -0
  221. package/src/record/format.ts +94 -0
  222. package/src/{tasks/record → record}/index.ts +49 -35
  223. package/src/{tasks/record → record}/info-list.svelte +145 -114
  224. package/src/record/kinds.ts +42 -0
  225. package/src/record/logic.ts +326 -0
  226. package/src/record/next-up.svelte +81 -0
  227. package/src/{tasks/record → record}/project-list.svelte +56 -50
  228. package/src/{tasks/record → record}/record-demo.svelte +130 -110
  229. package/src/{tasks/record → record}/record-header.svelte +37 -28
  230. package/src/{tasks/record → record}/record-shell.svelte +54 -54
  231. package/src/record/record-stats.svelte +48 -0
  232. package/src/record/record-timeline.svelte +251 -0
  233. package/src/record/record.test.ts +471 -0
  234. package/src/{tasks/record → record}/steps-rail.svelte +225 -216
  235. package/src/record/types.ts +299 -0
  236. package/src/record/where-we-are.svelte +125 -0
  237. package/src/shadcn/button/button.svelte +1 -1
  238. package/src/shadcn/card/card-title.svelte +1 -5
  239. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  240. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  241. package/src/shadcn/table/table-cell.svelte +2 -3
  242. package/src/shadcn/utilities.css +3 -8
  243. package/src/slides/Controls.svelte +150 -124
  244. package/src/slides/Deck.svelte +243 -238
  245. package/src/slides/Fragment.svelte +64 -57
  246. package/src/slides/Overview.svelte +106 -101
  247. package/src/slides/Presenter.svelte +300 -266
  248. package/src/slides/Slide.svelte +185 -170
  249. package/src/slides/SlideList.svelte +16 -12
  250. package/src/slides/ambient.d.ts +1 -1
  251. package/src/slides/bridges.ts +42 -23
  252. package/src/slides/deck-controller.svelte.ts +47 -35
  253. package/src/slides/demo-slides.svelte +330 -306
  254. package/src/slides/demo.svelte +164 -160
  255. package/src/slides/export/capture.ts +20 -16
  256. package/src/slides/export/ffmpeg.ts +43 -38
  257. package/src/slides/export/index.ts +5 -5
  258. package/src/slides/export/launch.ts +3 -1
  259. package/src/slides/export/pdf.ts +30 -27
  260. package/src/slides/export/types.ts +10 -8
  261. package/src/slides/index.ts +11 -11
  262. package/src/slides/labels.ts +22 -19
  263. package/src/slides/layout.ts +9 -2
  264. package/src/slides/model.ts +4 -4
  265. package/src/slides/navigation.ts +43 -31
  266. package/src/slides/theme.css +5 -7
  267. package/src/slides/types.ts +9 -10
  268. package/src/status/README.md +18 -0
  269. package/src/status/define-statuses.ts +25 -0
  270. package/src/status/index.ts +11 -0
  271. package/src/{tasks → status}/status-badge.svelte +3 -1
  272. package/src/{tasks → status}/status-dot.svelte +1 -3
  273. package/src/{tasks → status}/status-icon.svelte +1 -1
  274. package/src/status/status-types.ts +40 -0
  275. package/src/tasks/README.md +1 -1
  276. package/src/tasks/board-order.ts +2 -4
  277. package/src/tasks/demo.svelte +192 -34
  278. package/src/tasks/detail/detail-demo.svelte +75 -15
  279. package/src/tasks/detail/detail-types.ts +45 -45
  280. package/src/tasks/detail/task-detail.svelte +40 -10
  281. package/src/tasks/detail/task-property.svelte +8 -2
  282. package/src/tasks/detail/task-thread.svelte +53 -12
  283. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  284. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  285. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  286. package/src/tasks/gantt/gantt-header.svelte +9 -3
  287. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  288. package/src/tasks/gantt/gantt.css +2 -1
  289. package/src/tasks/gantt/gantt.test.ts +4 -2
  290. package/src/tasks/gantt/logic.test.ts +110 -20
  291. package/src/tasks/gantt/logic.ts +42 -6
  292. package/src/tasks/inbox/inbox-card.svelte +15 -7
  293. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  294. package/src/tasks/inbox/inbox-logic.ts +6 -40
  295. package/src/tasks/inbox/inbox-statuses.ts +24 -24
  296. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  297. package/src/tasks/inbox/inbox-types.ts +54 -58
  298. package/src/tasks/inbox/index.ts +22 -21
  299. package/src/tasks/index.ts +52 -53
  300. package/src/tasks/kanban-board.svelte +16 -12
  301. package/src/tasks/kanban-types.ts +2 -3
  302. package/src/tasks/presets/sales.ts +7 -7
  303. package/src/tasks/task-gantt.svelte +28 -8
  304. package/src/tasks/task-list.svelte +41 -17
  305. package/src/tasks/time/duration.ts +40 -40
  306. package/src/tasks/time/time-chip.svelte +9 -2
  307. package/src/tasks/time/time-dot.svelte +1 -1
  308. package/src/tasks/time/time-ring.svelte +11 -3
  309. package/src/tasks/time/time-tracker.svelte +57 -15
  310. package/src/tasks/time/time-types.ts +57 -57
  311. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  312. package/src/thread/README.md +31 -0
  313. package/src/thread/index.ts +12 -0
  314. package/src/thread/logic.ts +61 -0
  315. package/src/thread/thread.svelte +432 -0
  316. package/src/thread/thread.test.ts +73 -0
  317. package/src/thread/types.ts +93 -0
  318. package/src/tokens/craft.css +17 -53
  319. package/src/tokens/index.css +3 -13
  320. package/src/tokens/palette.css +13 -11
  321. package/src/tokens/primitives.css +10 -23
  322. package/src/components/list-page/section-label.svelte +0 -39
  323. package/src/tasks/define-statuses.ts +0 -32
  324. package/src/tasks/inbox/inbox.svelte +0 -203
  325. package/src/tasks/record/demo-data.ts +0 -286
  326. package/src/tasks/record/field-section.svelte +0 -53
  327. package/src/tasks/record/fields.ts +0 -142
  328. package/src/tasks/record/format.ts +0 -87
  329. package/src/tasks/record/kinds.ts +0 -42
  330. package/src/tasks/record/logic.ts +0 -324
  331. package/src/tasks/record/next-up.svelte +0 -70
  332. package/src/tasks/record/record-stats.svelte +0 -48
  333. package/src/tasks/record/record-timeline.svelte +0 -231
  334. package/src/tasks/record/record.test.ts +0 -350
  335. package/src/tasks/record/types.ts +0 -280
  336. package/src/tasks/record/where-we-are.svelte +0 -121
  337. package/src/tasks/side-panel.svelte +0 -193
  338. package/src/tasks/status-types.ts +0 -40
  339. /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
  340. /package/src/{tasks → panel}/side-panel-types.ts +0 -0
  341. /package/src/{tasks/record → record}/gutter.ts +0 -0
@@ -1,350 +0,0 @@
1
- import { describe, expect, it } from "vite-plus/test";
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,
18
- } from "./logic.js";
19
- import { formatRange } from "./format.js";
20
- import { fieldRow, groupFields, isFieldVisible, parseSaved, pickableOptions } from "./fields.js";
21
- import type { FieldDef, RecordEvent, RecordSummary, StageDef, StepDef } from "./types.js";
22
-
23
- const ev = (id: string, kind: string, occurredAt: string): RecordEvent => ({
24
- id,
25
- kind,
26
- occurredAt,
27
- title: id,
28
- });
29
-
30
- 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" },
36
- ];
37
-
38
- const project = (over: Partial<RecordSummary> & { id: string }): RecordSummary => ({
39
- name: over.id,
40
- stageKey: "lead",
41
- ...over,
42
- });
43
-
44
- 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
- });
70
- });
71
-
72
- 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
- });
79
- });
80
-
81
- 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
- });
98
- });
99
-
100
- 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
- });
123
- });
124
-
125
- 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
- });
151
- });
152
-
153
- 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
- });
201
- });
202
-
203
- 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
- });
274
- });
275
-
276
- 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
- });
283
- });
284
-
285
- 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
- });
350
- });
@@ -1,280 +0,0 @@
1
- // The view types of a client or project record. Plain data in, nothing loaded:
2
- // the host maps its own rows onto these and passes them down.
3
- import type { PhosphorIcon } from "../../lib/icon-types.js";
4
- import type { Tone } from "../../lib/tone.js";
5
-
6
- /** Where a stage sits in the pipeline. Structure, not an agency's own names. */
7
- export type StageCategory = "open" | "proposal" | "ongoing" | "completed" | "lost";
8
-
9
- /** One stage an agency declares. The record draws its label and tone. */
10
- export type StageDef = {
11
- key: string;
12
- label: string;
13
- tone: Tone;
14
- category: StageCategory;
15
- };
16
-
17
- /**
18
- * One thing that happened. Append-only. It belongs to a project, or to a client
19
- * when no project exists yet. `occurredAt` is an ISO timestamp.
20
- */
21
- export type RecordEvent = {
22
- id: string;
23
- /** A registry key: `call`, `note`, `stage`, or a module's own `slug:name`. */
24
- kind: string;
25
- occurredAt: string;
26
- title: string;
27
- body?: string | null;
28
- /** Money, in the currency's major unit. */
29
- amount?: number | null;
30
- currency?: string | null;
31
- /** Who or what wrote it. The host resolves the name. */
32
- actor?: string | null;
33
- /** A note that changed after posting. */
34
- edited?: boolean;
35
- /** The kind's own declared fields, already formatted by the host for display. */
36
- details?: { label: string; value: string }[];
37
- /** The project it belongs to. Set in client mode, so each entry can say which. */
38
- projectId?: string | null;
39
- projectName?: string | null;
40
- projectHref?: string | null;
41
- /** Where the event's source lives: the call, the invoice, the deck. */
42
- href?: string | null;
43
- };
44
-
45
- /** The numbers and lines one project rolls up to. The host computes them. */
46
- export type RecordSummary = {
47
- id: string;
48
- name: string;
49
- href?: string;
50
- stageKey: string;
51
- /** Short qualifier drawn after the stage, such as a loss reason. */
52
- stageDetail?: string | null;
53
- /** Money, in the currency's major unit. */
54
- value?: number | null;
55
- currency?: string | null;
56
- /** Money received, from payment events. */
57
- paid?: number | null;
58
- startDate?: string | null;
59
- endDate?: string | null;
60
- whereWeAre?: {
61
- text: string;
62
- /** ISO timestamp of the status event. */
63
- at?: string | null;
64
- actor?: string | null;
65
- health?: { label: string; tone: Tone } | null;
66
- } | null;
67
- next?: RecordNext | null;
68
- lastCallAt?: string | null;
69
- lastTouchAt?: string | null;
70
- progress?: { done: number; total: number } | null;
71
- /** A cancelled or archived project: dropped from "active" and "next". */
72
- closed?: boolean;
73
- };
74
-
75
- /** The next milestone, or the next dated task when there are no milestones. */
76
- export type RecordNext = {
77
- label: string;
78
- /** ISO date. */
79
- date?: string | null;
80
- /** ISO date, for a range. */
81
- endDate?: string | null;
82
- owner?: string | null;
83
- href?: string | null;
84
- projectId?: string | null;
85
- projectName?: string | null;
86
- };
87
-
88
- export type FieldType =
89
- | "text"
90
- | "longtext"
91
- | "choice"
92
- | "multi"
93
- | "money"
94
- | "date"
95
- | "url"
96
- | "email"
97
- | "timezone";
98
-
99
- export type FieldOption = {
100
- key: string;
101
- label: string;
102
- tone?: Tone;
103
- /** Still reads on old records, cannot be picked. */
104
- archived?: boolean;
105
- };
106
-
107
- /** What a field holds. A choice is its option key, a multi is its keys. */
108
- export type FieldValue = string | number | string[] | null;
109
-
110
- /** One attribute a module declares. The record draws it and calls `onSave`. */
111
- export type FieldDef = {
112
- key: string;
113
- label: string;
114
- type: FieldType;
115
- options?: FieldOption[];
116
- section?: string;
117
- required?: boolean;
118
- /** Shown only while another field has this value. Without `equals`, any value. */
119
- showWhen?: { key: string; equals?: string | string[] };
120
- /** For money. Defaults to the record's currency. */
121
- currency?: string;
122
- placeholder?: string;
123
- };
124
-
125
- export type FieldSectionDef = { key: string; label: string };
126
-
127
- /** One step of the rail. A step is a milestone task. */
128
- export type StepDef = {
129
- id: string;
130
- label: string;
131
- done: boolean;
132
- /** Which group header it sits under, such as Sales or Delivery. */
133
- group?: string;
134
- /** ISO dates. Both set draws a range, one draws a day. */
135
- start?: string | null;
136
- end?: string | null;
137
- /** Hidden from the rail, still in the edit menu. */
138
- hidden?: boolean;
139
- /** The label was renamed: the menu offers a reset. */
140
- renamed?: boolean;
141
- };
142
-
143
- export type StepGroupDef = { key: string; label: string };
144
-
145
- export type StepMarker = "complete" | "current" | "late" | "pending";
146
-
147
- /** One row of an `InfoList`. The value is already formatted for display. */
148
- export type InfoRow = {
149
- /** Names the row for a host to find it. Falls back to the label. */
150
- id?: string;
151
- label: string;
152
- icon?: PhosphorIcon;
153
- value?: string | null;
154
- /** A coloured chip in place of plain text. */
155
- tone?: Tone;
156
- /** Chips for a multi value. Wins over `value`. */
157
- chips?: { label: string; tone?: Tone }[];
158
- /** What the editor starts from, when it differs from what is drawn. */
159
- editValue?: string | string[] | null;
160
- href?: string;
161
- muted?: boolean;
162
- /** `select` becomes a searchable list past seven options. */
163
- input?: "text" | "longtext" | "select" | "multi" | "date" | "number" | "url" | "email";
164
- options?: { value: string; label: string; tone?: Tone }[];
165
- placeholder?: string;
166
- required?: boolean;
167
- /** Without it the row is read-only. A multi row passes the picked keys. */
168
- onSave?: (value: string | string[]) => void | Promise<void>;
169
- divider?: boolean;
170
- };
171
-
172
- /** A kind of event: how its filter chip and its default row read. */
173
- export type RecordKindDef = {
174
- key: string;
175
- label: string;
176
- icon?: PhosphorIcon;
177
- tone?: Tone;
178
- };
179
-
180
- /** A filter chip: a label over the kinds it shows. */
181
- export type RecordFilterDef = { key: string; label: string; kinds: string[] };
182
-
183
- export type RecordLabels = {
184
- whereWeAre: string;
185
- more: string;
186
- less: string;
187
- next: string;
188
- late: string;
189
- steps: string;
190
- stepsCount: (done: number, total: number) => string;
191
- editSteps: string;
192
- rename: string;
193
- resetName: string;
194
- hide: string;
195
- show: string;
196
- moveUp: string;
197
- moveDown: string;
198
- shownCount: (shown: number, total: number) => string;
199
- openSteps: string;
200
- activity: string;
201
- all: string;
202
- noEvents: string;
203
- older: (count: number) => string;
204
- today: string;
205
- yesterday: string;
206
- by: (name: string) => string;
207
- projects: string;
208
- projectCount: (count: number) => string;
209
- noProjects: string;
210
- details: string;
211
- edit: (field: string) => string;
212
- add: (field: string) => string;
213
- save: string;
214
- cancel: string;
215
- search: string;
216
- noMatch: string;
217
- pipeline: string;
218
- won: string;
219
- paid: string;
220
- lastCall: string;
221
- lastTouch: string;
222
- noCall: string;
223
- open: string;
224
- edited: string;
225
- noSteps: string;
226
- saveFailed: string;
227
- required: string;
228
- };
229
-
230
- export const DEFAULT_RECORD_LABELS: RecordLabels = {
231
- whereWeAre: "Where we are",
232
- more: "More",
233
- less: "Less",
234
- next: "Next",
235
- late: "Late",
236
- steps: "Steps",
237
- stepsCount: (done, total) => `${done}/${total}`,
238
- editSteps: "Choose visible steps",
239
- rename: "Rename",
240
- resetName: "Reset name",
241
- hide: "Hide",
242
- show: "Show",
243
- moveUp: "Move up",
244
- moveDown: "Move down",
245
- shownCount: (shown, total) => `${shown} of ${total} shown`,
246
- openSteps: "Open steps",
247
- activity: "Activity",
248
- all: "All",
249
- noEvents: "Nothing logged yet",
250
- older: (count) => `Show ${count} older`,
251
- today: "Today",
252
- yesterday: "Yesterday",
253
- by: (name) => `by ${name}`,
254
- projects: "Projects",
255
- projectCount: (count) => (count === 1 ? "1 project" : `${count} projects`),
256
- noProjects: "No projects",
257
- details: "Details",
258
- edit: (field) => `Edit ${field}`,
259
- add: (field) => `Add ${field.toLowerCase()}`,
260
- save: "Save",
261
- cancel: "Cancel",
262
- search: "Search",
263
- noMatch: "No match",
264
- pipeline: "Pipeline",
265
- won: "Won",
266
- paid: "Paid",
267
- lastCall: "Last call",
268
- lastTouch: "Last touch",
269
- noCall: "None yet",
270
- open: "Open",
271
- edited: "edited",
272
- noSteps: "No steps yet",
273
- saveFailed: "Couldn't save",
274
- required: "Required",
275
- };
276
-
277
- /** Merge a partial label set over the defaults. */
278
- export function resolveLabels(labels?: Partial<RecordLabels>): RecordLabels {
279
- return labels ? { ...DEFAULT_RECORD_LABELS, ...labels } : DEFAULT_RECORD_LABELS;
280
- }