@agent-native/core 0.123.0 → 0.123.1

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 (263) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  6. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  7. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  8. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  9. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  10. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  11. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  12. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  13. package/corpus/templates/crm/.env.example +5 -0
  14. package/corpus/templates/crm/AGENTS.md +82 -0
  15. package/corpus/templates/crm/CHANGELOG.md +25 -0
  16. package/corpus/templates/crm/DEVELOPING.md +66 -0
  17. package/corpus/templates/crm/README.md +122 -0
  18. package/corpus/templates/crm/_gitignore +12 -0
  19. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  20. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  21. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  22. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  23. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  24. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  25. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  26. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  27. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  28. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  29. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  30. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  31. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  32. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  33. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  34. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  35. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  36. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  37. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  38. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  39. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  40. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  41. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  42. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  43. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  44. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  45. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  46. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  47. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  48. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  49. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  50. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  51. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  52. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  53. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  54. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  55. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  56. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  57. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  58. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  59. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  60. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  61. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  62. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  63. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  64. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  65. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  66. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  67. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  68. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  69. package/corpus/templates/crm/actions/navigate.ts +54 -0
  70. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  71. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  72. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  73. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  74. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  75. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  76. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  77. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  78. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  79. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  80. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  81. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  82. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  83. package/corpus/templates/crm/actions/run.ts +7 -0
  84. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  85. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  86. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  87. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  88. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  89. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  90. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  91. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  92. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  93. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  94. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  95. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  96. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  97. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  98. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  99. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  100. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  101. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  102. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  103. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  104. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  105. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  106. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  107. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  108. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  109. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  110. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  111. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  112. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  113. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  114. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  115. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  116. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  117. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  118. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  119. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  120. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  121. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  122. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  123. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  124. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  125. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  126. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  127. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  128. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  129. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  130. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  131. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  132. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  133. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  134. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  135. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  136. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  137. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  138. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  139. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  140. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  141. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  142. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  143. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  144. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  145. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  146. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  147. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  148. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  149. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  150. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  151. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  152. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  153. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  154. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  155. package/corpus/templates/crm/app/design-system.ts +3 -0
  156. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  157. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  158. package/corpus/templates/crm/app/global.css +167 -0
  159. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  160. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  161. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  162. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  163. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  164. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  165. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  166. package/corpus/templates/crm/app/lib/types.ts +172 -0
  167. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  168. package/corpus/templates/crm/app/root.tsx +93 -0
  169. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  170. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  171. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  172. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  173. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  174. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  175. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  176. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  177. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  178. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  179. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  180. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  181. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  182. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  183. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  184. package/corpus/templates/crm/app/routes.ts +19 -0
  185. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  186. package/corpus/templates/crm/changelog/2026-07-26-any-list-or-view-can-be-shown-as-a-board-grouped-by-stage-so.md +6 -0
  187. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  188. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  189. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  190. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  191. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  192. package/corpus/templates/crm/changelog/2026-07-26-press-cmd-k-to-jump-to-any-record-list-view-or-setting-and-u.md +6 -0
  193. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  194. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  195. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  196. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  197. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  198. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  199. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  200. package/corpus/templates/crm/components.json +20 -0
  201. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  202. package/corpus/templates/crm/docs/features/README.md +55 -0
  203. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  204. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  205. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  206. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  207. package/corpus/templates/crm/learnings.defaults.md +5 -0
  208. package/corpus/templates/crm/netlify.toml +11 -0
  209. package/corpus/templates/crm/package.json +61 -0
  210. package/corpus/templates/crm/public/favicon.svg +1 -0
  211. package/corpus/templates/crm/public/icon-180.svg +1 -0
  212. package/corpus/templates/crm/public/manifest.json +11 -0
  213. package/corpus/templates/crm/react-router.config.ts +7 -0
  214. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  215. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  216. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  217. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  218. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  219. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  220. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  221. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  222. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  223. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  224. package/corpus/templates/crm/server/db/index.ts +191 -0
  225. package/corpus/templates/crm/server/db/schema.ts +647 -0
  226. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  227. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  228. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  229. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  230. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  231. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  232. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  233. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  234. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  235. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  236. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  237. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  238. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  239. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  240. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  241. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  242. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  243. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  244. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  245. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  246. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  247. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  248. package/corpus/templates/crm/ssr-entry.ts +11 -0
  249. package/corpus/templates/crm/tsconfig.json +24 -0
  250. package/corpus/templates/crm/vite.config.ts +15 -0
  251. package/corpus/templates/crm/vitest.config.ts +5 -0
  252. package/corpus/templates/design/app/i18n-data.ts +12 -12
  253. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  254. package/dist/cli/templates-meta.d.ts.map +1 -1
  255. package/dist/cli/templates-meta.js +12 -0
  256. package/dist/cli/templates-meta.js.map +1 -1
  257. package/dist/collab/routes.d.ts +1 -1
  258. package/dist/observability/routes.d.ts +3 -3
  259. package/dist/resources/handlers.d.ts +1 -1
  260. package/dist/server/realtime-token.d.ts +1 -1
  261. package/dist/server/transcribe-voice.d.ts +1 -1
  262. package/package.json +1 -1
  263. package/src/cli/templates-meta.ts +12 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The two per-type primitives both surfaces render identically: an option chip
3
+ * and a star rating. Everything larger — the grid's fixed-height cell, the
4
+ * record panel's labelled row — stays with its own surface.
5
+ */
6
+
7
+ import { IconStar, IconStarFilled } from "@tabler/icons-react";
8
+
9
+ import { cn } from "@/lib/utils";
10
+
11
+ import {
12
+ RATING_MAX,
13
+ type CrmAttributeValue,
14
+ type CrmValueToken,
15
+ } from "./attribute-value";
16
+
17
+ /**
18
+ * An option's colour tints the chip rather than filling it. `color-mix` instead
19
+ * of an appended hex alpha: an option colour may be any CSS colour, and
20
+ * `#0a0` + "22" is not a colour at all — it silently rendered untinted.
21
+ */
22
+ export function AttributeOptionChip({
23
+ token,
24
+ className,
25
+ }: {
26
+ token: CrmValueToken;
27
+ className?: string;
28
+ }) {
29
+ return (
30
+ <span
31
+ className={cn(
32
+ "inline-flex max-w-full items-center rounded-full border border-border/60 bg-muted/50 px-2 py-0.5 text-xs font-medium",
33
+ className,
34
+ )}
35
+ style={
36
+ token.color
37
+ ? {
38
+ backgroundColor: `color-mix(in srgb, ${token.color} 14%, transparent)`,
39
+ borderColor: `color-mix(in srgb, ${token.color} 35%, transparent)`,
40
+ color: token.color,
41
+ }
42
+ : undefined
43
+ }
44
+ >
45
+ <span className="truncate">{token.label}</span>
46
+ </span>
47
+ );
48
+ }
49
+
50
+ export function AttributeRating({
51
+ value,
52
+ className,
53
+ }: {
54
+ value: CrmAttributeValue | undefined;
55
+ className?: string;
56
+ }) {
57
+ const filled = typeof value === "number" ? Math.round(value) : 0;
58
+ return (
59
+ <span className={cn("inline-flex items-center gap-0.5", className)}>
60
+ {Array.from({ length: RATING_MAX }, (_, index) => index + 1).map((step) =>
61
+ step <= filled ? (
62
+ <IconStarFilled key={step} className="size-3.5 text-amber-500" />
63
+ ) : (
64
+ <IconStar key={step} className="size-3.5 text-muted-foreground/40" />
65
+ ),
66
+ )}
67
+ </span>
68
+ );
69
+ }
@@ -0,0 +1,591 @@
1
+ /**
2
+ * The one per-attribute-type value registry, shared by the spreadsheet grid and
3
+ * the record page's attribute panel.
4
+ *
5
+ * It owns everything about a value that is not layout: display text, clipboard
6
+ * text, parsing typed input back, resolving managed options and their colours,
7
+ * and which editing *affordance* a type calls for. It deliberately stops there.
8
+ * A grid cell is 34px tall, keyboard-driven, and edited in place; a panel row is
9
+ * full width, labelled, and stacked — so each surface renders the affordance in
10
+ * its own layout wrapper and only the value logic lives here.
11
+ *
12
+ * Adding an attribute type means adding one entry to `ATTRIBUTE_VALUE_SPECS`.
13
+ */
14
+
15
+ import {
16
+ ATTRIBUTE_TYPE_SPECS,
17
+ CRM_ATTRIBUTE_TYPES,
18
+ type CrmAttributeType,
19
+ } from "../../../../shared/crm-attributes";
20
+ import type { CrmAttributeOption } from "../../../../shared/crm-contract";
21
+
22
+ /**
23
+ * Every shape a stored CRM value can arrive in. Deliberately wider than either
24
+ * surface's own value type so both can pass their values straight in.
25
+ */
26
+ export type CrmAttributeValue =
27
+ | string
28
+ | number
29
+ | boolean
30
+ | null
31
+ | Array<string | number | boolean | null>
32
+ | { [key: string]: unknown };
33
+
34
+ /** Everything a typed editor can produce — assignable to both surfaces' types. */
35
+ export type CrmEditableValue =
36
+ | string
37
+ | number
38
+ | boolean
39
+ | null
40
+ | Array<string | number | boolean>;
41
+
42
+ /**
43
+ * The editing affordance a type calls for. `none` means no surface can edit it:
44
+ * a system-only or composite type is read-only everywhere, not read-only by
45
+ * accident in one place.
46
+ */
47
+ export type CrmAttributeControl =
48
+ | "text"
49
+ | "number"
50
+ | "checkbox"
51
+ | "date"
52
+ | "datetime"
53
+ | "options"
54
+ | "rating"
55
+ | "reference"
56
+ | "none";
57
+
58
+ export type CrmValueParseFailure =
59
+ | "not-a-number"
60
+ | "not-a-date"
61
+ | "unknown-option"
62
+ | "read-only";
63
+
64
+ export type CrmValueParse =
65
+ | { ok: true; value: CrmEditableValue }
66
+ | { ok: false; reason: CrmValueParseFailure; detail?: string };
67
+
68
+ /** The attribute subset the registry needs; both surfaces' types satisfy it. */
69
+ export interface CrmValueShape {
70
+ attributeType: CrmAttributeType;
71
+ multi: boolean;
72
+ options?: CrmAttributeOption[];
73
+ config?: Record<string, unknown>;
74
+ }
75
+
76
+ export interface CrmValueContext {
77
+ attribute: CrmValueShape;
78
+ locale?: string;
79
+ }
80
+
81
+ export interface CrmValueSpec {
82
+ control: CrmAttributeControl;
83
+ align: "left" | "right" | "center";
84
+ /**
85
+ * `<input type>` for a single-valued text editor. The grid overrides it for
86
+ * numbers — spinner arrows in a 34px cell hijack the scroll wheel — but the
87
+ * semantic type belongs to the attribute type, so it is declared once here.
88
+ */
89
+ inputType: "text" | "number" | "email" | "tel" | "date" | "datetime-local";
90
+ /** Display text for one scalar value. Empty string means "no value". */
91
+ format(value: CrmAttributeValue, ctx: CrmValueContext): string;
92
+ /** Text for one scalar in a clipboard copy. Defaults to `format`. */
93
+ copy?(value: CrmAttributeValue, ctx: CrmValueContext): string;
94
+ /** Read one scalar back from editor text or a pasted cell. */
95
+ parse(text: string, ctx: CrmValueContext): CrmValueParse;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Options
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Option rows a picker may offer and a write may name — the server rejects the rest. */
103
+ export function activeOptions(attribute: CrmValueShape): CrmAttributeOption[] {
104
+ return (attribute.options ?? []).filter((option) => !option.archived);
105
+ }
106
+
107
+ /**
108
+ * The option a stored value refers to, archived included: a value written
109
+ * before the option was archived still has to render with its own title.
110
+ */
111
+ export function resolveOption(
112
+ attribute: CrmValueShape,
113
+ value: unknown,
114
+ ): CrmAttributeOption | undefined {
115
+ if (typeof value !== "string") return undefined;
116
+ return attribute.options?.find((option) => option.value === value);
117
+ }
118
+
119
+ /** A rendered chip: a label and, when the option declares one, its colour. */
120
+ export interface CrmValueToken {
121
+ label: string;
122
+ color?: string;
123
+ }
124
+
125
+ /**
126
+ * One token per value member. Both surfaces render option chips from this —
127
+ * they differ only in the wrapper, not in what a chip says.
128
+ */
129
+ export function valueTokens(
130
+ attribute: CrmValueShape,
131
+ value: CrmAttributeValue | undefined,
132
+ ): CrmValueToken[] {
133
+ if (value === undefined || value === null) return [];
134
+ const entries = Array.isArray(value) ? value : [value];
135
+ const usesOptions = ATTRIBUTE_TYPE_SPECS[attribute.attributeType].usesOptions;
136
+ const tokens: CrmValueToken[] = [];
137
+ for (const entry of entries) {
138
+ if (entry === null || entry === undefined) continue;
139
+ const option = usesOptions ? resolveOption(attribute, entry) : undefined;
140
+ if (option) {
141
+ tokens.push(
142
+ option.color
143
+ ? { label: option.title, color: option.color }
144
+ : { label: option.title },
145
+ );
146
+ continue;
147
+ }
148
+ tokens.push({
149
+ label: typeof entry === "object" ? JSON.stringify(entry) : String(entry),
150
+ });
151
+ }
152
+ return tokens;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Shared coercions
157
+ // ---------------------------------------------------------------------------
158
+
159
+ function asDisplayString(value: CrmAttributeValue): string {
160
+ if (value === null || value === undefined) return "";
161
+ if (typeof value === "string") return value;
162
+ if (typeof value === "number") return String(value);
163
+ if (typeof value === "boolean") return value ? "true" : "false";
164
+ return JSON.stringify(value);
165
+ }
166
+
167
+ /** ISO date (`2026-01-31`) from an ISO date or timestamp; "" when unreadable. */
168
+ export function toDateInputValue(value: CrmAttributeValue | undefined): string {
169
+ if (typeof value !== "string" || !value) return "";
170
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(value);
171
+ return match?.[1] ?? "";
172
+ }
173
+
174
+ /** `YYYY-MM-DDTHH:mm` for `<input type="datetime-local">`; "" when unreadable. */
175
+ export function toDateTimeInputValue(
176
+ value: CrmAttributeValue | undefined,
177
+ ): string {
178
+ if (typeof value !== "string" || !value) return "";
179
+ const parsed = new Date(value);
180
+ if (Number.isNaN(parsed.getTime())) return "";
181
+ const pad = (part: number) => String(part).padStart(2, "0");
182
+ return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(
183
+ parsed.getDate(),
184
+ )}T${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
185
+ }
186
+
187
+ /** The ISO 4217 code an attribute declares, normalised; null when unusable. */
188
+ export function currencyCodeOf(
189
+ config: Record<string, unknown> | undefined,
190
+ ): string | null {
191
+ const currency = config?.currency;
192
+ if (!currency || typeof currency !== "object") return null;
193
+ const code = (currency as { code?: unknown }).code;
194
+ return typeof code === "string" && /^[A-Za-z]{3}$/.test(code)
195
+ ? code.toUpperCase()
196
+ : null;
197
+ }
198
+
199
+ function formatCurrency(
200
+ value: number,
201
+ code: string | null,
202
+ locale: string | undefined,
203
+ ): string {
204
+ if (!code) return String(value);
205
+ try {
206
+ return new Intl.NumberFormat(locale, {
207
+ style: "currency",
208
+ currency: code,
209
+ maximumFractionDigits: 2,
210
+ }).format(value);
211
+ } catch {
212
+ // An unsupported code is a configuration problem, not a reason to hide the
213
+ // amount: show the raw number next to the code the attribute declares.
214
+ return `${value} ${code}`;
215
+ }
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Per-type specs
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /** Ratings are a fixed five-star scale; the parser and both surfaces share it. */
223
+ export const RATING_MAX = 5;
224
+
225
+ const TEXT_SPEC: CrmValueSpec = {
226
+ control: "text",
227
+ align: "left",
228
+ inputType: "text",
229
+ format: asDisplayString,
230
+ parse: (text) => ({ ok: true, value: text.trim() === "" ? null : text }),
231
+ };
232
+
233
+ function numberSpec(overrides: Partial<CrmValueSpec> = {}): CrmValueSpec {
234
+ return {
235
+ control: "number",
236
+ align: "right",
237
+ inputType: "number",
238
+ format: (value) => (typeof value === "number" ? String(value) : ""),
239
+ parse: (text) => {
240
+ const trimmed = text.trim();
241
+ if (!trimmed) return { ok: true, value: null };
242
+ // `Number("")` is 0 and `Number("12abc")` is NaN — both would otherwise
243
+ // land in the field as a confident wrong number.
244
+ const parsed = Number(trimmed.replace(/[\s,]/g, ""));
245
+ if (!Number.isFinite(parsed)) {
246
+ return { ok: false, reason: "not-a-number", detail: trimmed };
247
+ }
248
+ return { ok: true, value: parsed };
249
+ },
250
+ ...overrides,
251
+ };
252
+ }
253
+
254
+ function optionSpec(): CrmValueSpec {
255
+ return {
256
+ control: "options",
257
+ align: "left",
258
+ inputType: "text",
259
+ format: (value, ctx) => {
260
+ const raw = asDisplayString(value);
261
+ if (!raw) return "";
262
+ return resolveOption(ctx.attribute, raw)?.title ?? raw;
263
+ },
264
+ // Copy the stored value, not the title: a clipboard round-trip has to paste
265
+ // back through `parse`, which matches on option value first.
266
+ copy: (value) => asDisplayString(value),
267
+ parse: (text, ctx) => {
268
+ const trimmed = text.trim();
269
+ if (!trimmed) return { ok: true, value: null };
270
+ const options = activeOptions(ctx.attribute);
271
+ const match =
272
+ options.find((option) => option.value === trimmed) ??
273
+ options.find(
274
+ (option) => option.title.toLowerCase() === trimmed.toLowerCase(),
275
+ );
276
+ // Managed options are a closed set, and the writer resolves them against
277
+ // non-archived rows only. Refusing here keeps a doomed write off the wire.
278
+ if (!match)
279
+ return { ok: false, reason: "unknown-option", detail: trimmed };
280
+ return { ok: true, value: match.value };
281
+ },
282
+ };
283
+ }
284
+
285
+ function referenceSpec(): CrmValueSpec {
286
+ return {
287
+ control: "reference",
288
+ align: "left",
289
+ inputType: "text",
290
+ format: asDisplayString,
291
+ parse: (text) => ({ ok: true, value: text.trim() === "" ? null : text }),
292
+ };
293
+ }
294
+
295
+ function readOnlySpec(format: CrmValueSpec["format"]): CrmValueSpec {
296
+ return {
297
+ control: "none",
298
+ align: "left",
299
+ inputType: "text",
300
+ format,
301
+ parse: () => ({ ok: false, reason: "read-only" }),
302
+ };
303
+ }
304
+
305
+ function formatLocation(value: CrmAttributeValue): string {
306
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
307
+ return asDisplayString(value);
308
+ }
309
+ const parts = ["locality", "region", "country"]
310
+ .map((key) => (value as Record<string, unknown>)[key])
311
+ .filter((part): part is string => typeof part === "string" && part !== "");
312
+ return parts.length ? parts.join(", ") : JSON.stringify(value);
313
+ }
314
+
315
+ function formatInteraction(value: CrmAttributeValue): string {
316
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
317
+ return asDisplayString(value);
318
+ }
319
+ const record = value as Record<string, unknown>;
320
+ const type = typeof record.type === "string" ? record.type : "";
321
+ const at = typeof record.occurredAt === "string" ? record.occurredAt : "";
322
+ return [type, at].filter(Boolean).join(" · ") || JSON.stringify(value);
323
+ }
324
+
325
+ export const ATTRIBUTE_VALUE_SPECS: Record<CrmAttributeType, CrmValueSpec> = {
326
+ text: TEXT_SPEC,
327
+ number: numberSpec(),
328
+ checkbox: {
329
+ control: "checkbox",
330
+ align: "center",
331
+ inputType: "text",
332
+ format: (value) =>
333
+ value === true ? "true" : value === false ? "false" : "",
334
+ parse: (text) => {
335
+ const trimmed = text.trim().toLowerCase();
336
+ if (!trimmed) return { ok: true, value: null };
337
+ if (["true", "yes", "1", "y"].includes(trimmed)) {
338
+ return { ok: true, value: true };
339
+ }
340
+ if (["false", "no", "0", "n"].includes(trimmed)) {
341
+ return { ok: true, value: false };
342
+ }
343
+ return { ok: false, reason: "not-a-number", detail: trimmed };
344
+ },
345
+ },
346
+ currency: numberSpec({
347
+ format: (value, ctx) =>
348
+ typeof value === "number"
349
+ ? formatCurrency(
350
+ value,
351
+ currencyCodeOf(ctx.attribute.config),
352
+ ctx.locale,
353
+ )
354
+ : "",
355
+ // A formatted "$1,200.00" cannot be read back as a number reliably, so both
356
+ // the clipboard and the editor seed carry the raw amount.
357
+ copy: (value) => (typeof value === "number" ? String(value) : ""),
358
+ }),
359
+ date: {
360
+ control: "date",
361
+ align: "left",
362
+ inputType: "date",
363
+ format: (value) => toDateInputValue(value),
364
+ parse: (text) => {
365
+ const trimmed = text.trim();
366
+ if (!trimmed) return { ok: true, value: null };
367
+ const parsed = new Date(trimmed);
368
+ if (Number.isNaN(parsed.getTime())) {
369
+ return { ok: false, reason: "not-a-date", detail: trimmed };
370
+ }
371
+ return { ok: true, value: parsed.toISOString().slice(0, 10) };
372
+ },
373
+ },
374
+ timestamp: {
375
+ control: "datetime",
376
+ align: "left",
377
+ inputType: "datetime-local",
378
+ format: (value, ctx) => {
379
+ if (typeof value !== "string" || !value) return "";
380
+ const parsed = new Date(value);
381
+ // An unreadable stored timestamp shows its raw text rather than a blank
382
+ // field: blank would read as "no value".
383
+ return Number.isNaN(parsed.getTime())
384
+ ? value
385
+ : parsed.toLocaleString(ctx.locale);
386
+ },
387
+ copy: (value) => asDisplayString(value),
388
+ parse: (text) => {
389
+ const trimmed = text.trim();
390
+ if (!trimmed) return { ok: true, value: null };
391
+ const parsed = new Date(trimmed);
392
+ if (Number.isNaN(parsed.getTime())) {
393
+ return { ok: false, reason: "not-a-date", detail: trimmed };
394
+ }
395
+ return { ok: true, value: parsed.toISOString() };
396
+ },
397
+ },
398
+ rating: numberSpec({
399
+ control: "rating",
400
+ parse: (text) => {
401
+ const trimmed = text.trim();
402
+ if (!trimmed) return { ok: true, value: null };
403
+ const parsed = Number(trimmed);
404
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > RATING_MAX) {
405
+ return { ok: false, reason: "not-a-number", detail: trimmed };
406
+ }
407
+ return { ok: true, value: Math.round(parsed) };
408
+ },
409
+ }),
410
+ status: optionSpec(),
411
+ select: optionSpec(),
412
+ "record-reference": referenceSpec(),
413
+ "actor-reference": referenceSpec(),
414
+ location: readOnlySpec(formatLocation),
415
+ domain: TEXT_SPEC,
416
+ "email-address": { ...TEXT_SPEC, inputType: "email" },
417
+ "phone-number": { ...TEXT_SPEC, inputType: "tel" },
418
+ interaction: readOnlySpec(formatInteraction),
419
+ "personal-name": readOnlySpec(asDisplayString),
420
+ };
421
+
422
+ export function valueSpecFor(attribute: CrmValueShape): CrmValueSpec {
423
+ return ATTRIBUTE_VALUE_SPECS[attribute.attributeType];
424
+ }
425
+
426
+ /** Every type has a spec and no system-only type is editable anywhere. */
427
+ export function assertValueRegistryComplete(): void {
428
+ for (const type of CRM_ATTRIBUTE_TYPES) {
429
+ const spec = ATTRIBUTE_VALUE_SPECS[type];
430
+ if (!spec) {
431
+ throw new Error(`CRM has no value spec for attribute type "${type}".`);
432
+ }
433
+ if (ATTRIBUTE_TYPE_SPECS[type].systemOnly && spec.control !== "none") {
434
+ throw new Error(
435
+ `CRM must render system-only attribute type "${type}" read-only.`,
436
+ );
437
+ }
438
+ }
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Multi-value handling
443
+ // ---------------------------------------------------------------------------
444
+
445
+ function scalarsOf(value: CrmAttributeValue): CrmAttributeValue[] {
446
+ return Array.isArray(value) ? value : value === null ? [] : [value];
447
+ }
448
+
449
+ function contextFor(
450
+ attribute: CrmValueShape,
451
+ locale: string | undefined,
452
+ ): CrmValueContext {
453
+ return { attribute, ...(locale ? { locale } : {}) };
454
+ }
455
+
456
+ export function formatAttributeValue(
457
+ attribute: CrmValueShape,
458
+ value: CrmAttributeValue,
459
+ locale?: string,
460
+ ): string {
461
+ const spec = valueSpecFor(attribute);
462
+ const ctx = contextFor(attribute, locale);
463
+ if (!attribute.multi) return spec.format(value, ctx);
464
+ return scalarsOf(value)
465
+ .map((entry) => spec.format(entry, ctx))
466
+ .filter((entry) => entry !== "")
467
+ .join(", ");
468
+ }
469
+
470
+ export function copyAttributeValue(
471
+ attribute: CrmValueShape,
472
+ value: CrmAttributeValue,
473
+ locale?: string,
474
+ ): string {
475
+ const spec = valueSpecFor(attribute);
476
+ const ctx = contextFor(attribute, locale);
477
+ const one = (entry: CrmAttributeValue) =>
478
+ spec.copy ? spec.copy(entry, ctx) : spec.format(entry, ctx);
479
+ if (!attribute.multi) return one(value);
480
+ return scalarsOf(value)
481
+ .map(one)
482
+ .filter((entry) => entry !== "")
483
+ .join(", ");
484
+ }
485
+
486
+ /**
487
+ * Read editor text or a pasted cell back into a storable value. A multi-valued
488
+ * attribute splits on commas; one bad member fails the whole value rather than
489
+ * dropping that member.
490
+ */
491
+ export function parseAttributeValue(
492
+ attribute: CrmValueShape,
493
+ text: string,
494
+ locale?: string,
495
+ ): CrmValueParse {
496
+ const spec = valueSpecFor(attribute);
497
+ const ctx = contextFor(attribute, locale);
498
+ if (!attribute.multi) return spec.parse(text, ctx);
499
+ const members = text
500
+ .split(",")
501
+ .map((member) => member.trim())
502
+ .filter((member) => member !== "");
503
+ if (members.length === 0) return { ok: true, value: null };
504
+ const values: Array<string | number | boolean> = [];
505
+ for (const member of members) {
506
+ const parsed = spec.parse(member, ctx);
507
+ if (!parsed.ok) return parsed;
508
+ if (parsed.value === null) continue;
509
+ if (
510
+ typeof parsed.value !== "string" &&
511
+ typeof parsed.value !== "number" &&
512
+ typeof parsed.value !== "boolean"
513
+ ) {
514
+ return { ok: false, reason: "read-only", detail: member };
515
+ }
516
+ values.push(parsed.value);
517
+ }
518
+ return { ok: true, value: values.length ? values : null };
519
+ }
520
+
521
+ /**
522
+ * The raw text an editor starts from. Never the formatted display text: a
523
+ * currency cell seeded with "$1,200.00" cannot be parsed back into 1200.
524
+ */
525
+ export function attributeInputValue(
526
+ attribute: CrmValueShape,
527
+ value: CrmAttributeValue | undefined,
528
+ ): string {
529
+ if (value === undefined || value === null) return "";
530
+ const control = valueSpecFor(attribute).control;
531
+ if (!attribute.multi && control === "date") return toDateInputValue(value);
532
+ if (!attribute.multi && control === "datetime") {
533
+ return toDateTimeInputValue(value);
534
+ }
535
+ if (Array.isArray(value))
536
+ return value.map((entry) => String(entry)).join(", ");
537
+ if (typeof value === "object") return JSON.stringify(value);
538
+ return String(value);
539
+ }
540
+
541
+ // ---------------------------------------------------------------------------
542
+ // Inline editor drafts
543
+ // ---------------------------------------------------------------------------
544
+
545
+ /** What an inline editor shows, plus the committed text it was seeded from. */
546
+ export interface CrmEditorDraft {
547
+ draft: string;
548
+ seed: string;
549
+ }
550
+
551
+ /**
552
+ * The draft an inline editor should hold for `seed`, returning `state` itself
553
+ * when the committed text has not moved.
554
+ *
555
+ * Re-seed only on the committed text. Keying it on anything looser — the
556
+ * attribute object, which the record panel rebuilds on every render — rewrites
557
+ * the input's value out from under the caret mid-edit. The browser drops the
558
+ * selection when that happens, so a select-all and retype interleaves with the
559
+ * value being replaced instead of replacing it.
560
+ */
561
+ export function editorDraftFor(
562
+ state: CrmEditorDraft | undefined,
563
+ seed: string,
564
+ ): CrmEditorDraft {
565
+ if (!state) return { draft: seed, seed };
566
+ return state.seed === seed ? state : { draft: seed, seed };
567
+ }
568
+
569
+ /**
570
+ * Days a value has sat past its status option's `targetDays`. `null` when the
571
+ * attribute is not a status, has no SLA, or has no known `activeFrom` — an
572
+ * unknown age is not an on-time one.
573
+ */
574
+ export function statusOverrunDays(input: {
575
+ attribute: CrmValueShape;
576
+ value: CrmAttributeValue;
577
+ since: string | undefined;
578
+ now?: Date;
579
+ }): number | null {
580
+ if (input.attribute.attributeType !== "status") return null;
581
+ if (typeof input.value !== "string" || !input.value) return null;
582
+ if (!input.since) return null;
583
+ const targetDays = resolveOption(input.attribute, input.value)?.targetDays;
584
+ if (typeof targetDays !== "number" || targetDays <= 0) return null;
585
+ const since = new Date(input.since);
586
+ if (Number.isNaN(since.getTime())) return null;
587
+ const now = input.now ?? new Date();
588
+ const elapsedDays = (now.getTime() - since.getTime()) / 86_400_000;
589
+ const overrun = elapsedDays - targetDays;
590
+ return overrun > 0 ? Math.floor(overrun) : null;
591
+ }