@antelopejs/dms 0.7.7 → 0.7.8

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 (72) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
  3. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  4. package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
  5. package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
  6. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
  7. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  8. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  9. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
  10. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
  11. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
  12. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
  13. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
  14. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
  15. package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
  16. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  17. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  18. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
  19. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  20. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  21. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  22. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  23. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  24. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  25. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  26. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  29. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  30. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  32. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  33. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  35. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  36. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  37. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  38. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  39. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  40. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  41. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  42. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  43. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
  44. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  45. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  46. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  49. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  50. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  51. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
  52. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  53. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  54. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  55. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  56. package/frontend-vue/tests/field-row.test.ts +14 -0
  57. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  58. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  59. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  60. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  61. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  62. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  63. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  64. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  65. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  66. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  67. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  68. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  69. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  70. package/package.json +2 -2
  71. package/skills/dms/SKILL.md +9 -2
  72. package/skills/dms-pages/REFERENCE.md +5 -0
@@ -0,0 +1,268 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A PeriodSelector keeps its period in the page URL: the period it starts
4
+ * from (the URL, then the user's last choice, then its default), the server
5
+ * render (no flash of the default), the in-place write on a change (no new
6
+ * history entry), and the period following a route changed from outside.
7
+ */
8
+ import {
9
+ createApp,
10
+ createSSRApp,
11
+ defineComponent,
12
+ h,
13
+ nextTick,
14
+ reactive,
15
+ type App,
16
+ } from "vue";
17
+ import { renderToString } from "vue/server-renderer";
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+ import type { PeriodPreset } from "../layers/dms-core/app/composables/period/types";
20
+
21
+ const route = reactive<{ query: Record<string, string> }>({ query: {} });
22
+ const preferences = new Map<string, unknown>();
23
+
24
+ vi.stubGlobal("useDmsRoute", () => route);
25
+ // Inertia's client-side replace moves its page URL, which the DMS route
26
+ // follows: the route then holds the query the browser shows.
27
+ const { inertiaReplace } = vi.hoisted(() => ({
28
+ inertiaReplace: vi.fn(({ url }: { url: string }) => {
29
+ route.query = Object.fromEntries(
30
+ new URL(url, "http://dms.local").searchParams,
31
+ );
32
+ }),
33
+ }));
34
+ vi.mock("@inertiajs/vue3", () => ({ router: { replace: inertiaReplace } }));
35
+ vi.stubGlobal("usePreferences", () => ({
36
+ getPreference: <T>(key: string, fallback?: T) =>
37
+ (preferences.get(key) as T | undefined) ?? fallback,
38
+ setPreference: (key: string, value: unknown) => preferences.set(key, value),
39
+ }));
40
+
41
+ const { usePeriod } = await import(
42
+ "../layers/dms-core/app/composables/period/usePeriod"
43
+ );
44
+ const { usePeriodLocation, PERIOD_PREFERENCE_KEY } = await import(
45
+ "../layers/dms-ui/app/build/composables/period/usePeriodLocation"
46
+ );
47
+
48
+ type Period = ReturnType<typeof usePeriod>;
49
+
50
+ const PAGE_ID = "dashboard";
51
+ const PAGE_PATH = "/dashboard";
52
+ const handles: Record<string, Period> = {};
53
+
54
+ /** The selector's state wiring, as PeriodSelector.vue does it in setup. */
55
+ function selector(id: string, defaultPreset: PeriodPreset = "this-month") {
56
+ return defineComponent({
57
+ setup() {
58
+ const period = usePeriod({
59
+ defaultPreset,
60
+ presets: ["last-7-days", "last-30-days", "this-month", "custom"],
61
+ });
62
+ usePeriodLocation(period, {
63
+ id,
64
+ pageId: PAGE_ID,
65
+ fallback: {
66
+ preset: period.preset.value,
67
+ comparison: period.comparison.value,
68
+ customRange: null,
69
+ customCompareRange: null,
70
+ },
71
+ });
72
+ handles[id] = period;
73
+ return () =>
74
+ h("span", `${period.preset.value}|${period.comparison.value}`);
75
+ },
76
+ });
77
+ }
78
+
79
+ const mounted: App[] = [];
80
+
81
+ function mount(...ids: string[]): void {
82
+ const app = createApp({
83
+ setup: () => () =>
84
+ h(
85
+ "div",
86
+ ids.map((id) => h(selector(id))),
87
+ ),
88
+ });
89
+ app.mount(document.createElement("div"));
90
+ mounted.push(app);
91
+ }
92
+
93
+ const search = () => new URLSearchParams(window.location.search);
94
+
95
+ const remember = (query: Record<string, string>) =>
96
+ preferences.set(PERIOD_PREFERENCE_KEY, { [`${PAGE_ID}#page`]: query });
97
+
98
+ beforeEach(() => {
99
+ route.query = {};
100
+ preferences.clear();
101
+ inertiaReplace.mockClear();
102
+ window.history.replaceState(null, "", PAGE_PATH);
103
+ });
104
+
105
+ afterEach(() => {
106
+ mounted.splice(0).forEach((app) => app.unmount());
107
+ });
108
+
109
+ describe("the period a selector starts from", () => {
110
+ it("is the URL's", () => {
111
+ route.query = {
112
+ "page.period": "last-7-days",
113
+ "page.compare": "previous-year",
114
+ };
115
+ remember({
116
+ period: "last-30-days",
117
+ });
118
+ mount("page");
119
+ expect(handles.page!.preset.value).toBe("last-7-days");
120
+ expect(handles.page!.comparison.value).toBe("previous-year");
121
+ });
122
+
123
+ it("is the user's last choice when the URL names none", () => {
124
+ remember({
125
+ period: "last-30-days",
126
+ compare: "previous-period",
127
+ });
128
+ mount("page");
129
+ expect(handles.page!.preset.value).toBe("last-30-days");
130
+ expect(handles.page!.comparison.value).toBe("previous-period");
131
+ });
132
+
133
+ it("is the default when neither names a valid one", () => {
134
+ route.query = { "page.period": "next-century" };
135
+ remember({
136
+ period: "ytd",
137
+ });
138
+ mount("page");
139
+ expect(handles.page!.preset.value).toBe("this-month");
140
+ expect(handles.page!.comparison.value).toBe("none");
141
+ });
142
+
143
+ it("is a custom range read from the URL", () => {
144
+ route.query = { "page.from": "2026-10-01", "page.to": "2026-10-15" };
145
+ mount("page");
146
+ expect(handles.page!.preset.value).toBe("custom");
147
+ expect(handles.page!.state.value.range).toEqual({
148
+ from: new Date(2026, 9, 1),
149
+ to: new Date(2026, 9, 15, 23, 59, 59, 999),
150
+ });
151
+ });
152
+
153
+ it("is rendered by the server, so the default never flashes", async () => {
154
+ route.query = { "page.period": "last-7-days" };
155
+ const html = await renderToString(createSSRApp(selector("page")));
156
+ expect(html).toContain("last-7-days|none");
157
+ });
158
+ });
159
+
160
+ describe("the URL on load", () => {
161
+ it("carries the period the selector starts from, the default included", () => {
162
+ window.history.replaceState(null, "", `${PAGE_PATH}?requests.tab=get`);
163
+ mount("page");
164
+ expect(Object.fromEntries(search())).toEqual({
165
+ "requests.tab": "get",
166
+ "page.period": "this-month",
167
+ "page.compare": "none",
168
+ });
169
+ });
170
+
171
+ it("is left alone when it already names the period", () => {
172
+ window.history.replaceState(
173
+ null,
174
+ "",
175
+ `${PAGE_PATH}?page.period=this-month&page.compare=none`,
176
+ );
177
+ route.query = { "page.period": "this-month", "page.compare": "none" };
178
+ mount("page");
179
+ expect(inertiaReplace).not.toHaveBeenCalled();
180
+ });
181
+ });
182
+
183
+ describe("a change of period", () => {
184
+ it("reaches the route, so a tab written from it keeps the new period", async () => {
185
+ mount("page");
186
+ handles.page!.setPreset("last-7-days");
187
+ await nextTick();
188
+ // What useTab does: the route's query, plus its own key.
189
+ const query = { ...route.query, tab: "2" };
190
+ window.history.replaceState(
191
+ null,
192
+ "",
193
+ `${PAGE_PATH}?${new URLSearchParams(query)}`,
194
+ );
195
+ expect(search().get("tab")).toBe("2");
196
+ expect(search().get("page.period")).toBe("last-7-days");
197
+ expect(handles.page!.preset.value).toBe("last-7-days");
198
+ });
199
+
200
+ it("replaces the URL in place and remembers the choice", async () => {
201
+ mount("page");
202
+ const entries = window.history.length;
203
+ handles.page!.setPreset("last-7-days");
204
+ handles.page!.setComparison("previous-period");
205
+ await nextTick();
206
+ expect(search().get("page.period")).toBe("last-7-days");
207
+ expect(search().get("page.compare")).toBe("previous-period");
208
+ expect(window.history.length).toBe(entries);
209
+ expect(
210
+ (preferences.get(PERIOD_PREFERENCE_KEY) as Record<string, unknown>)[
211
+ `${PAGE_ID}#page`
212
+ ],
213
+ ).toEqual({ period: "last-7-days", compare: "previous-period" });
214
+ });
215
+
216
+ it("swaps the preset key for the custom range keys", async () => {
217
+ window.history.replaceState(
218
+ null,
219
+ "",
220
+ `${PAGE_PATH}?page.period=last-7-days`,
221
+ );
222
+ mount("page");
223
+ handles.page!.setCustomRange({
224
+ from: new Date(2026, 9, 1),
225
+ to: new Date(2026, 9, 2, 23, 59, 59, 999),
226
+ });
227
+ await nextTick();
228
+ expect(search().get("page.period")).toBeNull();
229
+ expect(search().get("page.from")).toBe("2026-10-01");
230
+ expect(search().get("page.to")).toBe("2026-10-02");
231
+ });
232
+
233
+ it("leaves another selector's keys and the other parameters alone", async () => {
234
+ window.history.replaceState(null, "", `${PAGE_PATH}?requests.tab=get`);
235
+ mount("sales", "traffic");
236
+ handles.sales!.setPreset("last-7-days");
237
+ await nextTick();
238
+ handles.traffic!.setPreset("last-30-days");
239
+ await nextTick();
240
+ expect(Object.fromEntries(search())).toEqual({
241
+ "requests.tab": "get",
242
+ "sales.period": "last-7-days",
243
+ "sales.compare": "none",
244
+ "traffic.period": "last-30-days",
245
+ "traffic.compare": "none",
246
+ });
247
+ expect(handles.sales!.preset.value).toBe("last-7-days");
248
+ });
249
+ });
250
+
251
+ describe("a route changed from outside", () => {
252
+ it("moves the period of the selector it names", async () => {
253
+ mount("sales", "traffic");
254
+ route.query = { "sales.period": "last-30-days" };
255
+ await nextTick();
256
+ expect(handles.sales!.preset.value).toBe("last-30-days");
257
+ expect(handles.traffic!.preset.value).toBe("this-month");
258
+ });
259
+
260
+ it("keeps the period when it names an invalid one", async () => {
261
+ mount("page");
262
+ handles.page!.setPreset("last-7-days");
263
+ await nextTick();
264
+ route.query = { "page.period": "last-decade" };
265
+ await nextTick();
266
+ expect(handles.page!.preset.value).toBe("last-7-days");
267
+ });
268
+ });
@@ -0,0 +1,351 @@
1
+ /**
2
+ * A PeriodSelector keeps its period in the page URL under keys prefixed with
3
+ * its id. These cover what the URL holds: how a selection is written and read
4
+ * back, which source wins (the URL, then the user's last choice, then the
5
+ * default), that two selectors never read each other's keys, and that a value
6
+ * the selector cannot honour falls back silently.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import {
10
+ formatPeriodDate,
11
+ isPeriodQueryCurrent,
12
+ mergePeriodSearch,
13
+ parsePeriodDate,
14
+ periodQueryKeys,
15
+ type PeriodChoices,
16
+ type PeriodSelection,
17
+ readComparisonChoice,
18
+ readRangeChoice,
19
+ rememberPeriod,
20
+ resolvePeriodSelection,
21
+ writePeriodQuery,
22
+ } from "../layers/dms-ui/app/build/composables/period/periodQuery";
23
+
24
+ const CHOICES: PeriodChoices = {
25
+ presets: ["last-7-days", "last-30-days", "this-month", "custom"],
26
+ comparisons: ["none", "previous-period", "previous-year", "custom"],
27
+ };
28
+ const DEFAULT: PeriodSelection = {
29
+ preset: "this-month",
30
+ comparison: "none",
31
+ customRange: null,
32
+ customCompareRange: null,
33
+ };
34
+ const PAGE = periodQueryKeys("page");
35
+ const STORED = periodQueryKeys();
36
+
37
+ const day = (year: number, month: number, date: number) =>
38
+ new Date(year, month - 1, date);
39
+ const endOfDay = (year: number, month: number, date: number) =>
40
+ new Date(year, month - 1, date, 23, 59, 59, 999);
41
+
42
+ describe("the keys of a selector", () => {
43
+ it("prefixes every key with the selector id", () => {
44
+ expect(PAGE).toEqual({
45
+ period: "page.period",
46
+ compare: "page.compare",
47
+ from: "page.from",
48
+ to: "page.to",
49
+ compareFrom: "page.compareFrom",
50
+ compareTo: "page.compareTo",
51
+ });
52
+ });
53
+
54
+ it("keeps the bare keys without an id", () => {
55
+ expect(STORED.period).toBe("period");
56
+ });
57
+ });
58
+
59
+ describe("serializing a selection", () => {
60
+ it("writes a preset and its comparison", () => {
61
+ expect(
62
+ writePeriodQuery(
63
+ { ...DEFAULT, preset: "last-7-days", comparison: "previous-period" },
64
+ PAGE,
65
+ ),
66
+ ).toEqual({
67
+ "page.period": "last-7-days",
68
+ "page.compare": "previous-period",
69
+ });
70
+ });
71
+
72
+ it("writes a custom range of whole days as bare dates", () => {
73
+ expect(
74
+ writePeriodQuery(
75
+ {
76
+ ...DEFAULT,
77
+ preset: "custom",
78
+ customRange: { from: day(2026, 10, 1), to: endOfDay(2026, 10, 31) },
79
+ },
80
+ PAGE,
81
+ ),
82
+ ).toEqual({
83
+ "page.from": "2026-10-01",
84
+ "page.to": "2026-10-31",
85
+ "page.compare": "none",
86
+ });
87
+ });
88
+
89
+ it("writes an instant that is not a day boundary as an ISO date-time", () => {
90
+ const from = new Date(2026, 9, 1, 10, 30);
91
+ expect(formatPeriodDate(from, "start")).toBe(from.toISOString());
92
+ });
93
+
94
+ it("writes a custom comparison with its own range", () => {
95
+ expect(
96
+ writePeriodQuery(
97
+ {
98
+ ...DEFAULT,
99
+ comparison: "custom",
100
+ customCompareRange: {
101
+ from: day(2025, 10, 1),
102
+ to: endOfDay(2025, 10, 31),
103
+ },
104
+ },
105
+ PAGE,
106
+ ),
107
+ ).toEqual({
108
+ "page.period": "this-month",
109
+ "page.compare": "custom",
110
+ "page.compareFrom": "2025-10-01",
111
+ "page.compareTo": "2025-10-31",
112
+ });
113
+ });
114
+
115
+ it("reads back what it wrote", () => {
116
+ const selection: PeriodSelection = {
117
+ preset: "custom",
118
+ comparison: "custom",
119
+ customRange: {
120
+ from: new Date(2026, 9, 1, 8),
121
+ to: new Date(2026, 9, 2, 18),
122
+ },
123
+ customCompareRange: { from: day(2026, 9, 1), to: endOfDay(2026, 9, 2) },
124
+ };
125
+ const query = writePeriodQuery(selection, PAGE);
126
+ expect(
127
+ resolvePeriodSelection([{ query, keys: PAGE }], CHOICES, DEFAULT),
128
+ ).toEqual(selection);
129
+ expect(isPeriodQueryCurrent(query, PAGE, selection)).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe("parsing the URL", () => {
134
+ it("reads an ISO date as the start or the end of that local day", () => {
135
+ expect(parsePeriodDate("2026-10-01", "start")).toEqual(day(2026, 10, 1));
136
+ expect(parsePeriodDate("2026-10-01", "end")).toEqual(endOfDay(2026, 10, 1));
137
+ });
138
+
139
+ it("reads an ISO date-time as that instant", () => {
140
+ expect(parsePeriodDate("2026-10-01T08:00:00.000Z", "start")).toEqual(
141
+ new Date("2026-10-01T08:00:00.000Z"),
142
+ );
143
+ });
144
+
145
+ it.each(["2026-02-30", "yesterday", "1700000000", "2026-13-01", ""])(
146
+ "refuses %j",
147
+ (text) => {
148
+ expect(parsePeriodDate(text, "start")).toBeNull();
149
+ },
150
+ );
151
+
152
+ it("reads a preset the selector offers", () => {
153
+ const query = { "page.period": "last-30-days" };
154
+ expect(readRangeChoice({ query, keys: PAGE }, CHOICES)).toEqual({
155
+ preset: "last-30-days",
156
+ customRange: null,
157
+ });
158
+ });
159
+
160
+ it("prefers a custom range to a preset", () => {
161
+ const query = {
162
+ "page.period": "last-30-days",
163
+ "page.from": "2026-10-01",
164
+ "page.to": "2026-10-15",
165
+ };
166
+ expect(readRangeChoice({ query, keys: PAGE }, CHOICES)).toEqual({
167
+ preset: "custom",
168
+ customRange: { from: day(2026, 10, 1), to: endOfDay(2026, 10, 15) },
169
+ });
170
+ });
171
+
172
+ it("takes the first value of a repeated key", () => {
173
+ const query = { "page.period": ["last-7-days", "last-30-days"] };
174
+ expect(readRangeChoice({ query, keys: PAGE }, CHOICES)?.preset).toBe(
175
+ "last-7-days",
176
+ );
177
+ });
178
+
179
+ it.each([
180
+ ["an unknown preset", { "page.period": "last-decade" }],
181
+ ["a preset the selector does not offer", { "page.period": "ytd" }],
182
+ ["custom with no range", { "page.period": "custom" }],
183
+ [
184
+ "a range ending before it starts",
185
+ { "page.from": "2026-10-15", "page.to": "2026-10-01" },
186
+ ],
187
+ ["half a range", { "page.from": "2026-10-01" }],
188
+ ])("ignores %s", (_, query) => {
189
+ expect(readRangeChoice({ query, keys: PAGE }, CHOICES)).toBeNull();
190
+ });
191
+
192
+ it("ignores a custom range the selector does not offer", () => {
193
+ const query = { "page.from": "2026-10-01", "page.to": "2026-10-15" };
194
+ const presets = CHOICES.presets.filter((preset) => preset !== "custom");
195
+ expect(
196
+ readRangeChoice({ query, keys: PAGE }, { ...CHOICES, presets }),
197
+ ).toBeNull();
198
+ });
199
+
200
+ it.each([
201
+ ["an unknown comparison", { "page.compare": "previous-decade" }],
202
+ ["a custom comparison with no range", { "page.compare": "custom" }],
203
+ ])("ignores %s", (_, query) => {
204
+ expect(readComparisonChoice({ query, keys: PAGE }, CHOICES)).toBeNull();
205
+ });
206
+ });
207
+
208
+ describe("the order of the sources", () => {
209
+ const stored = {
210
+ query: { period: "last-30-days", compare: "previous-year" },
211
+ keys: STORED,
212
+ };
213
+
214
+ it("reads the URL first", () => {
215
+ const url = {
216
+ query: { "page.period": "last-7-days", "page.compare": "none" },
217
+ keys: PAGE,
218
+ };
219
+ expect(
220
+ resolvePeriodSelection([url, stored], CHOICES, DEFAULT),
221
+ ).toMatchObject({ preset: "last-7-days", comparison: "none" });
222
+ });
223
+
224
+ it("reads the user's last choice when the URL names nothing", () => {
225
+ expect(
226
+ resolvePeriodSelection(
227
+ [{ query: {}, keys: PAGE }, stored],
228
+ CHOICES,
229
+ DEFAULT,
230
+ ),
231
+ ).toMatchObject({ preset: "last-30-days", comparison: "previous-year" });
232
+ });
233
+
234
+ it("falls back to the default when no source names a period", () => {
235
+ expect(
236
+ resolvePeriodSelection(
237
+ [
238
+ { query: {}, keys: PAGE },
239
+ { query: {}, keys: STORED },
240
+ ],
241
+ CHOICES,
242
+ DEFAULT,
243
+ ),
244
+ ).toEqual(DEFAULT);
245
+ });
246
+
247
+ it("skips an invalid URL value for the next source, half by half", () => {
248
+ const url = {
249
+ query: {
250
+ "page.period": "last-decade",
251
+ "page.compare": "previous-period",
252
+ },
253
+ keys: PAGE,
254
+ };
255
+ expect(
256
+ resolvePeriodSelection([url, stored], CHOICES, DEFAULT),
257
+ ).toMatchObject({
258
+ preset: "last-30-days",
259
+ comparison: "previous-period",
260
+ });
261
+ });
262
+
263
+ it("skips an invalid stored value for the default", () => {
264
+ const broken = { query: { period: 42, compare: "?" }, keys: STORED };
265
+ expect(
266
+ resolvePeriodSelection(
267
+ [{ query: {}, keys: PAGE }, broken],
268
+ CHOICES,
269
+ DEFAULT,
270
+ ),
271
+ ).toEqual(DEFAULT);
272
+ });
273
+ });
274
+
275
+ describe("several selectors on one page", () => {
276
+ const query = {
277
+ "sales.period": "last-7-days",
278
+ "sales.compare": "previous-year",
279
+ "traffic.period": "last-30-days",
280
+ "traffic.compare": "none",
281
+ period: "this-month",
282
+ };
283
+
284
+ it("reads each selector's own keys only", () => {
285
+ const read = (id: string) =>
286
+ resolvePeriodSelection(
287
+ [{ query, keys: periodQueryKeys(id) }],
288
+ CHOICES,
289
+ DEFAULT,
290
+ );
291
+ expect(read("sales")).toMatchObject({
292
+ preset: "last-7-days",
293
+ comparison: "previous-year",
294
+ });
295
+ expect(read("traffic")).toMatchObject({
296
+ preset: "last-30-days",
297
+ comparison: "none",
298
+ });
299
+ expect(read("other")).toEqual(DEFAULT);
300
+ });
301
+
302
+ it("rewrites one selector's keys and keeps every other parameter", () => {
303
+ const search =
304
+ "?requests.tab=get&sales.period=last-7-days&sales.compare=none&traffic.period=last-30-days";
305
+ const next = mergePeriodSearch(search, periodQueryKeys("sales"), {
306
+ ...DEFAULT,
307
+ preset: "custom",
308
+ customRange: { from: day(2026, 10, 1), to: endOfDay(2026, 10, 2) },
309
+ });
310
+ expect(Object.fromEntries(new URLSearchParams(next))).toEqual({
311
+ "requests.tab": "get",
312
+ "sales.compare": "none",
313
+ "sales.from": "2026-10-01",
314
+ "sales.to": "2026-10-02",
315
+ "traffic.period": "last-30-days",
316
+ });
317
+ });
318
+ });
319
+
320
+ describe("the last choices kept in the browser", () => {
321
+ it("keeps only the most recent ones", () => {
322
+ let store = {};
323
+ for (let index = 0; index < 25; index++) {
324
+ store = rememberPeriod(store, `page-${index}#page`, { period: "ytd" });
325
+ }
326
+ const keys = Object.keys(store);
327
+ expect(keys).toHaveLength(20);
328
+ expect(keys[0]).toBe("page-5#page");
329
+ expect(keys.at(-1)).toBe("page-24#page");
330
+ });
331
+
332
+ it("moves a selector chosen again to the most recent place", () => {
333
+ const store = rememberPeriod(
334
+ { "a#page": { period: "ytd" }, "b#page": { period: "ytd" } },
335
+ "a#page",
336
+ { period: "last-7-days" },
337
+ 2,
338
+ );
339
+ expect(Object.keys(store)).toEqual(["b#page", "a#page"]);
340
+ expect(store["a#page"]).toEqual({ period: "last-7-days" });
341
+ expect(
342
+ Object.keys(rememberPeriod(store, "c#page", { period: "ytd" }, 2)),
343
+ ).toEqual(["a#page", "c#page"]);
344
+ });
345
+
346
+ it("starts over from a store that is not an object", () => {
347
+ expect(rememberPeriod("broken", "a#page", { period: "ytd" })).toEqual({
348
+ "a#page": { period: "ytd" },
349
+ });
350
+ });
351
+ });