portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,444 @@
1
+ // DataFormatter port — pure functions instead of template pipes.
2
+ // In JSX these are plain calls: {fmt.relative(user.last_login)}.
3
+ //
4
+ // The string-pipe parser ("filesize|muted") is deliberately NOT ported: pipe
5
+ // strings defeat the type checker and were the source's silent-failure vector
6
+ // (unknown formatter → pass-through). Typed functions only. HTML-emitting
7
+ // formatters (badge/avatar/linkify/clipboard) are components, not formatters.
8
+ //
9
+ // Two invariants bind every function here:
10
+ // 1. NEVER throws. Bad input degrades to `—` (a trailing `fallback` param
11
+ // overrides it; `slug` documents `''` instead). A formatter is the last
12
+ // thing allowed to take a row down.
13
+ // 2. Fixed 'en-US' locale — the portal's house style, not the browser's.
14
+ import { toDateSmart } from './date/fns';
15
+
16
+ const DASH = '—';
17
+
18
+ /** Value shapes that arrive on the wire for a numeric field. */
19
+ type Numeric = number | string | null | undefined;
20
+ /** Value shapes that arrive on the wire for a text field. */
21
+ type Textual = string | number | null | undefined;
22
+
23
+ // Rule-4 warnings (unknown value → default + console.warn) fire from render
24
+ // paths, so a table would flood the console. One warning per distinct message.
25
+ const warned = new Set<string>();
26
+ function warnOnce(message: string): void {
27
+ if (warned.has(message)) return;
28
+ warned.add(message);
29
+ console.warn(message);
30
+ }
31
+
32
+ /**
33
+ * Strict numeric parse — unlike the source's parseFloat, "12 items" is NOT 12.
34
+ * A half-parsed number silently renders wrong; `—` says "no value" honestly.
35
+ */
36
+ function toNumber(value: Numeric): number | null {
37
+ if (value == null || value === '') return null;
38
+ const n = typeof value === 'number' ? value : Number(String(value).trim());
39
+ return Number.isFinite(n) ? n : null;
40
+ }
41
+
42
+ /** Text shapes; `0` is a value, `''` is not. */
43
+ function toText(value: Textual): string | null {
44
+ if (value == null) return null;
45
+ const s = String(value);
46
+ return s === '' ? null : s;
47
+ }
48
+
49
+ // Intl.NumberFormat throws RangeError outside 0–20 fraction digits.
50
+ function clampDecimals(decimals: number): number {
51
+ if (!Number.isFinite(decimals)) return 0;
52
+ return Math.min(20, Math.max(0, Math.trunc(decimals)));
53
+ }
54
+
55
+ function escapeRegExp(s: string): string {
56
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+
59
+ // Constructing an Intl formatter is expensive and tables call these per cell.
60
+ const NUMBER_FMTS = new Map<string, Intl.NumberFormat>();
61
+ function numberFormat(decimals: number): Intl.NumberFormat {
62
+ const key = String(decimals);
63
+ let f = NUMBER_FMTS.get(key);
64
+ if (!f) {
65
+ f = new Intl.NumberFormat('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
66
+ NUMBER_FMTS.set(key, f);
67
+ }
68
+ return f;
69
+ }
70
+
71
+ const CURRENCY_FMTS = new Map<string, Intl.NumberFormat | null>();
72
+ /** null = the runtime rejected the code (Intl throws RangeError on bad codes). */
73
+ function currencyFormat(code: string, decimals: number | undefined): Intl.NumberFormat | null {
74
+ const key = `${code}|${decimals ?? ''}`;
75
+ if (CURRENCY_FMTS.has(key)) return CURRENCY_FMTS.get(key) ?? null;
76
+ let f: Intl.NumberFormat | null = null;
77
+ try {
78
+ // Omitting the digit options lets each currency use its own convention
79
+ // (USD 2 digits, JPY 0).
80
+ f = new Intl.NumberFormat('en-US', {
81
+ style: 'currency',
82
+ currency: code,
83
+ ...(decimals == null ? {} : { minimumFractionDigits: decimals, maximumFractionDigits: decimals }),
84
+ });
85
+ } catch {
86
+ f = null;
87
+ }
88
+ CURRENCY_FMTS.set(key, f);
89
+ return f;
90
+ }
91
+
92
+ const DATE_FMT = new Intl.DateTimeFormat('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' });
93
+ const DATETIME_FMT = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
94
+
95
+ // Every formatter parses through the shared sniffer, so ALL of django-mojo's
96
+ // datetime shapes render: epoch seconds (DateTimeField), 'YYYY-MM-DD'
97
+ // (DateField), full ISO strings, epoch milliseconds, and any of those in
98
+ // string clothing (JSONField metadata). See date/fns.ts detectTemporal.
99
+ const toDate = toDateSmart;
100
+
101
+ export function date(value: string | number | null | undefined, fallback = '—'): string {
102
+ const d = toDate(value);
103
+ return d ? DATE_FMT.format(d) : fallback;
104
+ }
105
+
106
+ export function datetime(value: string | number | null | undefined, fallback = '—'): string {
107
+ const d = toDate(value);
108
+ return d ? DATETIME_FMT.format(d) : fallback;
109
+ }
110
+
111
+ /** '3 weeks ago' style. Ports web-mojo's `relative` pipe behavior. */
112
+ export function relative(value: string | number | null | undefined, fallback = 'Never'): string {
113
+ const d = toDate(value);
114
+ if (!d) return fallback;
115
+ const secs = Math.round((Date.now() - d.getTime()) / 1000);
116
+ if (secs < 60) return 'just now';
117
+ const units: [number, string][] = [
118
+ [60, 'minute'], [60, 'hour'], [24, 'day'], [7, 'week'], [4.348, 'month'], [12, 'year'],
119
+ ];
120
+ let n = secs / 60;
121
+ let unit = 'minute';
122
+ for (const [div, name] of units.slice(1)) {
123
+ if (n < div) break;
124
+ n /= div;
125
+ unit = name;
126
+ }
127
+ const count = Math.floor(n);
128
+ return `${count} ${unit}${count === 1 ? '' : 's'} ago`;
129
+ }
130
+
131
+ export function initials(name: string | null | undefined): string {
132
+ // Real rows can carry display_name: null — a formatter degrades, never throws.
133
+ return (name ?? '').split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]!.toUpperCase()).join('') || '?';
134
+ }
135
+
136
+ export type Tone = 'success' | 'warning' | 'danger' | 'info' | 'muted' | 'primary';
137
+
138
+ /** Ports DataFormatter's inferBadgeType: map common status text → tone. */
139
+ export function inferTone(value: string | boolean | null | undefined): Tone {
140
+ if (typeof value === 'boolean') return value ? 'success' : 'muted';
141
+ const v = String(value ?? '').toLowerCase();
142
+ if (['active', 'verified', 'enabled', 'ok', 'online', 'completed'].includes(v)) return 'success';
143
+ if (['pending', 'trial', 'staff', 'warning'].includes(v)) return 'warning';
144
+ if (['inactive', 'disabled', 'failed', 'error', 'banned'].includes(v)) return 'danger';
145
+ if (['admin'].includes(v)) return 'primary';
146
+ return 'muted';
147
+ }
148
+
149
+ // ============================ numbers ============================
150
+
151
+ /**
152
+ * Thousands separators. `1234567` → `1,234,567`.
153
+ * Deviation: source defaulted to 2 decimals; portal columns are counts, so 0.
154
+ */
155
+ export function number(value: Numeric, decimals = 0, fallback = DASH): string {
156
+ const n = toNumber(value);
157
+ if (n == null) return fallback;
158
+ return numberFormat(clampDecimals(decimals)).format(n);
159
+ }
160
+
161
+ export interface CurrencyOptions {
162
+ /**
163
+ * `'cents'` (default) matches BOTH the source formatter and django-mojo's
164
+ * integer-minor-unit storage (`cents_to_currency` in serializers) — money
165
+ * crosses the wire as whole cents so it never touches a float. Assumes a
166
+ * 100-minor-unit currency; pass `'major'` for JPY-style zero-decimal ones.
167
+ */
168
+ unit?: 'cents' | 'major';
169
+ /** Force fraction digits; omit to use the currency's own convention. */
170
+ decimals?: number;
171
+ fallback?: string;
172
+ }
173
+
174
+ /**
175
+ * `currency(129900)` → `$1,299.00`; `currency(129900, 'EUR')` → `€1,299.00`.
176
+ * Deviation: the source's second arg was a literal SYMBOL, so its own callers
177
+ * passing `currency("EUR")` rendered `EUR1,299.00`. This takes an ISO code and
178
+ * lets Intl pick the symbol; an unknown code warns once and falls back to USD.
179
+ */
180
+ export function currency(value: Numeric, code = 'USD', opts: CurrencyOptions = {}): string {
181
+ const n = toNumber(value);
182
+ const fallback = opts.fallback ?? DASH;
183
+ if (n == null) return fallback;
184
+ const major = (opts.unit ?? 'cents') === 'cents' ? n / 100 : n;
185
+ const decimals = opts.decimals == null ? undefined : clampDecimals(opts.decimals);
186
+ let f = currencyFormat(code, decimals);
187
+ if (!f) {
188
+ warnOnce(`fmt.currency: unknown currency code ${JSON.stringify(code)} — falling back to USD`);
189
+ f = currencyFormat('USD', decimals);
190
+ }
191
+ // Belt and braces: if even USD is unavailable, still return a number.
192
+ return f ? f.format(major) : number(major, decimals ?? 2, fallback);
193
+ }
194
+
195
+ /**
196
+ * `percent(0.856)` → `86%`. `multiply` (source default, kept) scales a 0–1
197
+ * ratio by 100; pass `false` when the value is already a percentage.
198
+ */
199
+ export function percent(value: Numeric, decimals = 0, multiply = true, fallback = DASH): string {
200
+ const n = toNumber(value);
201
+ if (n == null) return fallback;
202
+ return `${number(multiply ? n * 100 : n, decimals)}%`;
203
+ }
204
+
205
+ /** `1234` → `1.2K`, `3400000` → `3.4M`. Caps at B, so 1e12 reads `1000.0B`. */
206
+ export function compact(value: Numeric, decimals = 1, fallback = DASH): string {
207
+ const n = toNumber(value);
208
+ if (n == null) return fallback;
209
+ const abs = Math.abs(n);
210
+ const sign = n < 0 ? '-' : '';
211
+ const d = clampDecimals(decimals);
212
+ if (abs >= 1e9) return `${sign}${(abs / 1e9).toFixed(d)}B`;
213
+ if (abs >= 1e6) return `${sign}${(abs / 1e6).toFixed(d)}M`;
214
+ if (abs >= 1e3) return `${sign}${(abs / 1e3).toFixed(d)}K`;
215
+ return String(n);
216
+ }
217
+
218
+ /** Bytes → `1.5 KB` (decimal) or `1.4 KiB` (`binary`). Whole bytes stay integral. */
219
+ export function filesize(value: Numeric, binary = false, decimals = 1, fallback = DASH): string {
220
+ const bytes = toNumber(value);
221
+ if (bytes == null) return fallback;
222
+ const units = binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB'];
223
+ const divisor = binary ? 1024 : 1000;
224
+ let size = bytes;
225
+ let unitIndex = 0;
226
+ while (Math.abs(size) >= divisor && unitIndex < units.length - 1) {
227
+ size /= divisor;
228
+ unitIndex++;
229
+ }
230
+ return `${size.toFixed(unitIndex === 0 ? 0 : clampDecimals(decimals))} ${units[unitIndex]}`;
231
+ }
232
+
233
+ /**
234
+ * `3` → `3rd`, `11` → `11th`. `suffixOnly` returns just the suffix.
235
+ * Deviation: the suffix is computed on the absolute value, so `-1` → `-1st`
236
+ * (the source's signed modulo made every negative `th`).
237
+ */
238
+ export function ordinal(value: Numeric, suffixOnly = false, fallback = DASH): string {
239
+ const n = toNumber(value);
240
+ if (n == null) return fallback;
241
+ const int = Math.trunc(n);
242
+ const abs = Math.abs(int);
243
+ const j = abs % 10;
244
+ const k = abs % 100;
245
+ let suffix = 'th';
246
+ if (j === 1 && k !== 11) suffix = 'st';
247
+ else if (j === 2 && k !== 12) suffix = 'nd';
248
+ else if (j === 3 && k !== 13) suffix = 'rd';
249
+ return suffixOnly ? suffix : `${int}${suffix}`;
250
+ }
251
+
252
+ // ============================ strings ============================
253
+
254
+ /** Cut to `length` chars and append `suffix` (so the result runs longer). */
255
+ export function truncate(value: Textual, length = 50, suffix = '...', fallback = DASH): string {
256
+ const s = toText(value);
257
+ if (s == null) return fallback;
258
+ if (s.length <= length) return s;
259
+ return s.substring(0, Math.max(0, length)) + suffix;
260
+ }
261
+
262
+ /** Keeps both ends: `truncate_middle` port — ids stay recognizable. */
263
+ export function truncateMiddle(value: Textual, size = 8, replace = '***', fallback = DASH): string {
264
+ const s = toText(value);
265
+ if (s == null) return fallback;
266
+ if (s.length <= size) return s;
267
+ const half = Math.max(0, Math.floor(size / 2));
268
+ return `${s.substring(0, half)}${replace}${s.substring(s.length - half)}`;
269
+ }
270
+
271
+ /** Keeps only the TAIL: `…c0ffee` — for hashes whose ending disambiguates. */
272
+ export function truncateFront(value: Textual, length = 8, prefix = '...', fallback = DASH): string {
273
+ const s = toText(value);
274
+ if (s == null) return fallback;
275
+ if (s.length <= length) return s;
276
+ // slice(-0) is slice(0) — the whole string — so keep=0 is handled apart.
277
+ const keep = Math.max(0, Math.trunc(length));
278
+ return keep === 0 ? prefix : `${prefix}${s.slice(-keep)}`;
279
+ }
280
+
281
+ /**
282
+ * URL-safe slug; documented fallback is `''` (a slug of nothing is nothing).
283
+ * Deviation: accents fold to ASCII (`Café` → `cafe`) instead of being dropped
284
+ * (`caf`), matching django-mojo's own `strip_accents`. The separator is regex-
285
+ * escaped — the source interpolated it raw, so `slug(v, '.')` misbehaved and
286
+ * `slug(v, '')` threw on an empty quantifier.
287
+ */
288
+ export function slug(value: Textual, separator = '-'): string {
289
+ const s = toText(value);
290
+ if (s == null) return '';
291
+ let out = s
292
+ .normalize('NFD')
293
+ .replace(/[\u0300-\u036f]/g, '')
294
+ .toLowerCase()
295
+ .replace(/[^\w\s-]/g, '')
296
+ .replace(/\s+/g, separator);
297
+ if (separator) {
298
+ const esc = escapeRegExp(separator);
299
+ out = out
300
+ .replace(new RegExp(`${esc}+`, 'g'), separator)
301
+ .replace(new RegExp(`^${esc}|${esc}$`, 'g'), '');
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * All-but-last-4: `4111111111111111` → `************1111`.
308
+ * Values no longer than `showLast` are returned intact (nothing to hide).
309
+ * Fixes a source bug: `showLast: 0` masked the string and then re-appended it
310
+ * whole, because `slice(-0)` is `slice(0)`.
311
+ */
312
+ export function mask(value: Textual, char = '*', showLast = 4, fallback = DASH): string {
313
+ const s = toText(value);
314
+ if (s == null) return fallback;
315
+ const keep = Math.max(0, Math.trunc(showLast));
316
+ if (s.length <= keep) return s;
317
+ return char.repeat(Math.max(0, s.length - keep)) + (keep === 0 ? '' : s.slice(-keep));
318
+ }
319
+
320
+ /**
321
+ * DISPLAY ONLY — E.164 (`+15555550142`) stays the wire shape; django-mojo
322
+ * REJECTS pretty formats on save, so never round-trip this back to a field.
323
+ * US 10/11-digit numbers group as `(555) 555-0142`; anything else (a real
324
+ * international number) is returned trimmed and unchanged rather than mangled
325
+ * — the source stripped the leading `+`, turning valid E.164 into digit soup.
326
+ */
327
+ export function phone(value: Textual, fallback = DASH): string {
328
+ const s = toText(value);
329
+ if (s == null) return fallback;
330
+ const trimmed = s.trim();
331
+ const digits = trimmed.replace(/\D/g, '');
332
+ if (!digits) return fallback;
333
+ if (digits.length === 10) return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
334
+ if (digits.length === 11 && digits.startsWith('1')) {
335
+ return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
336
+ }
337
+ return trimmed;
338
+ }
339
+
340
+ // ============================ time ============================
341
+
342
+ export type DurationUnit = 'ms' | 's' | 'm' | 'h' | 'd';
343
+
344
+ const UNIT_MS: Record<DurationUnit, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
345
+
346
+ const DURATION_PARTS: { name: string; short: string; ms: number }[] = [
347
+ { name: 'day', short: 'd', ms: 86_400_000 },
348
+ { name: 'hour', short: 'h', ms: 3_600_000 },
349
+ { name: 'minute', short: 'm', ms: 60_000 },
350
+ { name: 'second', short: 's', ms: 1000 },
351
+ ];
352
+
353
+ export interface DurationOptions {
354
+ /** `false` spells the units out: `2 hours 14 minutes`. */
355
+ short?: boolean;
356
+ /** How many units to show (default 2): `2h 14m`, not `2h 14m 3s`. */
357
+ precision?: number;
358
+ fallback?: string;
359
+ }
360
+
361
+ /**
362
+ * `duration(8040000)` → `2h 14m`; `duration(8040, 's')` → the same.
363
+ * Deviations: short form is the default (source: long) and its parts are
364
+ * space-separated (source: `2h14m`). Sub-second values report as `500ms`.
365
+ */
366
+ export function duration(value: Numeric, unit: DurationUnit = 'ms', opts: DurationOptions = {}): string {
367
+ const n = toNumber(value);
368
+ if (n == null) return opts.fallback ?? DASH;
369
+ let scale = UNIT_MS[unit];
370
+ if (scale == null) {
371
+ warnOnce(`fmt.duration: unknown unit ${JSON.stringify(unit)} — falling back to 'ms'`);
372
+ scale = 1;
373
+ }
374
+ const short = opts.short ?? true;
375
+ const precision = Math.max(1, Math.trunc(opts.precision ?? 2));
376
+ const ms = n * scale;
377
+ if (ms === 0) return short ? '0s' : '0 seconds';
378
+
379
+ const sign = ms < 0 ? '-' : '';
380
+ let remaining = Math.abs(ms);
381
+ const parts: string[] = [];
382
+ for (const u of DURATION_PARTS) {
383
+ if (remaining < u.ms) continue;
384
+ const count = Math.floor(remaining / u.ms);
385
+ remaining %= u.ms;
386
+ parts.push(short ? `${count}${u.short}` : `${count} ${u.name}${count === 1 ? '' : 's'}`);
387
+ if (parts.length >= precision) break;
388
+ }
389
+ if (parts.length === 0) {
390
+ const rounded = Math.round(Math.abs(ms));
391
+ return `${sign}${rounded}${short ? 'ms' : ` millisecond${rounded === 1 ? '' : 's'}`}`;
392
+ }
393
+ return sign + parts.join(' ');
394
+ }
395
+
396
+ /**
397
+ * Compact age for chips and timelines: `3w`, `5h`, `now`. Magnitude only —
398
+ * past and future read the same (use `relative` when direction matters).
399
+ */
400
+ export function relativeShort(value: string | number | null | undefined, fallback = DASH): string {
401
+ const d = toDate(value);
402
+ if (!d) return fallback;
403
+ const secs = Math.floor(Math.abs(Date.now() - d.getTime()) / 1000);
404
+ const mins = Math.floor(secs / 60);
405
+ const hours = Math.floor(mins / 60);
406
+ const days = Math.floor(hours / 24);
407
+ if (days > 365) return `${Math.floor(days / 365)}y`;
408
+ if (days > 30) return `${Math.floor(days / 30)}mo`;
409
+ if (days > 7) return `${Math.floor(days / 7)}w`;
410
+ if (days > 0) return `${days}d`;
411
+ if (hours > 0) return `${hours}h`;
412
+ if (mins > 0) return `${mins}m`;
413
+ return 'now';
414
+ }
415
+
416
+ // ============================ booleans ============================
417
+
418
+ // Same coercion table as grouping.ts's groupByBoolean — one truth about what
419
+ // "false" looks like on the wire.
420
+ const STRING_FALSE = new Set(['false', '0', 'no', 'off']);
421
+
422
+ export interface YesNoOptions {
423
+ yes?: string;
424
+ no?: string;
425
+ /** Shown for null/undefined/empty: unset is NOT the same answer as "No". */
426
+ fallback?: string;
427
+ }
428
+
429
+ /** `yesNo(true)` → `Yes`. Empty arrays/objects read false, as in the source's `bool`. */
430
+ export function yesNo(value: unknown, opts: YesNoOptions = {}): string {
431
+ const fallback = opts.fallback ?? DASH;
432
+ if (value == null) return fallback;
433
+ let b: boolean;
434
+ if (typeof value === 'boolean') b = value;
435
+ else if (typeof value === 'number') b = Number.isFinite(value) ? value !== 0 : false;
436
+ else if (typeof value === 'string') {
437
+ const lower = value.trim().toLowerCase();
438
+ if (lower === '') return fallback;
439
+ b = !STRING_FALSE.has(lower);
440
+ } else if (Array.isArray(value)) b = value.length > 0;
441
+ else if (typeof value === 'object') b = Object.keys(value).length > 0;
442
+ else b = Boolean(value);
443
+ return b ? (opts.yes ?? 'Yes') : (opts.no ?? 'No');
444
+ }
@@ -0,0 +1,150 @@
1
+ // Built-in `groupBy*` helpers for ModelTable — the web-mojo grouping.js port
2
+ // (src/core/views/list/grouping.js), operating on plain rows instead of Model
3
+ // instances. Each helper returns `{ groupBy, groupHeaderLabel }` ready to
4
+ // spread into ModelTable props. Group keys are STABLE bucket ids (sort-
5
+ // ordered where that matters) and labels are formatted separately.
6
+ //
7
+ // A falsy key means "ungrouped tail": the row renders without a new header,
8
+ // visually continuing the prior section.
9
+ import { toDateSmart } from './date/fns';
10
+
11
+ // Interfaces without index signatures (User, …) aren't assignable to
12
+ // Record<string, unknown> under strict TS, so the row constraint is plain
13
+ // `object` and field access goes through one localized cast.
14
+ type Row = object;
15
+ type Accessor<T> = (row: T) => unknown;
16
+
17
+ function resolveAccessor<T extends Row>(fieldOrAccessor: string | Accessor<T>): Accessor<T> {
18
+ if (typeof fieldOrAccessor === 'function') return fieldOrAccessor;
19
+ return (row: T) => (row as Record<string, unknown>)[fieldOrAccessor];
20
+ }
21
+
22
+ /** Epoch seconds / ms / numeric string / ISO / Date → local Date, or null.
23
+ * One shared sniffer (date/fns detectTemporal) so a grouped bucket and the
24
+ * cell rendered inside it can never disagree about what day a row is on. */
25
+ const toDate = toDateSmart;
26
+
27
+ /** Stable local-time YYYY-MM-DD bucket key. */
28
+ function isoDayKey(date: Date): string {
29
+ const m = String(date.getMonth() + 1).padStart(2, '0');
30
+ const d = String(date.getDate()).padStart(2, '0');
31
+ return `${date.getFullYear()}-${m}-${d}`;
32
+ }
33
+
34
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
35
+
36
+ function formatDayLabel(key: string): string {
37
+ const parts = key.split('-');
38
+ if (parts.length !== 3) return key;
39
+ const [year, month, day] = parts.map(Number) as [number, number, number];
40
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return key;
41
+ const now = new Date();
42
+ if (key === isoDayKey(now)) return 'Today';
43
+ const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
44
+ if (key === isoDayKey(yesterday)) return 'Yesterday';
45
+ const label = `${MONTHS[month - 1] ?? ''} ${day}`;
46
+ return year === now.getFullYear() ? label : `${label}, ${year}`;
47
+ }
48
+
49
+ export interface GroupSpec<T> {
50
+ groupBy: (row: T) => string | null;
51
+ groupHeaderLabel: (key: string) => string;
52
+ }
53
+
54
+ /** Day buckets for chronological feeds: Today / Yesterday / Apr 25 / Dec 19, 2025. */
55
+ export function groupByDay<T extends Row>(fieldOrAccessor: string | Accessor<T>): GroupSpec<T> {
56
+ const access = resolveAccessor(fieldOrAccessor);
57
+ return {
58
+ groupBy: (row) => {
59
+ const date = toDate(access(row));
60
+ return date ? isoDayKey(date) : null;
61
+ },
62
+ groupHeaderLabel: formatDayLabel,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Categorical bucketing on a field's string value. `labels` wins over
68
+ * `format`; `fallback` names the bucket for null/undefined/'' (default:
69
+ * ungrouped tail). `0`/`false` stringify and DO bucket.
70
+ */
71
+ export function groupByField<T extends Row>(
72
+ fieldOrAccessor: string | Accessor<T>,
73
+ opts: { labels?: Record<string, string>; format?: (key: string) => string; fallback?: string } = {},
74
+ ): GroupSpec<T> {
75
+ const access = resolveAccessor(fieldOrAccessor);
76
+ return {
77
+ groupBy: (row) => {
78
+ const raw = access(row);
79
+ if (raw == null || raw === '') return opts.fallback != null ? String(opts.fallback) : null;
80
+ return String(raw);
81
+ },
82
+ groupHeaderLabel: (key) => {
83
+ if (opts.labels && Object.prototype.hasOwnProperty.call(opts.labels, key)) return opts.labels[key]!;
84
+ if (opts.format) return opts.format(key);
85
+ return key;
86
+ },
87
+ };
88
+ }
89
+
90
+ // Sort-ordered keys so a descending-by-date sort renders buckets in natural
91
+ // reading order (Today on top, Older on bottom).
92
+ const RECENCY_LABELS: Record<string, string> = {
93
+ 'recency-0-today': 'Today',
94
+ 'recency-1-yesterday': 'Yesterday',
95
+ 'recency-2-this-week': 'This week',
96
+ 'recency-3-this-month': 'This month',
97
+ 'recency-4-this-year': 'Earlier this year',
98
+ 'recency-5-older': 'Older',
99
+ };
100
+
101
+ function recencyBucketKey(date: Date | null): string | null {
102
+ if (!date) return null;
103
+ const now = new Date();
104
+ const dateKey = isoDayKey(date);
105
+ if (dateKey === isoDayKey(now)) return 'recency-0-today';
106
+ const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
107
+ if (dateKey === isoDayKey(yesterday)) return 'recency-1-yesterday';
108
+ const sevenDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7);
109
+ if (date >= sevenDaysAgo) return 'recency-2-this-week';
110
+ if (date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()) return 'recency-3-this-month';
111
+ if (date.getFullYear() === now.getFullYear()) return 'recency-4-this-year';
112
+ return 'recency-5-older';
113
+ }
114
+
115
+ /** Six fixed buckets relative to now: Today … Older. */
116
+ export function groupByRecency<T extends Row>(fieldOrAccessor: string | Accessor<T>): GroupSpec<T> {
117
+ const access = resolveAccessor(fieldOrAccessor);
118
+ return {
119
+ groupBy: (row) => recencyBucketKey(toDate(access(row))),
120
+ groupHeaderLabel: (key) => RECENCY_LABELS[key] ?? key,
121
+ };
122
+ }
123
+
124
+ const STRING_FALSE = new Set(['false', '0', 'no', 'off']);
125
+
126
+ /** Binary split. Missing/empty values fall to the ungrouped tail, not "false". */
127
+ export function groupByBoolean<T extends Row>(
128
+ fieldOrAccessor: string | Accessor<T>,
129
+ opts: { trueLabel?: string; falseLabel?: string } = {},
130
+ ): GroupSpec<T> {
131
+ const access = resolveAccessor(fieldOrAccessor);
132
+ const trueLabel = opts.trueLabel ?? 'Yes';
133
+ const falseLabel = opts.falseLabel ?? 'No';
134
+ return {
135
+ groupBy: (row) => {
136
+ const raw = access(row);
137
+ if (raw == null || raw === '') return null;
138
+ let b: boolean;
139
+ if (typeof raw === 'boolean') b = raw;
140
+ else if (typeof raw === 'number') b = raw !== 0;
141
+ else if (typeof raw === 'string') {
142
+ const lower = raw.trim().toLowerCase();
143
+ if (lower === '') return null;
144
+ b = !STRING_FALSE.has(lower);
145
+ } else b = Boolean(raw);
146
+ return b ? 'true' : 'false';
147
+ },
148
+ groupHeaderLabel: (key) => (key === 'true' ? trueLabel : key === 'false' ? falseLabel : key),
149
+ };
150
+ }