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,679 @@
1
+ // date fns — narrow date utilities used by the Calendar / picker engine.
2
+ // Ported from web-mojo src/core/utils/dateFns.js (logic verbatim — the tuned
3
+ // edge cases matter: 12h clock rules, month-length clamping, DST-safe local
4
+ // math).
5
+ //
6
+ // Local-date components only (year / month / day ints). All formatting and
7
+ // comparison work in the browser's local timezone — never UTC offset — so
8
+ // DST boundaries don't cause days to disappear. Date objects are internal
9
+ // scaffolding only; every public shape is plain `{y, m?, d?}` ints.
10
+ //
11
+ // NOT a general-purpose date library. Add only what the pickers need.
12
+
13
+ export type Precision = 'day' | 'month' | 'year';
14
+
15
+ /** `m?: undefined` / `d?: undefined` markers make the three shapes a
16
+ * discriminated-by-presence union: `{y,m,d}` is NOT assignable to ParsedYm. */
17
+ export interface ParsedYear { y: number; m?: undefined; d?: undefined }
18
+ export interface ParsedYm { y: number; m: number; d?: undefined }
19
+ export interface ParsedYmd { y: number; m: number; d: number }
20
+ /** Any calendar value — the `Precision` in play decides which parts are read. */
21
+ export type ParsedDate = ParsedYear | ParsedYm | ParsedYmd;
22
+
23
+ export interface ParsedTime { hours: number; minutes: number }
24
+ export interface ParsedDateTime { date: ParsedYmd; time: ParsedTime; timezone: string | null }
25
+
26
+ /** Loose object form `parseTime` accepts (`{hours,minutes}` or `{h,m}`). */
27
+ export interface TimeParts {
28
+ hours?: number | string | null;
29
+ minutes?: number | string | null;
30
+ h?: number | string | null;
31
+ m?: number | string | null;
32
+ }
33
+
34
+ /** Loose object form `parseDateTime` accepts — date/time as STRINGS. */
35
+ export interface DateTimeParts {
36
+ date?: string | Date | null;
37
+ time?: string | TimeParts | null;
38
+ timezone?: string | null;
39
+ }
40
+
41
+ const MONTH_NAMES_FULL = ['January', 'February', 'March', 'April', 'May', 'June',
42
+ 'July', 'August', 'September', 'October', 'November', 'December'];
43
+ const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
44
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
45
+
46
+ // ── Parse / format ─────────────────────────────────────────────────
47
+
48
+ /** "YYYY" → { y } | null */
49
+ export function parseYear(s: string | null | undefined): ParsedYear | null {
50
+ if (s == null || s === '') return null;
51
+ const y = parseInt(String(s).slice(0, 4), 10);
52
+ return Number.isFinite(y) ? { y } : null;
53
+ }
54
+
55
+ /** "YYYY-MM" → { y, m } | null */
56
+ export function parseYm(s: string | null | undefined): ParsedYm | null {
57
+ if (s == null || s === '') return null;
58
+ const parts = String(s).split('-');
59
+ const y = parseInt(parts[0]!, 10);
60
+ const m = parseInt(parts[1]!, 10);
61
+ if (!Number.isFinite(y) || !Number.isFinite(m) || m < 1 || m > 12) return null;
62
+ return { y, m };
63
+ }
64
+
65
+ /** "YYYY-MM-DD" or Date → { y, m, d } | null. Day is range-checked 1–31 only
66
+ * (not against the month's length) — matches source; the engine never emits
67
+ * an out-of-month day. */
68
+ export function parseYmd(s: string | Date | null | undefined): ParsedYmd | null {
69
+ if (s == null || s === '') return null;
70
+ if (s instanceof Date) {
71
+ if (isNaN(s.getTime())) return null;
72
+ return { y: s.getFullYear(), m: s.getMonth() + 1, d: s.getDate() };
73
+ }
74
+ const parts = String(s).split('-');
75
+ const y = parseInt(parts[0]!, 10);
76
+ const m = parseInt(parts[1]!, 10);
77
+ const d = parseInt(parts[2]!, 10);
78
+ if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
79
+ if (m < 1 || m > 12 || d < 1 || d > 31) return null;
80
+ return { y, m, d };
81
+ }
82
+
83
+ export function formatYear(value: ParsedDate | string | null | undefined): string {
84
+ const p = value != null && typeof value === 'object' ? value : parseYear(value);
85
+ return p ? String(p.y) : '';
86
+ }
87
+
88
+ export function formatYm(value: ParsedYm | ParsedYmd | string | null | undefined): string {
89
+ const p = value != null && typeof value === 'object' ? value : parseYm(value);
90
+ return p ? `${p.y}-${pad2(p.m)}` : '';
91
+ }
92
+
93
+ export function formatYmd(value: ParsedYmd | string | null | undefined): string {
94
+ const p = value != null && typeof value === 'object' ? value : parseYmd(value);
95
+ return p ? `${p.y}-${pad2(p.m)}-${pad2(p.d)}` : '';
96
+ }
97
+
98
+ /** Format a parsed value (or string) by precision: 'year' | 'month' | 'day'.
99
+ * Caller contract (same as source): the value carries the parts the
100
+ * precision reads. */
101
+ export function formatByPrecision(value: ParsedDate | string | null | undefined, precision: Precision): string {
102
+ if (precision === 'year') return formatYear(value);
103
+ if (precision === 'month') return formatYm(value as ParsedYm | ParsedYmd | string | null | undefined);
104
+ return formatYmd(value as ParsedYmd | string | null | undefined);
105
+ }
106
+
107
+ /** Parse a stored string by precision. */
108
+ export function parseByPrecision(s: string | null | undefined, precision: 'day'): ParsedYmd | null;
109
+ export function parseByPrecision(s: string | null | undefined, precision: 'month'): ParsedYm | null;
110
+ export function parseByPrecision(s: string | null | undefined, precision: 'year'): ParsedYear | null;
111
+ export function parseByPrecision(s: string | null | undefined, precision: Precision): ParsedDate | null;
112
+ export function parseByPrecision(s: string | null | undefined, precision: Precision): ParsedDate | null {
113
+ if (precision === 'year') return parseYear(s);
114
+ if (precision === 'month') return parseYm(s);
115
+ return parseYmd(s);
116
+ }
117
+
118
+ // ── Display formatting ─────────────────────────────────────────────
119
+
120
+ const DISPLAY_TOKENS = /YYYY|YY|MMMM|MMM|MM|M|DD|D/g;
121
+
122
+ /**
123
+ * Lightweight display formatter. Supports YYYY, YY, MMMM, MMM, MM, M, DD, D.
124
+ * For tokens not relevant to the precision (e.g. DD when precision='month'),
125
+ * the token is dropped — caller is responsible for picking a sensible format.
126
+ */
127
+ export function formatForDisplay(parsed: ParsedDate | null | undefined, format: string): string {
128
+ if (!parsed) return '';
129
+ const y = parsed.y;
130
+ const m = parsed.m;
131
+ const d = parsed.d;
132
+ return String(format).replace(DISPLAY_TOKENS, (tok) => {
133
+ switch (tok) {
134
+ case 'YYYY': return String(y);
135
+ case 'YY': return String(y).slice(-2);
136
+ case 'MMMM': return m ? MONTH_NAMES_FULL[m - 1]! : '';
137
+ case 'MMM': return m ? MONTH_NAMES_SHORT[m - 1]! : '';
138
+ case 'MM': return m ? pad2(m) : '';
139
+ case 'M': return m ? String(m) : '';
140
+ case 'DD': return d ? pad2(d) : '';
141
+ case 'D': return d ? String(d) : '';
142
+ default: return tok;
143
+ }
144
+ });
145
+ }
146
+
147
+ // ── Component math ─────────────────────────────────────────────────
148
+
149
+ /** Days in `month` (1-indexed) of `year`. Last day of `month` is day 0 of `month+1`. */
150
+ export function daysInMonth(year: number, month: number): number {
151
+ return new Date(year, month, 0).getDate();
152
+ }
153
+
154
+ /**
155
+ * Weekday of a given Y-M-D, 0..6 with `firstDay` (0=Sun, 1=Mon, ...) as 0.
156
+ */
157
+ export function weekdayWithFirstDay(year: number, month: number, day: number, firstDay = 1): number {
158
+ const d = new Date(year, month - 1, day).getDay(); // 0=Sun..6=Sat
159
+ return ((d - firstDay) % 7 + 7) % 7;
160
+ }
161
+
162
+ /** Add `n` months to a {y,m} or {y,m,d}, returning the same shape clamped. */
163
+ export function addMonths(p: ParsedYmd, n: number): ParsedYmd;
164
+ export function addMonths(p: ParsedYm, n: number): ParsedYm;
165
+ export function addMonths(p: null | undefined, n: number): null;
166
+ export function addMonths(p: ParsedYm | ParsedYmd | null | undefined, n: number): ParsedYm | ParsedYmd | null;
167
+ export function addMonths(p: ParsedYm | ParsedYmd | null | undefined, n: number): ParsedYm | ParsedYmd | null {
168
+ if (!p) return null;
169
+ let y = p.y;
170
+ let m = p.m + n;
171
+ while (m > 12) { m -= 12; y += 1; }
172
+ while (m < 1) { m += 12; y -= 1; }
173
+ if (p.d == null) return { y, m };
174
+ const dim = daysInMonth(y, m);
175
+ return { y, m, d: Math.min(p.d, dim) };
176
+ }
177
+
178
+ export function addYears(p: ParsedYmd, n: number): ParsedYmd;
179
+ export function addYears(p: ParsedYm, n: number): ParsedYm;
180
+ export function addYears(p: ParsedYear, n: number): ParsedYear;
181
+ export function addYears(p: null | undefined, n: number): null;
182
+ export function addYears(p: ParsedDate | null | undefined, n: number): ParsedDate | null;
183
+ export function addYears(p: ParsedDate | null | undefined, n: number): ParsedDate | null {
184
+ if (!p) return null;
185
+ if (p.m == null) return { y: p.y + n };
186
+ if (p.d == null) return { y: p.y + n, m: p.m };
187
+ const dim = daysInMonth(p.y + n, p.m);
188
+ return { y: p.y + n, m: p.m, d: Math.min(p.d, dim) };
189
+ }
190
+
191
+ // ── Comparison ─────────────────────────────────────────────────────
192
+
193
+ /** -1 / 0 / 1 — compare two parsed values at day precision. Missing parts compared as 0. */
194
+ export function compareYmd(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined): -1 | 0 | 1 {
195
+ if (!a || !b) return 0;
196
+ if (a.y !== b.y) return a.y < b.y ? -1 : 1;
197
+ if ((a.m || 0) !== (b.m || 0)) return (a.m || 0) < (b.m || 0) ? -1 : 1;
198
+ if ((a.d || 0) !== (b.d || 0)) return (a.d || 0) < (b.d || 0) ? -1 : 1;
199
+ return 0;
200
+ }
201
+
202
+ export function compareYm(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined): -1 | 0 | 1 {
203
+ if (!a || !b) return 0;
204
+ if (a.y !== b.y) return a.y < b.y ? -1 : 1;
205
+ if ((a.m || 0) !== (b.m || 0)) return (a.m || 0) < (b.m || 0) ? -1 : 1;
206
+ return 0;
207
+ }
208
+
209
+ export function compareYear(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined): -1 | 0 | 1 {
210
+ if (!a || !b) return 0;
211
+ return a.y === b.y ? 0 : (a.y < b.y ? -1 : 1);
212
+ }
213
+
214
+ /** Cross-precision comparison is the point: `{y}` vs `{y,m,d}` at level
215
+ * 'year' compares years only — min/max gating at pane precision rides on it. */
216
+ export function compareByPrecision(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined, precision: Precision): -1 | 0 | 1 {
217
+ if (precision === 'year') return compareYear(a, b);
218
+ if (precision === 'month') return compareYm(a, b);
219
+ return compareYmd(a, b);
220
+ }
221
+
222
+ export function isSameDay(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined): boolean {
223
+ return compareYmd(a, b) === 0 && !!a && !!b && a.d === b.d;
224
+ }
225
+
226
+ export function isSameMonth(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined): boolean {
227
+ return !!a && !!b && a.y === b.y && a.m === b.m;
228
+ }
229
+
230
+ export function isSameYear(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined): boolean {
231
+ return !!a && !!b && a.y === b.y;
232
+ }
233
+
234
+ // ── Unit counts ────────────────────────────────────────────────────
235
+
236
+ /** Inclusive day count between two parsed dates (1 if same day). */
237
+ export function daysBetweenInclusive(a: ParsedYmd | null | undefined, b: ParsedYmd | null | undefined): number {
238
+ if (!a || !b) return 0;
239
+ const A = new Date(a.y, a.m - 1, a.d);
240
+ const B = new Date(b.y, b.m - 1, b.d);
241
+ return Math.round(Math.abs(B.getTime() - A.getTime()) / 86400000) + 1;
242
+ }
243
+
244
+ /** Inclusive month count. */
245
+ export function monthsBetweenInclusive(a: ParsedYm | ParsedYmd | null | undefined, b: ParsedYm | ParsedYmd | null | undefined): number {
246
+ if (!a || !b) return 0;
247
+ return Math.abs((b.y - a.y) * 12 + (b.m - a.m)) + 1;
248
+ }
249
+
250
+ /** Inclusive year count. */
251
+ export function yearsBetweenInclusive(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined): number {
252
+ if (!a || !b) return 0;
253
+ return Math.abs(b.y - a.y) + 1;
254
+ }
255
+
256
+ export function unitsBetweenInclusive(a: ParsedDate | null | undefined, b: ParsedDate | null | undefined, precision: Precision): number {
257
+ if (precision === 'year') return yearsBetweenInclusive(a, b);
258
+ if (precision === 'month') return monthsBetweenInclusive(a as ParsedYm | null, b as ParsedYm | null);
259
+ return daysBetweenInclusive(a as ParsedYmd | null, b as ParsedYmd | null);
260
+ }
261
+
262
+ // ── Time parse / format ────────────────────────────────────────────
263
+
264
+ /**
265
+ * Parse a time string into { hours, minutes } in 24h canonical form.
266
+ * Accepts: "HH:MM", "H:MM", "h:mm am", "h:mm pm" (case-insensitive),
267
+ * "HH:MM:SS" (seconds discarded), "HH:MM TZ" (TZ discarded).
268
+ * Returns null on invalid input. 12h rules are strict: hour must be 1–12
269
+ * when am/pm is present ("0:30 am" and "13:00 pm" are invalid).
270
+ */
271
+ export function parseTime(s: string | TimeParts | null | undefined): ParsedTime | null {
272
+ if (s == null || s === '') return null;
273
+ if (typeof s === 'object') {
274
+ const h = parseInt(String(s.hours ?? s.h), 10);
275
+ const mn = parseInt(String(s.minutes ?? s.m), 10);
276
+ if (!Number.isFinite(h) || !Number.isFinite(mn)) return null;
277
+ if (h < 0 || h > 23 || mn < 0 || mn > 59) return null;
278
+ return { hours: h, minutes: mn };
279
+ }
280
+ const str = String(s).trim();
281
+ // Strip a trailing TZ-ish token after a space (e.g. "14:30 America/New_York")
282
+ const firstSpace = str.indexOf(' ');
283
+ const timePart = firstSpace > -1 ? str.slice(0, firstSpace) : str;
284
+ const tail = firstSpace > -1 ? str.slice(firstSpace + 1).trim() : '';
285
+ const ampmMatch = tail.match(/^(am|pm)$/i) || timePart.match(/(am|pm)$/i);
286
+ const cleanTime = timePart.replace(/(am|pm)$/i, '').trim();
287
+ const parts = cleanTime.split(':');
288
+ if (parts.length < 2) return null;
289
+ let h = parseInt(parts[0]!, 10);
290
+ const mn = parseInt(parts[1]!, 10);
291
+ if (!Number.isFinite(h) || !Number.isFinite(mn)) return null;
292
+ if (mn < 0 || mn > 59) return null;
293
+ if (ampmMatch) {
294
+ const isPm = ampmMatch[1]!.toLowerCase() === 'pm';
295
+ if (h < 1 || h > 12) return null;
296
+ if (h === 12) h = isPm ? 12 : 0;
297
+ else if (isPm) h += 12;
298
+ }
299
+ if (h < 0 || h > 23) return null;
300
+ return { hours: h, minutes: mn };
301
+ }
302
+
303
+ /**
304
+ * Format { hours, minutes } as either '24h' (default) or '12h'.
305
+ * '24h' → "HH:MM"
306
+ * '12h' → "H:MM AM" / "H:MM PM" (1..12)
307
+ */
308
+ export function formatTime(t: ParsedTime | null | undefined, format: '24h' | '12h' = '24h'): string {
309
+ if (!t) return '';
310
+ const h = t.hours;
311
+ const mn = t.minutes;
312
+ if (!Number.isFinite(h) || !Number.isFinite(mn)) return '';
313
+ if (format === '12h') {
314
+ const ampm = h >= 12 ? 'PM' : 'AM';
315
+ let h12 = h % 12;
316
+ if (h12 === 0) h12 = 12;
317
+ return `${h12}:${pad2(mn)} ${ampm}`;
318
+ }
319
+ return `${pad2(h)}:${pad2(mn)}`;
320
+ }
321
+
322
+ /**
323
+ * Compare two parsed times. Missing values compare as 0.
324
+ */
325
+ export function compareTime(a: ParsedTime | null | undefined, b: ParsedTime | null | undefined): -1 | 0 | 1 {
326
+ if (!a || !b) return 0;
327
+ if (a.hours !== b.hours) return a.hours < b.hours ? -1 : 1;
328
+ if (a.minutes !== b.minutes) return a.minutes < b.minutes ? -1 : 1;
329
+ return 0;
330
+ }
331
+
332
+ /**
333
+ * Add `n` minutes to a parsed time, wrapping within 24h. Returns new object.
334
+ */
335
+ export function addMinutes(t: ParsedTime | null | undefined, n: number): ParsedTime | null {
336
+ if (!t) return null;
337
+ let total = t.hours * 60 + t.minutes + n;
338
+ total = ((total % 1440) + 1440) % 1440;
339
+ return { hours: Math.floor(total / 60), minutes: total % 60 };
340
+ }
341
+
342
+ // ── DateTime parse / format ────────────────────────────────────────
343
+
344
+ /**
345
+ * Compute the UTC offset for a given IANA zone on a given date (DST-aware).
346
+ * Returns a string like "+05:30" or "-07:00", or null if unavailable.
347
+ *
348
+ * The reference instant is required so DST is resolved correctly — pass
349
+ * the date the time is meant to represent.
350
+ */
351
+ export function ianaOffset(zone: string | null | undefined, refDate?: Date | null): string | null {
352
+ if (!zone) return null;
353
+ try {
354
+ const ref = refDate instanceof Date ? refDate : new Date();
355
+ const fmt = new Intl.DateTimeFormat('en-US', {
356
+ timeZone: zone,
357
+ timeZoneName: 'shortOffset',
358
+ });
359
+ const parts = fmt.formatToParts(ref);
360
+ const tzPart = parts.find((p) => p.type === 'timeZoneName');
361
+ if (!tzPart) return null;
362
+ let s = tzPart.value;
363
+ if (s === 'GMT' || s === 'UTC') return '+00:00';
364
+ s = s.replace(/^(GMT|UTC)/, '');
365
+ const m = s.match(/^([+-])(\d{1,2})(?::(\d{2}))?$/);
366
+ if (!m) return null;
367
+ const sign = m[1]!;
368
+ const h = m[2]!.padStart(2, '0');
369
+ const mins = m[3] || '00';
370
+ return `${sign}${h}:${mins}`;
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Parse various datetime strings into { date, time, timezone? }.
378
+ *
379
+ * Accepted forms:
380
+ * ISO with offset: "2026-05-04T14:30:00-07:00" (offset becomes timezone)
381
+ * ISO no offset: "2026-05-04T14:30" (no timezone)
382
+ * Space + IANA: "2026-05-04 14:30 America/New_York"
383
+ * Space, no tz: "2026-05-04 14:30"
384
+ * Date only: "2026-05-04" (time defaults to 00:00)
385
+ *
386
+ * For ISO-with-offset inputs, `timezone` holds the offset string (e.g.
387
+ * "-07:00", normalized from "Z"/"±HHMM"). Callers that need an IANA name
388
+ * should pass an object `{ date, time, timezone }` directly (strings).
389
+ */
390
+ export function parseDateTime(s: string | DateTimeParts | null | undefined): ParsedDateTime | null {
391
+ if (s == null || s === '') return null;
392
+ if (typeof s === 'object' && !Array.isArray(s) && !(s instanceof Date)) {
393
+ const date = s.date ? parseYmd(s.date) : null;
394
+ const time = s.time ? parseTime(s.time) : null;
395
+ if (!date) return null;
396
+ return { date, time: time || { hours: 0, minutes: 0 }, timezone: s.timezone || null };
397
+ }
398
+ const str = String(s).trim();
399
+ // Try ISO with offset first: YYYY-MM-DD[T ]HH:MM(:SS(.sss)?)?(Z|±HH:MM|±HHMM)?
400
+ const isoMatch = str.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/);
401
+ if (isoMatch) {
402
+ const [, yy, mm, dd, hh, mn, off] = isoMatch;
403
+ let timezone: string | null = null;
404
+ if (off) {
405
+ if (off === 'Z') timezone = '+00:00';
406
+ else if (/^[+-]\d{4}$/.test(off)) timezone = `${off.slice(0, 3)}:${off.slice(3)}`;
407
+ else timezone = off;
408
+ }
409
+ return {
410
+ date: { y: +yy!, m: +mm!, d: +dd! },
411
+ time: { hours: +hh!, minutes: +mn! },
412
+ timezone,
413
+ };
414
+ }
415
+ // Space-separated forms with optional IANA tz at the end. The separator
416
+ // between date and time is either 'T' at position 10 (after YYYY-MM-DD)
417
+ // or a space — never a 'T' that lives inside a tz token like "UTC".
418
+ let datePart: string;
419
+ let rest: string;
420
+ if (str.length >= 11 && str.charAt(10) === 'T') {
421
+ datePart = str.slice(0, 10);
422
+ rest = str.slice(11);
423
+ } else {
424
+ const sp = str.indexOf(' ');
425
+ if (sp === -1) {
426
+ const date = parseYmd(str);
427
+ return date ? { date, time: { hours: 0, minutes: 0 }, timezone: null } : null;
428
+ }
429
+ datePart = str.slice(0, sp);
430
+ rest = str.slice(sp + 1);
431
+ }
432
+ const date = parseYmd(datePart);
433
+ if (!date) return null;
434
+ const restTrim = rest.trim();
435
+ let timezone: string | null = null;
436
+ let timeStr = restTrim;
437
+ // IANA tz suffix: "Region/City" | "UTC" | "GMT"
438
+ const tzMatch = restTrim.match(/\s+([A-Za-z][A-Za-z_+\-]*\/[A-Za-z][A-Za-z_+\-]*|UTC|GMT)$/);
439
+ if (tzMatch) {
440
+ timezone = tzMatch[1]!;
441
+ timeStr = restTrim.slice(0, tzMatch.index).trim();
442
+ }
443
+ const time = parseTime(timeStr);
444
+ if (!time) return null;
445
+ return { date, time, timezone };
446
+ }
447
+
448
+ /**
449
+ * Format a parsed datetime for display.
450
+ * `displayFormat` uses the same tokens as formatForDisplay for the date part.
451
+ * `timeFormat` is '24h' or '12h' for the time part.
452
+ * If timezone is present, it is appended after the time with a space.
453
+ */
454
+ export function formatDateTimeForDisplay(parsed: ParsedDateTime | null | undefined, displayFormat: string, timeFormat: '24h' | '12h' = '24h'): string {
455
+ if (!parsed) return '';
456
+ const datePart = formatForDisplay(parsed.date, displayFormat);
457
+ const timePart = formatTime(parsed.time, timeFormat);
458
+ let out = `${datePart} ${timePart}`.trim();
459
+ if (parsed.timezone) out += ` ${parsed.timezone}`;
460
+ return out;
461
+ }
462
+
463
+ /**
464
+ * Canonical 24h string: "YYYY-MM-DD HH:MM" or "YYYY-MM-DD HH:MM TZ".
465
+ */
466
+ export function formatDateTime(parsed: ParsedDateTime | null | undefined): string {
467
+ if (!parsed || !parsed.date) return '';
468
+ const d = formatYmd(parsed.date);
469
+ const t = parsed.time ? formatTime(parsed.time, '24h') : '00:00';
470
+ let out = `${d} ${t}`;
471
+ if (parsed.timezone) out += ` ${parsed.timezone}`;
472
+ return out;
473
+ }
474
+
475
+ // ── Today (frozen-aware for tests) ─────────────────────────────────
476
+
477
+ let _frozenToday: ParsedYmd | null = null;
478
+ /** Test hook — set null to clear. */
479
+ export function _setFrozenToday(parsed: ParsedYmd | null): void { _frozenToday = parsed; }
480
+
481
+ export function today(): ParsedYmd {
482
+ if (_frozenToday) return { ..._frozenToday };
483
+ const d = new Date();
484
+ return { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() };
485
+ }
486
+
487
+ // ── Locale-aware names ─────────────────────────────────────────────
488
+
489
+ let _monthsKey: string | null = null;
490
+ let _cachedMonths: string[] | null = null;
491
+ let _weekdaysKey: string | null = null;
492
+ let _cachedWeekdays: string[] | null = null;
493
+
494
+ /** Returns 12 month names for the given locale. */
495
+ export function monthNames(locale = 'en-US', length: 'short' | 'long' = 'short'): string[] {
496
+ // For default English, return our static list — cheap and deterministic in tests.
497
+ if ((!locale || locale === 'en-US' || locale === 'en') && length === 'short') return MONTH_NAMES_SHORT.slice();
498
+ if ((!locale || locale === 'en-US' || locale === 'en') && length === 'long') return MONTH_NAMES_FULL.slice();
499
+
500
+ if (typeof Intl === 'undefined' || !Intl.DateTimeFormat) {
501
+ return length === 'long' ? MONTH_NAMES_FULL.slice() : MONTH_NAMES_SHORT.slice();
502
+ }
503
+ const cacheKey = locale + '/' + length;
504
+ if (_monthsKey === cacheKey && _cachedMonths) return _cachedMonths.slice();
505
+ const fmt = new Intl.DateTimeFormat(locale, { month: length });
506
+ const out: string[] = [];
507
+ for (let m = 0; m < 12; m++) {
508
+ out.push(fmt.format(new Date(2024, m, 15)));
509
+ }
510
+ _monthsKey = cacheKey;
511
+ _cachedMonths = out;
512
+ return out.slice();
513
+ }
514
+
515
+ /** Returns 7 weekday names ordered by `firstDay` (0=Sun,1=Mon,...). */
516
+ export function weekdayNames(locale = 'en-US', firstDay = 1, length: 'short' | 'long' = 'short'): string[] {
517
+ if (typeof Intl === 'undefined' || !Intl.DateTimeFormat) {
518
+ const fallback = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
519
+ return rotate(fallback, firstDay);
520
+ }
521
+ const cacheKey = locale + '/' + firstDay + '/' + length;
522
+ if (_weekdaysKey === cacheKey && _cachedWeekdays) return _cachedWeekdays.slice();
523
+ const fmt = new Intl.DateTimeFormat(locale, { weekday: length });
524
+ const base: string[] = [];
525
+ for (let i = 0; i < 7; i++) {
526
+ // 2024-01-07 was a Sunday; index 0=Sun..6=Sat
527
+ base.push(fmt.format(new Date(2024, 0, 7 + i)));
528
+ }
529
+ const out = rotate(base, firstDay);
530
+ _weekdaysKey = cacheKey;
531
+ _cachedWeekdays = out;
532
+ return out.slice();
533
+ }
534
+
535
+ // ── Temporal detection (epoch vs ISO — the shape sniffer) ──────────
536
+ //
537
+ // django-mojo does NOT speak one datetime shape. Depending on the column
538
+ // and the server's serializer settings a timestamp arrives as epoch
539
+ // SECONDS (DateTimeField), a 'YYYY-MM-DD' string (DateField), a full ISO
540
+ // string (some JSONField payloads, the webhook-secret timestamps), and
541
+ // occasionally epoch MILLISECONDS (anything that round-tripped through a
542
+ // JS producer). web-mojo tolerated all of them at read time
543
+ // (DataFormatter.normalizeEpoch); this is that idea done once, for the
544
+ // whole toolkit — and with the shape REPORTED, so a writer can answer in
545
+ // the same shape instead of flipping the column's wire type on save.
546
+
547
+ export type TemporalKind =
548
+ /** number/numeric-string of epoch seconds — django-mojo's DateTimeField */
549
+ | 'epoch-s'
550
+ /** number/numeric-string of epoch milliseconds — a JS producer's output */
551
+ | 'epoch-ms'
552
+ /** 'YYYY' | 'YYYY-MM' | 'YYYY-MM-DD' — django-mojo's DateField */
553
+ | 'date-string'
554
+ /** anything with a time part: ISO, 'YYYY-MM-DD HH:MM', IANA-tailed */
555
+ | 'datetime-string'
556
+ /** a live Date instance (never a wire shape — callers hand these in) */
557
+ | 'date-object';
558
+
559
+ export interface Temporal {
560
+ /** Milliseconds since the Unix epoch — the one normalized instant. */
561
+ ms: number;
562
+ /** The shape the value ARRIVED in, so writers can answer in kind. */
563
+ kind: TemporalKind;
564
+ }
565
+
566
+ /** 'YYYY' | 'YYYY-MM' | 'YYYY-MM-DD' — no time part. */
567
+ const DATE_ONLY_RE = /^\d{4}(-\d{2}(-\d{2})?)?$/;
568
+ /** A string that is ENTIRELY a number. Deliberately not parseFloat: that
569
+ * reads '2026-06-15' as 2026 and would multiply a date into garbage. */
570
+ const ALL_NUMERIC_RE = /^\s*[+-]?\d+(\.\d+)?\s*$/;
571
+
572
+ /**
573
+ * Seconds-vs-milliseconds boundary. 1e11 SECONDS is the year 5138; 1e11
574
+ * MILLISECONDS is 1973 — so under the boundary the value can only sanely be
575
+ * seconds, and over it, milliseconds. (web-mojo threw on the 1e11–1e12 band;
576
+ * we read it as milliseconds instead: 1973–2001 timestamps are real, year
577
+ * 5138+ ones are not, and a formatter must never throw at render time.)
578
+ */
579
+ const SECONDS_CEILING = 1e11;
580
+
581
+ /** ParsedDateTime → epoch ms, resolving the zone the parse found:
582
+ * explicit offset → exact instant; IANA name → the offset AT that instant
583
+ * (two-pass, so a DST boundary lands on the correct side); no zone → the
584
+ * browser's local wall time (what the user meant in a zoneless picker). */
585
+ export function partsToMs(parsed: ParsedDateTime): number {
586
+ const { date, time, timezone } = parsed;
587
+ const utcGuess = Date.UTC(date.y, date.m - 1, date.d, time.hours, time.minutes);
588
+ if (!timezone) {
589
+ return new Date(date.y, date.m - 1, date.d, time.hours, time.minutes).getTime();
590
+ }
591
+ const direct = offsetToMinutes(timezone);
592
+ if (direct != null) return utcGuess - direct * 60_000;
593
+ const off1 = ianaOffset(timezone, new Date(utcGuess));
594
+ const min1 = off1 != null ? offsetToMinutes(off1) : null;
595
+ if (min1 == null) return utcGuess; // unknown zone — treat wall time as UTC
596
+ const attempt = utcGuess - min1 * 60_000;
597
+ const off2 = ianaOffset(timezone, new Date(attempt));
598
+ const min2 = off2 != null ? offsetToMinutes(off2) : min1;
599
+ return min2 === min1 ? attempt : utcGuess - (min2 ?? min1) * 60_000;
600
+ }
601
+
602
+ /**
603
+ * Sniff any stored temporal value into `{ ms, kind }`, or null when it is
604
+ * not a timestamp at all. Every date-consuming surface in the toolkit reads
605
+ * through this — formatters, pickers, and the form wire boundary — so one
606
+ * row can carry `last_login` epochs beside a `dob` 'YYYY-MM-DD' beside an
607
+ * ISO `created_at` and all three render (and save) correctly.
608
+ *
609
+ * Numeric strings of 4 digits or fewer are NOT read as epochs: '2026' is a
610
+ * canonical year (what a yearpicker holds), not 1970-01-01T00:33:46Z.
611
+ */
612
+ export function detectTemporal(raw: unknown): Temporal | null {
613
+ if (raw == null || raw === '') return null;
614
+
615
+ if (raw instanceof Date) {
616
+ const ms = raw.getTime();
617
+ return Number.isNaN(ms) ? null : { ms, kind: 'date-object' };
618
+ }
619
+
620
+ if (typeof raw === 'number') {
621
+ if (!Number.isFinite(raw)) return null;
622
+ return Math.abs(raw) < SECONDS_CEILING
623
+ ? { ms: raw * 1000, kind: 'epoch-s' }
624
+ : { ms: raw, kind: 'epoch-ms' };
625
+ }
626
+
627
+ if (typeof raw !== 'string') return null;
628
+ const str = raw.trim();
629
+ if (str === '') return null;
630
+
631
+ // Numeric strings: epoch in string clothing (JSONField metadata, query
632
+ // params, anything that crossed a text boundary).
633
+ if (ALL_NUMERIC_RE.test(str) && str.replace(/^[+-]/, '').split('.')[0]!.length >= 5) {
634
+ const n = Number(str);
635
+ if (!Number.isFinite(n)) return null;
636
+ return Math.abs(n) < SECONDS_CEILING
637
+ ? { ms: n * 1000, kind: 'epoch-s' }
638
+ : { ms: n, kind: 'epoch-ms' };
639
+ }
640
+
641
+ // Date-only strings resolve at UTC midnight — the same convention the
642
+ // form wire boundary uses, so 'YYYY-MM-DD' round-trips to its own day.
643
+ if (DATE_ONLY_RE.test(str)) {
644
+ const p = parseYmd(str.length === 4 ? `${str}-01-01` : str.length === 7 ? `${str}-01` : str);
645
+ if (!p) return null;
646
+ return { ms: Date.UTC(p.y, p.m - 1, p.d), kind: 'date-string' };
647
+ }
648
+
649
+ const parsed = parseDateTime(str);
650
+ if (parsed) return { ms: partsToMs(parsed), kind: 'datetime-string' };
651
+
652
+ // Last resort: whatever else Date can read (RFC 2822, 'Aug 5, 2026'…).
653
+ const loose = Date.parse(str);
654
+ return Number.isFinite(loose) ? { ms: loose, kind: 'datetime-string' } : null;
655
+ }
656
+
657
+ /** `detectTemporal` → a Date, or null. The one parse every formatter uses. */
658
+ export function toDateSmart(raw: unknown): Date | null {
659
+ const t = detectTemporal(raw);
660
+ if (!t) return null;
661
+ const d = new Date(t.ms);
662
+ return Number.isNaN(d.getTime()) ? null : d;
663
+ }
664
+
665
+ // ── Internal ───────────────────────────────────────────────────────
666
+
667
+ /** '±HH:MM' → signed minutes, or null when malformed. */
668
+ function offsetToMinutes(offset: string): number | null {
669
+ const m = offset.match(/^([+-])(\d{2}):(\d{2})$/);
670
+ if (!m) return null;
671
+ return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
672
+ }
673
+
674
+ function pad2(n: number): string { return n < 10 ? '0' + n : String(n); }
675
+
676
+ function rotate<T>(arr: T[], n: number): T[] {
677
+ const k = ((n % arr.length) + arr.length) % arr.length;
678
+ return arr.slice(k).concat(arr.slice(0, k));
679
+ }