@xh/hoist 87.1.1 → 87.3.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 (270) hide show
  1. package/.npmignore +39 -0
  2. package/CHANGELOG.md +108 -20
  3. package/admin/AdminUtils.ts +64 -0
  4. package/admin/App.scss +88 -0
  5. package/admin/columns/UserData.ts +30 -3
  6. package/admin/detail/RestDetailModel.ts +64 -0
  7. package/admin/detail/RestDetailPanel.ts +106 -0
  8. package/admin/tabs/activity/tracking/ActivityTrackingModel.ts +27 -94
  9. package/admin/tabs/activity/tracking/ActivityTrackingPanel.ts +23 -60
  10. package/admin/tabs/activity/tracking/detail/ActivityDetailView.ts +1 -2
  11. package/admin/tabs/general/alertBanner/AlertBannerPanel.ts +2 -13
  12. package/admin/tabs/general/config/ConfigDetailPanel.ts +72 -0
  13. package/admin/tabs/general/config/ConfigPanel.ts +41 -53
  14. package/admin/tabs/general/config/ConfigPanelModel.ts +3 -0
  15. package/admin/tabs/general/config/ConfigValue.scss +14 -0
  16. package/admin/tabs/general/config/ConfigValue.ts +104 -65
  17. package/admin/tabs/userData/jsonblob/JsonBlobDetailPanel.ts +93 -0
  18. package/admin/tabs/userData/jsonblob/JsonBlobModel.ts +4 -0
  19. package/admin/tabs/userData/jsonblob/JsonBlobPanel.ts +2 -0
  20. package/admin/tabs/userData/prefs/UserPreferenceDetailPanel.ts +62 -0
  21. package/admin/tabs/userData/prefs/UserPreferenceModel.ts +10 -3
  22. package/admin/tabs/userData/prefs/UserPreferencePanel.ts +3 -1
  23. package/admin/tabs/userData/roles/details/RoleDetails.scss +2 -19
  24. package/admin/tabs/userData/roles/details/RoleDetails.ts +13 -13
  25. package/appcontainer/AboutDialogModel.ts +1 -0
  26. package/appcontainer/AppContainerModel.ts +3 -0
  27. package/appcontainer/AppStateModel.ts +1 -0
  28. package/appcontainer/BannerSourceModel.ts +1 -0
  29. package/appcontainer/ChangelogDialogModel.ts +1 -0
  30. package/appcontainer/ColChooserOptionsModel.ts +1 -0
  31. package/appcontainer/ExceptionDialogModel.ts +1 -0
  32. package/appcontainer/FeedbackDialogModel.ts +1 -0
  33. package/appcontainer/ImpersonationBarModel.ts +1 -0
  34. package/appcontainer/MessageSourceModel.ts +1 -0
  35. package/appcontainer/OptionsDialogModel.ts +1 -0
  36. package/appcontainer/PageStateModel.ts +1 -0
  37. package/appcontainer/RouterModel.ts +2 -0
  38. package/appcontainer/SizingModeModel.ts +1 -0
  39. package/appcontainer/ThemeModel.ts +1 -0
  40. package/appcontainer/ToastSourceModel.ts +1 -0
  41. package/appcontainer/UserAgentModel.ts +1 -0
  42. package/appcontainer/ViewportSizeModel.ts +1 -0
  43. package/build/types/admin/AdminUtils.d.ts +29 -0
  44. package/build/types/admin/columns/UserData.d.ts +3 -0
  45. package/build/types/admin/detail/RestDetailModel.d.ts +20 -0
  46. package/build/types/admin/detail/RestDetailPanel.d.ts +34 -0
  47. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +5 -17
  48. package/build/types/admin/tabs/general/config/ConfigDetailPanel.d.ts +7 -0
  49. package/build/types/admin/tabs/general/config/ConfigValue.d.ts +10 -4
  50. package/build/types/admin/tabs/userData/jsonblob/JsonBlobDetailPanel.d.ts +3 -0
  51. package/build/types/admin/tabs/userData/prefs/UserPreferenceDetailPanel.d.ts +3 -0
  52. package/build/types/admin/tabs/userData/prefs/UserPreferenceModel.d.ts +3 -0
  53. package/build/types/appcontainer/AboutDialogModel.d.ts +1 -0
  54. package/build/types/appcontainer/AppContainerModel.d.ts +1 -0
  55. package/build/types/appcontainer/AppStateModel.d.ts +1 -0
  56. package/build/types/appcontainer/BannerSourceModel.d.ts +1 -0
  57. package/build/types/appcontainer/ChangelogDialogModel.d.ts +1 -0
  58. package/build/types/appcontainer/ColChooserOptionsModel.d.ts +1 -0
  59. package/build/types/appcontainer/ExceptionDialogModel.d.ts +1 -0
  60. package/build/types/appcontainer/FeedbackDialogModel.d.ts +1 -0
  61. package/build/types/appcontainer/ImpersonationBarModel.d.ts +1 -0
  62. package/build/types/appcontainer/MessageSourceModel.d.ts +1 -0
  63. package/build/types/appcontainer/OptionsDialogModel.d.ts +1 -0
  64. package/build/types/appcontainer/PageStateModel.d.ts +1 -0
  65. package/build/types/appcontainer/RouterModel.d.ts +1 -0
  66. package/build/types/appcontainer/SizingModeModel.d.ts +1 -0
  67. package/build/types/appcontainer/ThemeModel.d.ts +1 -0
  68. package/build/types/appcontainer/ToastSourceModel.d.ts +1 -0
  69. package/build/types/appcontainer/UserAgentModel.d.ts +1 -0
  70. package/build/types/appcontainer/ViewportSizeModel.d.ts +1 -0
  71. package/build/types/cmp/ag-grid/AgGridModel.d.ts +3 -1
  72. package/build/types/cmp/card/Card.d.ts +2 -2
  73. package/build/types/cmp/chart/ChartModel.d.ts +2 -0
  74. package/build/types/cmp/dataview/DataViewModel.d.ts +2 -0
  75. package/build/types/cmp/daterange/DateRangePickerModel.d.ts +266 -0
  76. package/build/types/cmp/daterange/DateRangePresets.d.ts +26 -0
  77. package/build/types/cmp/daterange/DateRangeUtils.d.ts +96 -0
  78. package/build/types/cmp/daterange/Types.d.ts +152 -0
  79. package/build/types/cmp/daterange/index.d.ts +4 -0
  80. package/build/types/cmp/filter/FilterChooserModel.d.ts +3 -1
  81. package/build/types/cmp/form/FormModel.d.ts +3 -1
  82. package/build/types/cmp/grid/GridModel.d.ts +2 -0
  83. package/build/types/cmp/grid/Types.d.ts +2 -0
  84. package/build/types/cmp/grid/filter/GridFilterModel.d.ts +1 -1
  85. package/build/types/cmp/grid/impl/ColumnWidthCalculator.d.ts +1 -1
  86. package/build/types/cmp/grouping/GroupingChooserModel.d.ts +3 -1
  87. package/build/types/cmp/input/HoistInputProps.d.ts +49 -2
  88. package/build/types/cmp/tab/TabContainerModel.d.ts +3 -1
  89. package/build/types/cmp/tab/TabModel.d.ts +3 -1
  90. package/build/types/cmp/treemap/TreeMapModel.d.ts +2 -0
  91. package/build/types/cmp/viewmanager/ViewManagerModel.d.ts +2 -0
  92. package/build/types/cmp/zoneGrid/ZoneGridModel.d.ts +2 -0
  93. package/build/types/core/HoistBase.d.ts +7 -0
  94. package/build/types/core/HoistProps.d.ts +26 -1
  95. package/build/types/core/model/RootRefreshContextModel.d.ts +1 -0
  96. package/build/types/core/types/Telemetry.d.ts +1 -1
  97. package/build/types/data/Store.d.ts +3 -1
  98. package/build/types/data/StoreSelectionModel.d.ts +3 -1
  99. package/build/types/data/cube/Cube.d.ts +5 -2
  100. package/build/types/data/cube/View.d.ts +2 -0
  101. package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +3 -0
  102. package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +3 -0
  103. package/build/types/desktop/cmp/button/Button.d.ts +2 -2
  104. package/build/types/desktop/cmp/button/ButtonGroup.d.ts +2 -2
  105. package/build/types/desktop/cmp/dash/DashConfig.d.ts +2 -0
  106. package/build/types/desktop/cmp/dash/DashViewModel.d.ts +3 -1
  107. package/build/types/desktop/cmp/dash/canvas/DashCanvasModel.d.ts +1 -1
  108. package/build/types/desktop/cmp/dash/container/DashContainerModel.d.ts +1 -1
  109. package/build/types/desktop/cmp/daterange/DateRangePicker.d.ts +66 -0
  110. package/build/types/desktop/cmp/daterange/impl/CustomTab.d.ts +3 -0
  111. package/build/types/desktop/cmp/daterange/impl/DateRangePickerLocalModel.d.ts +121 -0
  112. package/build/types/desktop/cmp/daterange/impl/PeriodTab.d.ts +2 -0
  113. package/build/types/desktop/cmp/daterange/impl/PresetsTab.d.ts +3 -0
  114. package/build/types/desktop/cmp/daterange/impl/RelativeTab.d.ts +3 -0
  115. package/build/types/desktop/cmp/daterange/impl/TabUtils.d.ts +21 -0
  116. package/build/types/desktop/cmp/daterange/index.d.ts +2 -0
  117. package/build/types/desktop/cmp/dock/DockContainerModel.d.ts +3 -1
  118. package/build/types/desktop/cmp/dock/DockViewModel.d.ts +3 -1
  119. package/build/types/desktop/cmp/grouping/GroupingChooser.d.ts +4 -1
  120. package/build/types/desktop/cmp/input/IntentInput.d.ts +38 -0
  121. package/build/types/desktop/cmp/input/NumberInput.d.ts +2 -2
  122. package/build/types/desktop/cmp/input/SegmentedControl.d.ts +25 -4
  123. package/build/types/desktop/cmp/input/TextArea.d.ts +2 -2
  124. package/build/types/desktop/cmp/input/TextInput.d.ts +2 -2
  125. package/build/types/desktop/cmp/input/index.d.ts +1 -0
  126. package/build/types/desktop/cmp/leftrightchooser/LeftRightChooserModel.d.ts +3 -1
  127. package/build/types/desktop/cmp/panel/PanelModel.d.ts +3 -1
  128. package/build/types/desktop/cmp/rest/RestGridModel.d.ts +1 -1
  129. package/build/types/desktop/cmp/tab/impl/TabContainer.d.ts +1 -1
  130. package/build/types/desktop/hooks/UseHotkeys.d.ts +4 -3
  131. package/build/types/icon/Icon.d.ts +3 -0
  132. package/build/types/inspector/impl/InspectorUtils.d.ts +3 -0
  133. package/build/types/inspector/instances/InstancesModel.d.ts +4 -0
  134. package/build/types/kit/react-day-picker/index.d.ts +3 -3
  135. package/build/types/mobile/cmp/button/Button.d.ts +2 -2
  136. package/build/types/mobile/cmp/input/NumberInput.d.ts +2 -2
  137. package/build/types/mobile/cmp/input/SegmentedControl.d.ts +18 -4
  138. package/build/types/mobile/cmp/input/TextArea.d.ts +2 -2
  139. package/build/types/mobile/cmp/input/TextInput.d.ts +2 -2
  140. package/build/types/mobile/cmp/navigator/NavigatorModel.d.ts +8 -2
  141. package/build/types/mobile/cmp/navigator/PageModel.d.ts +3 -1
  142. package/build/types/mobile/cmp/tab/impl/TabContainer.d.ts +1 -1
  143. package/build/types/svc/InspectorService.d.ts +1 -0
  144. package/build/types/utils/js/LangUtils.d.ts +24 -5
  145. package/cmp/README.md +1 -0
  146. package/cmp/ag-grid/AgGridModel.ts +5 -0
  147. package/cmp/badge/Badge.ts +5 -3
  148. package/cmp/card/Card.ts +4 -3
  149. package/cmp/chart/ChartModel.ts +11 -1
  150. package/cmp/clock/Clock.ts +1 -1
  151. package/cmp/dataview/DataViewModel.ts +7 -1
  152. package/cmp/daterange/DateRangePickerModel.ts +628 -0
  153. package/cmp/daterange/DateRangePresets.ts +274 -0
  154. package/cmp/daterange/DateRangeUtils.ts +557 -0
  155. package/cmp/daterange/README.md +434 -0
  156. package/cmp/daterange/Types.ts +203 -0
  157. package/cmp/daterange/index.ts +10 -0
  158. package/cmp/filter/FilterChooserModel.ts +7 -4
  159. package/cmp/form/FormModel.ts +6 -0
  160. package/cmp/form/README.md +1 -0
  161. package/cmp/grid/GridModel.ts +24 -4
  162. package/cmp/grid/Types.ts +3 -0
  163. package/cmp/grid/filter/GridFilterModel.ts +3 -1
  164. package/cmp/grid/impl/ColumnWidthCalculator.ts +5 -10
  165. package/cmp/grouping/GroupingChooserModel.ts +6 -1
  166. package/cmp/input/HoistInputProps.ts +50 -2
  167. package/cmp/input/README.md +42 -0
  168. package/cmp/layout/Box.ts +3 -2
  169. package/cmp/tab/TabContainerModel.ts +9 -1
  170. package/cmp/tab/TabModel.ts +5 -0
  171. package/cmp/treemap/SplitTreeMapModel.ts +12 -2
  172. package/cmp/treemap/TreeMapModel.ts +6 -1
  173. package/cmp/viewmanager/ViewManagerModel.ts +6 -1
  174. package/cmp/zoneGrid/ZoneGridModel.ts +7 -0
  175. package/core/HoistBase.ts +11 -0
  176. package/core/HoistProps.ts +27 -0
  177. package/core/README.md +46 -0
  178. package/core/impl/InstallServices.ts +5 -1
  179. package/core/model/RootRefreshContextModel.ts +2 -0
  180. package/core/types/Telemetry.ts +1 -1
  181. package/data/README.md +3 -1
  182. package/data/Store.ts +7 -0
  183. package/data/StoreSelectionModel.ts +5 -1
  184. package/data/cube/Cube.ts +12 -3
  185. package/data/cube/View.ts +5 -1
  186. package/desktop/README.md +2 -0
  187. package/desktop/appcontainer/LoginPanel.ts +2 -0
  188. package/desktop/cmp/button/Button.scss +71 -42
  189. package/desktop/cmp/button/Button.ts +10 -1
  190. package/desktop/cmp/button/ButtonGroup.ts +4 -1
  191. package/desktop/cmp/button/grid/ColChooserButton.ts +9 -1
  192. package/desktop/cmp/button/zoneGrid/ZoneMapperButton.ts +1 -0
  193. package/desktop/cmp/dash/DashConfig.ts +3 -0
  194. package/desktop/cmp/dash/DashViewModel.ts +14 -1
  195. package/desktop/cmp/dash/canvas/DashCanvasModel.ts +3 -1
  196. package/desktop/cmp/dash/container/DashContainerModel.ts +3 -1
  197. package/desktop/cmp/daterange/DateRangePicker.scss +577 -0
  198. package/desktop/cmp/daterange/DateRangePicker.ts +357 -0
  199. package/desktop/cmp/daterange/impl/CustomTab.ts +165 -0
  200. package/desktop/cmp/daterange/impl/DateRangePickerLocalModel.ts +482 -0
  201. package/desktop/cmp/daterange/impl/PeriodTab.ts +138 -0
  202. package/desktop/cmp/daterange/impl/PresetsTab.ts +58 -0
  203. package/desktop/cmp/daterange/impl/RelativeTab.ts +108 -0
  204. package/desktop/cmp/daterange/impl/TabUtils.ts +34 -0
  205. package/desktop/cmp/daterange/index.ts +8 -0
  206. package/desktop/cmp/dock/DockContainerModel.ts +6 -1
  207. package/desktop/cmp/dock/DockViewModel.ts +6 -1
  208. package/desktop/cmp/filechooser/FileChooser.ts +4 -0
  209. package/desktop/cmp/form/FormField.ts +1 -0
  210. package/desktop/cmp/grid/editors/DateEditor.ts +11 -2
  211. package/desktop/cmp/grouping/GroupingChooser.ts +8 -2
  212. package/desktop/cmp/input/Checkbox.ts +1 -0
  213. package/desktop/cmp/input/CodeInput.ts +1 -0
  214. package/desktop/cmp/input/DateInput.scss +1 -1
  215. package/desktop/cmp/input/DateInput.ts +1 -0
  216. package/desktop/cmp/input/IntentInput.scss +202 -0
  217. package/desktop/cmp/input/IntentInput.ts +213 -0
  218. package/desktop/cmp/input/NumberInput.ts +11 -2
  219. package/desktop/cmp/input/Picker.ts +1 -0
  220. package/desktop/cmp/input/RadioInput.ts +2 -1
  221. package/desktop/cmp/input/SegmentedControl.scss +57 -5
  222. package/desktop/cmp/input/SegmentedControl.ts +66 -11
  223. package/desktop/cmp/input/Select.ts +9 -7
  224. package/desktop/cmp/input/Slider.ts +2 -0
  225. package/desktop/cmp/input/SwitchInput.ts +1 -0
  226. package/desktop/cmp/input/TextArea.ts +11 -2
  227. package/desktop/cmp/input/TextInput.ts +11 -2
  228. package/desktop/cmp/input/index.ts +1 -0
  229. package/desktop/cmp/leftrightchooser/LeftRightChooserModel.ts +5 -0
  230. package/desktop/cmp/panel/Panel.ts +8 -5
  231. package/desktop/cmp/panel/PanelModel.ts +8 -0
  232. package/desktop/cmp/panel/impl/ResizeContainer.ts +2 -1
  233. package/desktop/cmp/rest/RestGridModel.ts +4 -0
  234. package/desktop/cmp/tab/impl/TabContainer.ts +2 -0
  235. package/desktop/cmp/toolbar/Toolbar.scss +20 -6
  236. package/desktop/hooks/UseHotkeys.ts +41 -12
  237. package/docs/README.md +2 -0
  238. package/docs/build-and-publish.md +23 -7
  239. package/docs/doc-registry.json +8 -0
  240. package/docs/version-compatibility.md +2 -0
  241. package/icon/Icon.ts +9 -0
  242. package/icon/index.ts +24 -0
  243. package/inspector/README.md +8 -2
  244. package/inspector/impl/InspectorUtils.ts +12 -0
  245. package/inspector/instances/DiagnosticsModel.ts +3 -2
  246. package/inspector/instances/InstancesModel.ts +81 -14
  247. package/inspector/instances/InstancesPanel.ts +6 -0
  248. package/kit/react-day-picker/index.ts +3 -3
  249. package/mobile/appcontainer/LoginPanel.ts +2 -0
  250. package/mobile/cmp/button/Button.ts +16 -3
  251. package/mobile/cmp/form/FormField.ts +1 -0
  252. package/mobile/cmp/input/Checkbox.ts +1 -0
  253. package/mobile/cmp/input/DateInput.ts +1 -0
  254. package/mobile/cmp/input/Label.ts +13 -8
  255. package/mobile/cmp/input/NumberInput.ts +11 -2
  256. package/mobile/cmp/input/SearchInput.ts +1 -0
  257. package/mobile/cmp/input/SegmentedControl.scss +36 -6
  258. package/mobile/cmp/input/SegmentedControl.ts +37 -11
  259. package/mobile/cmp/input/Select.ts +2 -0
  260. package/mobile/cmp/input/SwitchInput.ts +1 -0
  261. package/mobile/cmp/input/TextArea.ts +11 -2
  262. package/mobile/cmp/input/TextInput.ts +11 -2
  263. package/mobile/cmp/navigator/NavigatorModel.ts +20 -8
  264. package/mobile/cmp/navigator/PageModel.ts +6 -1
  265. package/mobile/cmp/tab/impl/TabContainer.ts +9 -1
  266. package/package.json +19 -19
  267. package/styles/vars.scss +32 -2
  268. package/svc/InspectorService.ts +2 -0
  269. package/svc/TraceService.ts +5 -2
  270. package/utils/js/LangUtils.ts +37 -5
@@ -0,0 +1,557 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2026 Extremely Heavy Industries Inc.
6
+ */
7
+ import {LocalDate} from '@xh/hoist/utils/datetime';
8
+ import {throwIf} from '@xh/hoist/utils/js';
9
+ import {isFinite, isFunction, isPlainObject, isString} from 'lodash';
10
+ import type {
11
+ DateRangeContext,
12
+ DateRangeFormat,
13
+ DateRangePickerTab,
14
+ DateRangePreset,
15
+ DateRangeSelection,
16
+ DateRangeUnit,
17
+ LocalDateRange,
18
+ MonthDateRangeSelection,
19
+ PresetDateRangeSelection,
20
+ QuarterDateRangeSelection,
21
+ RelativeDateRangeSelection,
22
+ ResolvedDateRange,
23
+ YearDateRangeSelection
24
+ } from './Types';
25
+
26
+ /** All picker tabs, in their default display order. */
27
+ export const DATE_RANGE_PICKER_TABS: DateRangePickerTab[] = [
28
+ 'presets',
29
+ 'relative',
30
+ 'period',
31
+ 'custom'
32
+ ];
33
+
34
+ /** Units offered on the Relative tab's unit control, in display order. */
35
+ export const DATE_RANGE_UNITS: DateRangeUnit[] = ['days', 'weeks', 'months', 'quarters', 'years'];
36
+
37
+ /** Largest `count` accepted for a relative selection. */
38
+ export const MAX_RELATIVE_COUNT = 999;
39
+
40
+ /** Furthest a preset or relative selection can be stepped from its natural range, either way. */
41
+ export const MAX_STEP_OFFSET = 9999;
42
+
43
+ /** The calendar unit of each pinned-period selection kind. */
44
+ const PERIOD_UNITS = {month: 'months', quarter: 'quarters', year: 'years'} as const;
45
+
46
+ /** Year bounds for month and year selections - shared by validation and picker navigation. */
47
+ export const MIN_SELECTION_YEAR = 1900;
48
+ export const MAX_SELECTION_YEAR = 9999;
49
+
50
+ /**
51
+ * Resolve a selection to its current range, and to the comparable range immediately before it.
52
+ * Throws if the selection is a preset unknown to the context - validate with
53
+ * {@link parseDateRangeSelection} first for untrusted input.
54
+ */
55
+ export function resolveDateRange(
56
+ sel: DateRangeSelection,
57
+ ctx: DateRangeContext
58
+ ): ResolvedDateRange {
59
+ const {anchorDate, minDate, maxDate} = ctx;
60
+
61
+ switch (sel.kind) {
62
+ case 'preset': {
63
+ const preset = getPreset(sel.token, ctx);
64
+ return applyOffset(
65
+ preset.resolve(ctx),
66
+ range => resolvePresetPrior(preset, range, ctx),
67
+ range => resolvePresetNext(preset, range, ctx),
68
+ sel.offset
69
+ );
70
+ }
71
+
72
+ case 'relative': {
73
+ const {unit} = sel,
74
+ count = Math.max(1, sel.count),
75
+ // Days need no special case: a day is already a calendar boundary, so both
76
+ // expressions below reduce to `anchorDate - count + 1` for that unit.
77
+ start = sel.snap
78
+ ? // Calendar-aligned - the current (partial) unit counts as one.
79
+ anchorDate.startOf(unit).subtract(count - 1, unit)
80
+ : // Rolling window of exactly `count` units ending on the anchor date.
81
+ anchorDate.subtract(count, unit).nextDay();
82
+ return applyOffset(
83
+ {start, end: anchorDate},
84
+ range => shiftRelative(sel, range, -1, ctx),
85
+ range => shiftRelative(sel, range, 1, ctx),
86
+ sel.offset
87
+ );
88
+ }
89
+
90
+ case 'month':
91
+ case 'quarter':
92
+ case 'year':
93
+ return resolveCalendarUnit(periodStart(sel), PERIOD_UNITS[sel.kind], minDate, maxDate);
94
+
95
+ case 'custom': {
96
+ const current = {start: LocalDate.get(sel.start), end: LocalDate.get(sel.end)};
97
+ return {current, prior: shiftByDuration(current, -1)};
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Step a natural range `|offset|` periods - back via its prior-range logic, forward via its
104
+ * next-range logic - then compute the prior of where it lands. Stops early if the range becomes
105
+ * unbounded; `stepDateRangeSelection` refuses offsets the range cannot reach.
106
+ */
107
+ function applyOffset(
108
+ current: LocalDateRange,
109
+ priorFn: (range: LocalDateRange) => LocalDateRange | null,
110
+ nextFn: (range: LocalDateRange) => LocalDateRange | null,
111
+ offset: number = 0
112
+ ): ResolvedDateRange {
113
+ const stepFn = offset < 0 ? priorFn : nextFn;
114
+ for (let i = 0; i < Math.abs(offset); i++) {
115
+ const stepped = stepFn(current);
116
+ if (!stepped) break;
117
+ current = stepped;
118
+ }
119
+ return {current, prior: priorFn(current)};
120
+ }
121
+
122
+ function resolvePresetPrior(
123
+ preset: DateRangePreset,
124
+ range: LocalDateRange,
125
+ ctx: DateRangeContext
126
+ ): LocalDateRange | null {
127
+ return preset.resolvePrior ? preset.resolvePrior(range, ctx) : shiftByDuration(range, -1);
128
+ }
129
+
130
+ function resolvePresetNext(
131
+ preset: DateRangePreset,
132
+ range: LocalDateRange,
133
+ ctx: DateRangeContext
134
+ ): LocalDateRange | null {
135
+ return preset.resolveNext ? preset.resolveNext(range, ctx) : shiftByDuration(range, 1);
136
+ }
137
+
138
+ /**
139
+ * Compare like against like: a lookback in weeks or larger units steps by the same units,
140
+ * matching the equivalent presets - so 3 months ending May 31 compares against 3 months ending
141
+ * Feb 28, not 92 days. Days keep an equal number of days, with a single day walking by business
142
+ * day when the model is in `businessDayMode`. `dir` is -1 for the prior range, 1 for the next.
143
+ */
144
+ function shiftRelative(
145
+ sel: RelativeDateRangeSelection,
146
+ {start, end}: LocalDateRange,
147
+ dir: 1 | -1,
148
+ ctx: DateRangeContext
149
+ ): LocalDateRange {
150
+ const {unit} = sel,
151
+ count = Math.max(1, sel.count) * dir;
152
+ if (unit !== 'days') return {start: start.add(count, unit), end: end.add(count, unit)};
153
+ if (Math.abs(count) === 1 && ctx.businessDayMode) {
154
+ return singleDay(dir < 0 ? previousBusinessDay(start, ctx) : nextBusinessDay(start, ctx));
155
+ }
156
+ return shiftByDuration({start, end}, dir);
157
+ }
158
+
159
+ /**
160
+ * A full calendar month, quarter or year, clamped to `maxDate` (and `minDate`) when those fall
161
+ * within it - so the current month covers the 1st through the anchor. A period entirely beyond
162
+ * `maxDate` (e.g. a persisted pick from a user with a later anchor) keeps its natural bounds
163
+ * rather than producing an inverted range.
164
+ */
165
+ function resolveCalendarUnit(
166
+ naturalStart: LocalDate,
167
+ unit: PeriodUnit,
168
+ minDate: LocalDate,
169
+ maxDate: LocalDate
170
+ ): ResolvedDateRange {
171
+ const naturalEnd = naturalStart.endOf(unit),
172
+ start = minDate && minDate > naturalStart && minDate <= naturalEnd ? minDate : naturalStart,
173
+ end = maxDate && maxDate >= naturalStart && maxDate < naturalEnd ? maxDate : naturalEnd,
174
+ priorStart = naturalStart.subtract(1, unit);
175
+
176
+ return {
177
+ current: {start, end},
178
+ prior:
179
+ end === naturalEnd
180
+ ? {start: priorStart, end: priorStart.endOf(unit)}
181
+ : // Clamped to a partial period - compare against the same span one unit earlier.
182
+ {start: priorStart, end: end.subtract(1, unit)}
183
+ };
184
+ }
185
+
186
+ /**
187
+ * The adjacent range of equal duration in days - preceding for `dir` -1, following for 1 - or
188
+ * null if `current` is unbounded.
189
+ */
190
+ function shiftByDuration({start, end}: LocalDateRange, dir: 1 | -1): LocalDateRange | null {
191
+ if (!start || !end) return null;
192
+ const days = (end.diff(start, 'days') + 1) * dir;
193
+ return {start: start.add(days, 'days'), end: end.add(days, 'days')};
194
+ }
195
+
196
+ /** A range of the one given day. */
197
+ export function singleDay(day: LocalDate): LocalDateRange {
198
+ return {start: day, end: day};
199
+ }
200
+
201
+ /**
202
+ * The nearest business day strictly before `date`, per the context's `isBusinessDay`. Bounded, to
203
+ * guard against a test that never returns true.
204
+ */
205
+ export function previousBusinessDay(date: LocalDate, ctx: BusinessDayContext): LocalDate {
206
+ let ret = date.previousDay();
207
+ for (let i = 0; i < 366 && !ctx.isBusinessDay(ret); i++) ret = ret.previousDay();
208
+ return ret;
209
+ }
210
+
211
+ /** The nearest business day strictly after `date`, per the context's `isBusinessDay`. Bounded. */
212
+ export function nextBusinessDay(date: LocalDate, ctx: BusinessDayContext): LocalDate {
213
+ let ret = date.nextDay();
214
+ for (let i = 0; i < 366 && !ctx.isBusinessDay(ret); i++) ret = ret.nextDay();
215
+ return ret;
216
+ }
217
+
218
+ /** `date` itself if it is a business day, else the nearest business day before it. */
219
+ export function businessDayOnOrBefore(date: LocalDate, ctx: BusinessDayContext): LocalDate {
220
+ return ctx.isBusinessDay(date) ? date : previousBusinessDay(date, ctx);
221
+ }
222
+
223
+ type BusinessDayContext = Pick<DateRangeContext, 'isBusinessDay'>;
224
+
225
+ /**
226
+ * The day before `date` - the previous business day in `businessDayMode`, else the previous
227
+ * calendar day. The step taken by single-day presets and their prior ranges.
228
+ */
229
+ export function previousDayInMode(date: LocalDate, ctx: DateRangeContext): LocalDate {
230
+ return ctx.businessDayMode ? previousBusinessDay(date, ctx) : date.previousDay();
231
+ }
232
+
233
+ /** The day after `date` - the next business day in `businessDayMode`, else the next calendar day. */
234
+ export function nextDayInMode(date: LocalDate, ctx: DateRangeContext): LocalDate {
235
+ return ctx.businessDayMode ? nextBusinessDay(date, ctx) : date.nextDay();
236
+ }
237
+
238
+ /**
239
+ * Validate and normalize a raw (e.g. persisted or app-supplied) value into a
240
+ * {@link DateRangeSelection}. Accepts a bare preset token string as shorthand for a preset
241
+ * selection. Returns null for anything unrecognized, out of bounds, or naming a preset not in
242
+ * `presets` - callers should substitute their default.
243
+ */
244
+ export function parseDateRangeSelection(
245
+ raw: unknown,
246
+ presets: Record<string, DateRangePreset>
247
+ ): DateRangeSelection | null {
248
+ if (isString(raw)) return presets[raw] ? {kind: 'preset', token: raw} : null;
249
+ if (!isPlainObject(raw)) return null;
250
+
251
+ const obj = raw as any;
252
+ switch (obj.kind) {
253
+ case 'preset': {
254
+ const offset = parseOffset(obj.offset);
255
+ return isString(obj.token) && presets[obj.token] && offset != null
256
+ ? withOffset({kind: 'preset', token: obj.token}, offset)
257
+ : null;
258
+ }
259
+
260
+ case 'relative': {
261
+ const count = Math.round(obj.count),
262
+ offset = parseOffset(obj.offset),
263
+ {unit} = obj;
264
+ return isFinite(count) &&
265
+ count >= 1 &&
266
+ count <= MAX_RELATIVE_COUNT &&
267
+ DATE_RANGE_UNITS.includes(unit) &&
268
+ offset != null
269
+ ? // Snap has no meaning at day grain - normalize it away so values compare equal.
270
+ withOffset(
271
+ {kind: 'relative', count, unit, snap: unit !== 'days' && obj.snap === true},
272
+ offset
273
+ )
274
+ : null;
275
+ }
276
+
277
+ case 'month': {
278
+ const {year, month} = obj;
279
+ return isValidYear(year) && Number.isInteger(month) && month >= 1 && month <= 12
280
+ ? {kind: 'month', year, month}
281
+ : null;
282
+ }
283
+
284
+ case 'quarter': {
285
+ const {year, quarter} = obj;
286
+ return isValidYear(year) && Number.isInteger(quarter) && quarter >= 1 && quarter <= 4
287
+ ? {kind: 'quarter', year, quarter}
288
+ : null;
289
+ }
290
+
291
+ case 'year':
292
+ return isValidYear(obj.year) ? {kind: 'year', year: obj.year} : null;
293
+
294
+ case 'custom': {
295
+ const start = parseIsoDate(obj.start),
296
+ end = parseIsoDate(obj.end);
297
+ if (!start || !end) return null;
298
+ return start <= end
299
+ ? {kind: 'custom', start: start.isoString, end: end.isoString}
300
+ : {kind: 'custom', start: end.isoString, end: start.isoString};
301
+ }
302
+
303
+ default:
304
+ return null;
305
+ }
306
+ }
307
+
308
+ /** A missing offset is zero. Anything else must be an integer within ±MAX_STEP_OFFSET. */
309
+ function parseOffset(raw: unknown): number | null {
310
+ if (raw == null) return 0;
311
+ return Number.isInteger(raw) && Math.abs(raw as number) <= MAX_STEP_OFFSET
312
+ ? (raw as number)
313
+ : null;
314
+ }
315
+
316
+ /** The selection at the given offset, in normalized form - `offset` present only when non-zero. */
317
+ function withOffset<T extends PresetDateRangeSelection | RelativeDateRangeSelection>(
318
+ sel: T,
319
+ offset: number
320
+ ): T {
321
+ const {offset: _, ...rest} = sel;
322
+ return (offset ? {...rest, offset} : rest) as T;
323
+ }
324
+
325
+ function isValidYear(year: unknown): year is number {
326
+ return (
327
+ Number.isInteger(year) &&
328
+ (year as number) >= MIN_SELECTION_YEAR &&
329
+ (year as number) <= MAX_SELECTION_YEAR
330
+ );
331
+ }
332
+
333
+ function parseIsoDate(s: unknown): LocalDate | null {
334
+ if (!isString(s) || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
335
+ try {
336
+ return LocalDate.get(s);
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Short label for a selection, suitable for the picker trigger - e.g. `MTD`, `Prev 6 Months`,
344
+ * `Aug 2026`, `2025`, or `Custom`.
345
+ *
346
+ * Month and year picks read as the period they name - `Sep 2026`, `2026` - even when clamped to
347
+ * the anchor date, with the trigger's dates showing the clamp. `MTD` and `YTD` are reserved for
348
+ * the presets, which are live and step to the same partial span a unit earlier, where a pinned
349
+ * month or year steps by whole units. The `prevMonth` and `prevYear` presets read as the period
350
+ * too, since they resolve to a full one.
351
+ *
352
+ * Once stepped to a non-zero offset, the trigger's dates locate the range and the label describes
353
+ * only its shape: a rolling window reads as its length (`7 Days`, `3 Months`), a named period as
354
+ * the period (`Jul 2026`), and a to-date preset as its name with the signed offset (`MTD −1`,
355
+ * `MTD +1`).
356
+ */
357
+ export function getDateRangeLabel(sel: DateRangeSelection, ctx: DateRangeContext): string {
358
+ switch (sel.kind) {
359
+ case 'preset': {
360
+ const preset = getPreset(sel.token, ctx),
361
+ {offset} = sel;
362
+ if (!offset) return evalLabel(preset.label, ctx);
363
+ return preset.shiftedLabel
364
+ ? preset.shiftedLabel(resolveDateRange(sel, ctx).current, offset, ctx)
365
+ : `${evalLabel(preset.label, ctx)} ${fmtDateRangeOffset(offset)}`;
366
+ }
367
+ case 'relative': {
368
+ const length = `${sel.count} ${getDateRangeUnitLabel(sel.unit, sel.count)}`;
369
+ return sel.offset ? length : `Prev ${length}`;
370
+ }
371
+ case 'month':
372
+ return getMonthStart(sel.year, sel.month).format('MMM YYYY');
373
+ case 'quarter':
374
+ return `Q${sel.quarter} ${sel.year}`;
375
+ case 'year':
376
+ return String(sel.year);
377
+ case 'custom':
378
+ return 'Custom';
379
+ }
380
+ }
381
+
382
+ /** Display name for a preset's row within the picker - its `name`, falling back to its `label`. */
383
+ export function getDateRangePresetName(preset: DateRangePreset, ctx: DateRangeContext): string {
384
+ return evalLabel(preset.name ?? preset.label, ctx);
385
+ }
386
+
387
+ function evalLabel(label: DateRangePreset['label'], ctx: DateRangeContext): string {
388
+ return isFunction(label) ? label(ctx) : label;
389
+ }
390
+
391
+ /** A signed offset as `−n` or `+n`, with a true minus sign - the default suffix for a stepped label. */
392
+ export function fmtDateRangeOffset(offset: number): string {
393
+ return `${offset < 0 ? '−' : '+'}${Math.abs(offset)}`;
394
+ }
395
+
396
+ function getPreset(token: string, ctx: DateRangeContext): DateRangePreset {
397
+ const preset = ctx.presets[token];
398
+ throwIf(!preset, `Unknown date range preset: '${token}'.`);
399
+ return preset;
400
+ }
401
+
402
+ const UNIT_LABELS: Record<DateRangeUnit, string> = {
403
+ days: 'Day',
404
+ weeks: 'Week',
405
+ months: 'Month',
406
+ quarters: 'Quarter',
407
+ years: 'Year'
408
+ };
409
+
410
+ /** Title-case unit label, singular when count is 1 - e.g. `Days`, `Month`. */
411
+ export function getDateRangeUnitLabel(unit: DateRangeUnit, count: number = 2): string {
412
+ const singular = UNIT_LABELS[unit];
413
+ return count === 1 ? singular : `${singular}s`;
414
+ }
415
+
416
+ /** Format a date per a {@link DateRangeFormat} - a moment.js format string or a function. */
417
+ export function fmtDate(date: LocalDate, format: DateRangeFormat): string {
418
+ return isFunction(format) ? format(date) : date.format(format);
419
+ }
420
+
421
+ /**
422
+ * Format a range as `start ▸ end` per `dateFormat`, with `…` for an unbounded edge. A single-day
423
+ * range formats as that one date per `singleDayFormat`, which defaults to `dateFormat`. Returns an empty
424
+ * string for a null range.
425
+ */
426
+ export function fmtDateRange(
427
+ range: LocalDateRange,
428
+ dateFormat: DateRangeFormat = 'YYYY-MM-DD',
429
+ singleDayFormat: DateRangeFormat = dateFormat
430
+ ): string {
431
+ if (!range) return '';
432
+ const {start, end} = range,
433
+ fmt = (d: LocalDate) => (d ? fmtDate(d, dateFormat) : '…');
434
+ return start && start === end ? fmtDate(start, singleDayFormat) : `${fmt(start)} ▸ ${fmt(end)}`;
435
+ }
436
+
437
+ /** First day of the given month (1-12) of the given year. */
438
+ export function getMonthStart(year: number, month: number): LocalDate {
439
+ return LocalDate.get(`${year}-${String(month).padStart(2, '0')}-01`);
440
+ }
441
+
442
+ /** First day of the given quarter (1-4) of the given year. */
443
+ export function getQuarterStart(year: number, quarter: number): LocalDate {
444
+ return getMonthStart(year, quarter * 3 - 2);
445
+ }
446
+
447
+ type PeriodSelection = MonthDateRangeSelection | QuarterDateRangeSelection | YearDateRangeSelection;
448
+ type PeriodUnit = (typeof PERIOD_UNITS)[PeriodSelection['kind']];
449
+
450
+ /** Natural (unclamped) first day of a month, quarter or year selection. */
451
+ function periodStart(sel: PeriodSelection): LocalDate {
452
+ switch (sel.kind) {
453
+ case 'month':
454
+ return getMonthStart(sel.year, sel.month);
455
+ case 'quarter':
456
+ return getQuarterStart(sel.year, sel.quarter);
457
+ case 'year':
458
+ return LocalDate.get(`${sel.year}-01-01`);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Move a selection by `steps` periods - negative to go back, positive to go forward. No selection
464
+ * changes kind:
465
+ *
466
+ * - Preset and relative selections adjust their `offset`, stepping through their own prior- and
467
+ * next-range logic - so a lookback in months steps by months, and a single day steps by
468
+ * business day in `businessDayMode`. Their natural range ends on the anchor date, so a positive
469
+ * offset is reachable only when `maxDate` allows dates beyond it.
470
+ * - Month, quarter and year selections step by calendar unit.
471
+ * - Custom selections step by their length in days - or by business day when a single day in
472
+ * `businessDayMode`.
473
+ *
474
+ * Steps stop at the context's `minDate` and `maxDate`. Returns null when the selection cannot
475
+ * move that way: it is already at a bound, or unbounded.
476
+ */
477
+ export function stepDateRangeSelection(
478
+ sel: DateRangeSelection,
479
+ steps: number,
480
+ ctx: DateRangeContext
481
+ ): DateRangeSelection | null {
482
+ if (!steps) return null;
483
+ const {minDate, maxDate} = ctx;
484
+
485
+ if (sel.kind === 'preset' || sel.kind === 'relative') {
486
+ const offset = sel.offset ?? 0,
487
+ nextOffset = Math.max(-MAX_STEP_OFFSET, Math.min(MAX_STEP_OFFSET, offset + steps));
488
+ if (nextOffset === offset) return null;
489
+
490
+ const next = withOffset(sel, nextOffset),
491
+ {current} = resolveDateRange(next, ctx),
492
+ {current: prev} = resolveDateRange(sel, ctx);
493
+ // Unbounded, or the prior/next logic could not reach the requested offset.
494
+ if (!current.start || !current.end) return null;
495
+ if (current.start === prev.start && current.end === prev.end) return null;
496
+ // A stepped range may not lie entirely outside the bounds.
497
+ if (current.end > maxDate || (minDate && current.start < minDate)) return null;
498
+ return next;
499
+ }
500
+
501
+ if (sel.kind === 'month' || sel.kind === 'quarter' || sel.kind === 'year') {
502
+ const unit = PERIOD_UNITS[sel.kind],
503
+ start = periodStart(sel),
504
+ // Never step beyond the period containing a bound, or below the supported years.
505
+ maxStart = maxDate.startOf(unit),
506
+ minStart = (minDate ?? LocalDate.get(`${MIN_SELECTION_YEAR}-01-01`)).startOf(unit);
507
+
508
+ let next = start.add(steps, unit);
509
+ if (next > maxStart) next = maxStart;
510
+ if (next < minStart) next = minStart;
511
+ // No move, or clamping reversed the direction - e.g. stepping forward from a period
512
+ // already beyond `maxDate`.
513
+ if (steps > 0 ? next <= start : next >= start) return null;
514
+
515
+ const year = next.moment.year();
516
+ switch (sel.kind) {
517
+ case 'month':
518
+ return {kind: 'month', year, month: next.moment.month() + 1};
519
+ case 'quarter':
520
+ return {kind: 'quarter', year, quarter: next.moment.quarter()};
521
+ case 'year':
522
+ return {kind: 'year', year};
523
+ }
524
+ }
525
+
526
+ const {start, end} = resolveDateRange(sel, ctx).current;
527
+ if (!start || !end) return null;
528
+
529
+ // A single day walks by business day in that mode - stopping at a bound rather than crossing it.
530
+ if (start === end && ctx.businessDayMode) {
531
+ let day = start;
532
+ for (let i = 0; i < Math.abs(steps); i++) {
533
+ day = steps > 0 ? nextBusinessDay(day, ctx) : previousBusinessDay(day, ctx);
534
+ }
535
+ if (day > maxDate || (minDate && day < minDate)) return null;
536
+ return {kind: 'custom', start: day.isoString, end: day.isoString};
537
+ }
538
+
539
+ const dayCount = end.diff(start, 'days') + 1;
540
+ let nextStart = start.add(dayCount * steps, 'days'),
541
+ nextEnd = end.add(dayCount * steps, 'days');
542
+
543
+ if (nextEnd > maxDate) {
544
+ nextEnd = maxDate;
545
+ nextStart = maxDate.subtract(dayCount - 1, 'days');
546
+ }
547
+ if (minDate && nextStart < minDate) {
548
+ nextStart = minDate;
549
+ nextEnd = minDate.add(dayCount - 1, 'days');
550
+ // A range longer than the bounds themselves - fill them.
551
+ if (nextEnd > maxDate) nextEnd = maxDate;
552
+ }
553
+ // As above - never move against the requested direction.
554
+ if (steps > 0 ? nextEnd <= end : nextStart >= start) return null;
555
+
556
+ return {kind: 'custom', start: nextStart.isoString, end: nextEnd.isoString};
557
+ }