@antelopejs/dms 0.7.7 → 0.7.9

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 (85) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/test/attachment-host/capabilities.d.ts +25 -0
  3. package/dist/test/attachment-host/capabilities.js +53 -1
  4. package/dist/test/attachment-host/capabilities.js.map +1 -1
  5. package/dist/test/integration/table-view-capabilities.test.js +13 -0
  6. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
  8. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
  10. package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
  11. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
  12. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +6 -1
  13. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  14. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  15. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  16. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
  17. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
  18. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
  19. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
  20. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
  21. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
  22. package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
  23. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  24. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  25. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
  26. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  29. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  30. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  32. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  33. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  35. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  36. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  37. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  38. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  40. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  46. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  47. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
  48. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  49. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  50. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  51. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +99 -13
  52. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  53. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  54. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  55. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  56. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  57. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
  58. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  59. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  60. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +7 -2
  61. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  62. package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
  63. package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
  64. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  65. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  66. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  67. package/frontend-vue/tests/component-id.test.ts +18 -0
  68. package/frontend-vue/tests/field-row.test.ts +14 -0
  69. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  70. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  71. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  72. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  73. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  74. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  75. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  76. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  77. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  78. package/frontend-vue/tests/table-row-hover.test.ts +32 -0
  79. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  80. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  81. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  82. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  83. package/package.json +2 -2
  84. package/skills/dms/SKILL.md +9 -2
  85. package/skills/dms-pages/REFERENCE.md +5 -0
@@ -0,0 +1,229 @@
1
+ import { readonly, ref, type Ref } from "vue";
2
+ import type { PageLiveOptions } from "../../../types/live";
3
+
4
+ const MS_PER_SECOND = 1000;
5
+ const STORAGE_KEY_PREFIX = "dms.live.paused:";
6
+ const PAUSED_VALUE = "1";
7
+
8
+ /**
9
+ * One block or table of a live page: what it reads again, and what it missed
10
+ * while the page was paused.
11
+ *
12
+ * @internal
13
+ */
14
+ export interface LiveSubscription {
15
+ /**
16
+ * An event behind the block arrived. `event` identifies it, so one event
17
+ * reaching several blocks counts once in the page's `missedCount`.
18
+ */
19
+ trigger(event?: object): void;
20
+ /** Events the block received while paused and has not read yet. */
21
+ readonly missed: Readonly<Ref<number>>;
22
+ /** When the block last read through the gate (ms since the epoch), 0 before. */
23
+ readonly lastReadAt: Readonly<Ref<number>>;
24
+ /** Reads now, whatever the state, and forgets what was missed. */
25
+ catchUp(): void;
26
+ /**
27
+ * Records a read the block made by itself (one the user asked for): it
28
+ * forgets what was missed, and a waiting trailing read is dropped.
29
+ */
30
+ acknowledge(): void;
31
+ unsubscribe(): void;
32
+ }
33
+
34
+ /**
35
+ * The Direct / Pause state of one page and the gate every refetch of its
36
+ * blocks goes through (see `PageLiveOptions` in interface-dms).
37
+ *
38
+ * @internal
39
+ */
40
+ export interface PageLiveController {
41
+ readonly pageId: string;
42
+ readonly isPaused: Readonly<Ref<boolean>>;
43
+ /** When the page last read its data (ms since the epoch). */
44
+ readonly lastUpdatedAt: Readonly<Ref<number>>;
45
+ /** Distinct events received since the pause. */
46
+ readonly missedCount: Readonly<Ref<number>>;
47
+ pause(): void;
48
+ resume(): void;
49
+ toggle(): void;
50
+ subscribe(refresh: () => void): LiveSubscription;
51
+ dispose(): void;
52
+ }
53
+
54
+ interface SubscriberState {
55
+ refresh: () => void;
56
+ missed: Ref<number>;
57
+ lastRunAt: Ref<number>;
58
+ timer: ReturnType<typeof setTimeout> | null;
59
+ /** The event the trailing call stands for, counted if the page pauses first. */
60
+ pendingEvent: object | undefined;
61
+ }
62
+
63
+ /** Browser storage of the paused pages; absent on the server or when denied. */
64
+ function liveStorage(): Storage | undefined {
65
+ try {
66
+ return typeof window === "undefined" ? undefined : window.localStorage;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ function readStoredPause(pageId: string): boolean {
73
+ return liveStorage()?.getItem(STORAGE_KEY_PREFIX + pageId) === PAUSED_VALUE;
74
+ }
75
+
76
+ function storePause(pageId: string, isPaused: boolean): void {
77
+ const storage = liveStorage();
78
+ if (!storage) return;
79
+ try {
80
+ if (isPaused) storage.setItem(STORAGE_KEY_PREFIX + pageId, PAUSED_VALUE);
81
+ else storage.removeItem(STORAGE_KEY_PREFIX + pageId);
82
+ } catch {
83
+ // A full or locked storage only loses the memory of the choice.
84
+ }
85
+ }
86
+
87
+ class PageLive implements PageLiveController {
88
+ readonly isPaused: Readonly<Ref<boolean>>;
89
+ readonly lastUpdatedAt: Readonly<Ref<number>>;
90
+ readonly missedCount: Readonly<Ref<number>>;
91
+ private readonly paused: Ref<boolean>;
92
+ private readonly updatedAt = ref(Date.now());
93
+ private readonly missedTotal = ref(0);
94
+ private readonly subscribers = new Set<SubscriberState>();
95
+ private countedEvents = new WeakSet<object>();
96
+ private readonly minIntervalMs: number;
97
+
98
+ constructor(
99
+ readonly pageId: string,
100
+ options: PageLiveOptions,
101
+ ) {
102
+ this.minIntervalMs = (options.maxRate ?? 0) * MS_PER_SECOND;
103
+ this.paused = ref(readStoredPause(pageId));
104
+ this.isPaused = readonly(this.paused);
105
+ this.lastUpdatedAt = readonly(this.updatedAt);
106
+ this.missedCount = readonly(this.missedTotal);
107
+ }
108
+
109
+ pause(): void {
110
+ if (this.paused.value) return;
111
+ this.paused.value = true;
112
+ storePause(this.pageId, true);
113
+ // A trailing read still waiting stands for an event not shown yet.
114
+ for (const state of this.subscribers) {
115
+ if (!state.timer) continue;
116
+ const event = state.pendingEvent;
117
+ clearTimer(state);
118
+ this.countMissed(state, event);
119
+ }
120
+ }
121
+
122
+ resume(): void {
123
+ if (!this.paused.value) return;
124
+ this.paused.value = false;
125
+ storePause(this.pageId, false);
126
+ this.missedTotal.value = 0;
127
+ this.countedEvents = new WeakSet();
128
+ this.updatedAt.value = Date.now();
129
+ for (const state of this.subscribers) {
130
+ if (state.missed.value === 0) continue;
131
+ state.missed.value = 0;
132
+ this.run(state);
133
+ }
134
+ }
135
+
136
+ toggle(): void {
137
+ if (this.paused.value) this.resume();
138
+ else this.pause();
139
+ }
140
+
141
+ subscribe(refresh: () => void): LiveSubscription {
142
+ const state: SubscriberState = {
143
+ refresh,
144
+ missed: ref(0),
145
+ lastRunAt: ref(0),
146
+ timer: null,
147
+ pendingEvent: undefined,
148
+ };
149
+ this.subscribers.add(state);
150
+ return {
151
+ trigger: (event) => this.trigger(state, event),
152
+ missed: readonly(state.missed),
153
+ lastReadAt: readonly(state.lastRunAt),
154
+ catchUp: () => {
155
+ state.missed.value = 0;
156
+ this.run(state);
157
+ },
158
+ acknowledge: () => {
159
+ state.missed.value = 0;
160
+ this.markRead(state);
161
+ },
162
+ unsubscribe: () => {
163
+ clearTimer(state);
164
+ this.subscribers.delete(state);
165
+ },
166
+ };
167
+ }
168
+
169
+ dispose(): void {
170
+ for (const state of this.subscribers) clearTimer(state);
171
+ this.subscribers.clear();
172
+ }
173
+
174
+ private trigger(state: SubscriberState, event: object | undefined): void {
175
+ if (this.paused.value) this.countMissed(state, event);
176
+ else this.triggerDirect(state, event);
177
+ }
178
+
179
+ // Leading and trailing: the first event of a quiet block reads at once, the
180
+ // ones within `maxRate` of a read collapse into one read when it ends, so
181
+ // the last event of a burst is never lost.
182
+ private triggerDirect(state: SubscriberState, event: object | undefined) {
183
+ if (state.timer) return;
184
+ const wait = state.lastRunAt.value + this.minIntervalMs - Date.now();
185
+ if (wait <= 0) {
186
+ this.run(state);
187
+ return;
188
+ }
189
+ state.pendingEvent = event;
190
+ state.timer = setTimeout(() => this.run(state), wait);
191
+ }
192
+
193
+ private run(state: SubscriberState): void {
194
+ this.markRead(state);
195
+ state.refresh();
196
+ }
197
+
198
+ private markRead(state: SubscriberState): void {
199
+ clearTimer(state);
200
+ state.lastRunAt.value = Date.now();
201
+ this.updatedAt.value = state.lastRunAt.value;
202
+ }
203
+
204
+ private countMissed(state: SubscriberState, event: object | undefined) {
205
+ state.missed.value += 1;
206
+ if (event && this.countedEvents.has(event)) return;
207
+ if (event) this.countedEvents.add(event);
208
+ this.missedTotal.value += 1;
209
+ }
210
+ }
211
+
212
+ function clearTimer(state: SubscriberState): void {
213
+ if (state.timer) clearTimeout(state.timer);
214
+ state.timer = null;
215
+ state.pendingEvent = undefined;
216
+ }
217
+
218
+ /**
219
+ * The controller of a page declared with `live`. Starts in the state the
220
+ * browser remembers for `pageId`, Direct otherwise.
221
+ *
222
+ * @internal
223
+ */
224
+ export function createPageLiveController(
225
+ pageId: string,
226
+ options: PageLiveOptions,
227
+ ): PageLiveController {
228
+ return new PageLive(pageId, options);
229
+ }
@@ -0,0 +1,8 @@
1
+ /** Switches the page between Direct and Pause, while a LiveToggle is on it. */
2
+ export const LIVE_TOGGLE_L_METADATA: ShortcutMetadata = {
3
+ key: ["l"],
4
+ descriptionKey: "$dms.shortcuts.livetoggle.l.description",
5
+ component: "$dms.components.livetoggle",
6
+ };
7
+
8
+ export const LIVE_TOGGLE_SHORTCUTS_METADATA = [LIVE_TOGGLE_L_METADATA];
@@ -0,0 +1,25 @@
1
+ import { onBeforeUnmount, onMounted } from "vue";
2
+
3
+ // The toggles mounted on the page, in mount order: the first one owns the L
4
+ // key, so a page showing two toggles still switches once per press.
5
+ const claims: symbol[] = [];
6
+
7
+ /**
8
+ * Binds L to `toggle` for the first LiveToggle mounted on the page; the next
9
+ * one takes over when it goes.
10
+ *
11
+ * @internal
12
+ */
13
+ export function useLiveToggleShortcut(toggle: () => void): void {
14
+ const claim = Symbol("live-toggle");
15
+ onMounted(() => claims.push(claim));
16
+ onBeforeUnmount(() => {
17
+ const index = claims.indexOf(claim);
18
+ if (index >= 0) claims.splice(index, 1);
19
+ });
20
+ defineShortcuts({
21
+ l: () => {
22
+ if (claims[0] === claim) toggle();
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,46 @@
1
+ import { onScopeDispose, watch } from "vue";
2
+ import type { PageLiveOptions } from "../../../types/live";
3
+ import { setCurrentPageLive } from "./currentPageLive";
4
+ import {
5
+ createPageLiveController,
6
+ type PageLiveController,
7
+ } from "./pageLiveController";
8
+
9
+ /** The page the renderer shows: its id and its `live` option. */
10
+ export interface PageLiveSource {
11
+ pageId: string | undefined;
12
+ live: PageLiveOptions | undefined;
13
+ }
14
+
15
+ /**
16
+ * Gives the page on screen its live controller while it declares `live`, and
17
+ * a new one when the renderer moves to another page. Browser only.
18
+ *
19
+ * @internal
20
+ */
21
+ export function usePageLiveHost(source: () => PageLiveSource): void {
22
+ if (typeof window === "undefined") return;
23
+ let controller: PageLiveController | null = null;
24
+
25
+ const install = (next: PageLiveController | null) => {
26
+ controller?.dispose();
27
+ controller = next;
28
+ setCurrentPageLive(next);
29
+ };
30
+
31
+ watch(
32
+ () => {
33
+ const { pageId, live } = source();
34
+ return pageId && live ? JSON.stringify([pageId, live]) : "";
35
+ },
36
+ (key) => {
37
+ const { pageId, live } = source();
38
+ install(
39
+ key && pageId && live ? createPageLiveController(pageId, live) : null,
40
+ );
41
+ },
42
+ { immediate: true },
43
+ );
44
+
45
+ onScopeDispose(() => install(null));
46
+ }
@@ -0,0 +1,330 @@
1
+ import {
2
+ END_OF_DAY_HOURS,
3
+ END_OF_DAY_MINUTES,
4
+ END_OF_DAY_MS,
5
+ END_OF_DAY_SECONDS,
6
+ type PeriodComparison,
7
+ type PeriodPreset,
8
+ type PeriodRange,
9
+ } from "#dms-core/app/composables/period/types";
10
+
11
+ /** The range half of a selector's choice: a preset, or a custom range. */
12
+ export interface PeriodRangeChoice {
13
+ preset: PeriodPreset;
14
+ customRange: PeriodRange | null;
15
+ }
16
+
17
+ /** The comparison half of a selector's choice. */
18
+ export interface PeriodComparisonChoice {
19
+ comparison: PeriodComparison;
20
+ customCompareRange: PeriodRange | null;
21
+ }
22
+
23
+ /** What a selector keeps in the URL and in the user's preferences. */
24
+ export interface PeriodSelection
25
+ extends PeriodRangeChoice,
26
+ PeriodComparisonChoice {}
27
+
28
+ /** The menus a selection has to fit in. */
29
+ export interface PeriodChoices {
30
+ presets: readonly PeriodPreset[];
31
+ comparisons: readonly PeriodComparison[];
32
+ }
33
+
34
+ /** A page URL query, or a selection kept in the preferences. */
35
+ export type PeriodQuery = Record<string, unknown>;
36
+
37
+ /** The keys one selector reads and writes. */
38
+ export interface PeriodQueryKeys {
39
+ period: string;
40
+ compare: string;
41
+ from: string;
42
+ to: string;
43
+ compareFrom: string;
44
+ compareTo: string;
45
+ }
46
+
47
+ /** A source the halves of a selection are read from, in order. */
48
+ export interface PeriodSource {
49
+ query: PeriodQuery;
50
+ keys: PeriodQueryKeys;
51
+ }
52
+
53
+ const CUSTOM_PRESET: PeriodPreset = "custom";
54
+ const CUSTOM_COMPARISON: PeriodComparison = "custom";
55
+ const KEY_NAMES: Array<keyof PeriodQueryKeys> = [
56
+ "period",
57
+ "compare",
58
+ "from",
59
+ "to",
60
+ "compareFrom",
61
+ "compareTo",
62
+ ];
63
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
64
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
65
+ const MONTH_OFFSET = 1;
66
+ const DATE_PART_LENGTH = 2;
67
+ const YEAR_PART_LENGTH = 4;
68
+
69
+ /**
70
+ * The keys of selector `id` in the page URL (`<id>.period`), or the bare keys
71
+ * (`period`) of a selection kept on its own.
72
+ */
73
+ export function periodQueryKeys(id?: string): PeriodQueryKeys {
74
+ const prefix = id ? `${id}.` : "";
75
+ return Object.fromEntries(
76
+ KEY_NAMES.map((name) => [name, `${prefix}${name}`]),
77
+ ) as unknown as PeriodQueryKeys;
78
+ }
79
+
80
+ function readText(query: PeriodQuery, key: string): string | undefined {
81
+ const raw = query[key];
82
+ const value = Array.isArray(raw) ? raw[0] : raw;
83
+ return typeof value === "string" && value ? value : undefined;
84
+ }
85
+
86
+ type DayBoundary = "start" | "end";
87
+
88
+ function dayBoundary(day: Date, boundary: DayBoundary): Date {
89
+ if (boundary === "end") {
90
+ day.setHours(
91
+ END_OF_DAY_HOURS,
92
+ END_OF_DAY_MINUTES,
93
+ END_OF_DAY_SECONDS,
94
+ END_OF_DAY_MS,
95
+ );
96
+ }
97
+ return day;
98
+ }
99
+
100
+ /** A local calendar day, refused when it does not exist (`2026-02-30`). */
101
+ function parseIsoDay(match: RegExpExecArray, boundary: DayBoundary) {
102
+ const [year, month, day] = match.slice(1).map(Number);
103
+ const date = new Date(year!, month! - MONTH_OFFSET, day);
104
+ const exists =
105
+ date.getFullYear() === year &&
106
+ date.getMonth() === month! - MONTH_OFFSET &&
107
+ date.getDate() === day;
108
+ return exists ? dayBoundary(date, boundary) : null;
109
+ }
110
+
111
+ /**
112
+ * A bound read from the URL: an ISO date is the start (`from`) or the end
113
+ * (`to`) of that local day, an ISO date-time is that instant. Anything else
114
+ * is `null`.
115
+ */
116
+ export function parsePeriodDate(
117
+ text: string | undefined,
118
+ boundary: DayBoundary,
119
+ ): Date | null {
120
+ if (!text) return null;
121
+ const day = ISO_DATE.exec(text);
122
+ if (day) return parseIsoDay(day, boundary);
123
+ if (!ISO_DATE_TIME.test(text)) return null;
124
+ const date = new Date(text);
125
+ return Number.isNaN(date.getTime()) ? null : date;
126
+ }
127
+
128
+ function pad(value: number, length = DATE_PART_LENGTH): string {
129
+ return String(value).padStart(length, "0");
130
+ }
131
+
132
+ function isDayBoundary(date: Date, boundary: DayBoundary): boolean {
133
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate());
134
+ return dayBoundary(day, boundary).getTime() === date.getTime();
135
+ }
136
+
137
+ /**
138
+ * A bound as the URL writes it: the bare local day when it falls on that
139
+ * day's boundary (a range picked in the calendar), the ISO instant otherwise.
140
+ */
141
+ export function formatPeriodDate(date: Date, boundary: DayBoundary): string {
142
+ if (!isDayBoundary(date, boundary)) return date.toISOString();
143
+ return [
144
+ pad(date.getFullYear(), YEAR_PART_LENGTH),
145
+ pad(date.getMonth() + MONTH_OFFSET),
146
+ pad(date.getDate()),
147
+ ].join("-");
148
+ }
149
+
150
+ function readRange(
151
+ query: PeriodQuery,
152
+ fromKey: string,
153
+ toKey: string,
154
+ ): PeriodRange | null {
155
+ const from = parsePeriodDate(readText(query, fromKey), "start");
156
+ const to = parsePeriodDate(readText(query, toKey), "end");
157
+ if (!from || !to || from.getTime() > to.getTime()) return null;
158
+ return { from, to };
159
+ }
160
+
161
+ /**
162
+ * The range a source names, when the selector offers it: a custom range
163
+ * (`from` and `to`) first, then a preset (`period`).
164
+ */
165
+ export function readRangeChoice(
166
+ source: PeriodSource,
167
+ choices: PeriodChoices,
168
+ ): PeriodRangeChoice | null {
169
+ const { query, keys } = source;
170
+ const customRange = readRange(query, keys.from, keys.to);
171
+ if (customRange && choices.presets.includes(CUSTOM_PRESET)) {
172
+ return { preset: CUSTOM_PRESET, customRange };
173
+ }
174
+ const preset = choices.presets.find(
175
+ (entry) => entry === readText(query, keys.period),
176
+ );
177
+ if (!preset || preset === CUSTOM_PRESET) return null;
178
+ return { preset, customRange: null };
179
+ }
180
+
181
+ /**
182
+ * The comparison a source names, when the selector offers it; a custom one
183
+ * needs its `compareFrom` and `compareTo`.
184
+ */
185
+ export function readComparisonChoice(
186
+ source: PeriodSource,
187
+ choices: PeriodChoices,
188
+ ): PeriodComparisonChoice | null {
189
+ const { query, keys } = source;
190
+ const comparison = choices.comparisons.find(
191
+ (entry) => entry === readText(query, keys.compare),
192
+ );
193
+ if (!comparison) return null;
194
+ if (comparison !== CUSTOM_COMPARISON) {
195
+ return { comparison, customCompareRange: null };
196
+ }
197
+ const customCompareRange = readRange(query, keys.compareFrom, keys.compareTo);
198
+ return customCompareRange ? { comparison, customCompareRange } : null;
199
+ }
200
+
201
+ function firstChoice<C>(
202
+ sources: PeriodSource[],
203
+ read: (source: PeriodSource) => C | null,
204
+ ): C | undefined {
205
+ for (const source of sources) {
206
+ const choice = read(source);
207
+ if (choice) return choice;
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ /**
213
+ * The selection the sources give, read in order (the URL, then the user's
214
+ * last choice), each half on its own: a half no source names validly comes
215
+ * from `fallback`, silently.
216
+ */
217
+ export function resolvePeriodSelection(
218
+ sources: PeriodSource[],
219
+ choices: PeriodChoices,
220
+ fallback: PeriodSelection,
221
+ ): PeriodSelection {
222
+ const range =
223
+ firstChoice(sources, (source) => readRangeChoice(source, choices)) ??
224
+ fallback;
225
+ const comparison =
226
+ firstChoice(sources, (source) => readComparisonChoice(source, choices)) ??
227
+ fallback;
228
+ return {
229
+ preset: range.preset,
230
+ customRange: range.customRange,
231
+ comparison: comparison.comparison,
232
+ customCompareRange: comparison.customCompareRange,
233
+ };
234
+ }
235
+
236
+ function rangeEntries(
237
+ range: PeriodRange | null,
238
+ fromKey: string,
239
+ toKey: string,
240
+ ): Array<[string, string]> {
241
+ if (!range) return [];
242
+ return [
243
+ [fromKey, formatPeriodDate(range.from, "start")],
244
+ [toKey, formatPeriodDate(range.to, "end")],
245
+ ];
246
+ }
247
+
248
+ /** A selection as the keys `keys` name. */
249
+ export function writePeriodQuery(
250
+ selection: PeriodSelection,
251
+ keys: PeriodQueryKeys,
252
+ ): Record<string, string> {
253
+ const isCustom = selection.preset === CUSTOM_PRESET;
254
+ const isCustomCompare = selection.comparison === CUSTOM_COMPARISON;
255
+ return Object.fromEntries([
256
+ ...(isCustom
257
+ ? rangeEntries(selection.customRange, keys.from, keys.to)
258
+ : [[keys.period, selection.preset]]),
259
+ [keys.compare, selection.comparison],
260
+ ...(isCustomCompare
261
+ ? rangeEntries(
262
+ selection.customCompareRange,
263
+ keys.compareFrom,
264
+ keys.compareTo,
265
+ )
266
+ : []),
267
+ ]);
268
+ }
269
+
270
+ /** Whether `query` already holds what `keys` would write for `selection`. */
271
+ export function isPeriodQueryCurrent(
272
+ query: PeriodQuery,
273
+ keys: PeriodQueryKeys,
274
+ selection: PeriodSelection,
275
+ ): boolean {
276
+ const written = writePeriodQuery(selection, keys);
277
+ return Object.values(keys).every(
278
+ (key) => readText(query, key) === written[key],
279
+ );
280
+ }
281
+
282
+ /**
283
+ * The search part of a URL with `selection` written under `keys`, and the
284
+ * keys it no longer uses (a preset's `period` once a custom range is picked)
285
+ * removed. Every other parameter, another selector's included, is kept as it
286
+ * was, in its place.
287
+ */
288
+ export function mergePeriodSearch(
289
+ search: string,
290
+ keys: PeriodQueryKeys,
291
+ selection: PeriodSelection,
292
+ ): string {
293
+ const params = new URLSearchParams(search);
294
+ const written = writePeriodQuery(selection, keys);
295
+ for (const key of Object.values(keys)) {
296
+ const value = written[key];
297
+ if (value === undefined) params.delete(key);
298
+ else params.set(key, value);
299
+ }
300
+ const text = params.toString();
301
+ return text ? `?${text}` : "";
302
+ }
303
+
304
+ /** The last choices of the selectors, by `rememberedPeriodKey`, oldest first. */
305
+ export type RememberedPeriods = Record<string, Record<string, string>>;
306
+
307
+ /** How many selectors' last choices the browser keeps. */
308
+ export const REMEMBERED_PERIODS_LIMIT = 20;
309
+
310
+ /** The entry of selector `id` of page `pageId` among the last choices. */
311
+ export function rememberedPeriodKey(pageId: string, id: string): string {
312
+ return `${pageId}#${id}`;
313
+ }
314
+
315
+ /**
316
+ * The last choices with `key`'s set to `query`, moved last as the most
317
+ * recent, and only the `limit` most recent kept: the store lives in a
318
+ * cookie, which must not grow with every page a user ever opened.
319
+ */
320
+ export function rememberPeriod(
321
+ store: unknown,
322
+ key: string,
323
+ query: Record<string, string>,
324
+ limit = REMEMBERED_PERIODS_LIMIT,
325
+ ): RememberedPeriods {
326
+ const entries = Object.entries(
327
+ store && typeof store === "object" ? (store as RememberedPeriods) : {},
328
+ ).filter(([entry]) => entry !== key);
329
+ return Object.fromEntries([...entries, [key, query]].slice(-limit));
330
+ }