@antelopejs/dms 0.7.6 → 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 (75) hide show
  1. package/CHANGELOG.md +35 -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/form/FormSectionNav.vue +30 -15
  19. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
  20. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  21. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  22. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  23. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  24. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  26. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  27. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  29. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  30. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  32. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  33. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  35. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  36. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  37. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  38. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  40. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  41. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  42. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  43. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  44. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
  45. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +1 -1
  46. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  49. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  50. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  51. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  52. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  53. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
  54. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  55. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  56. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  57. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  58. package/frontend-vue/tests/field-row.test.ts +14 -0
  59. package/frontend-vue/tests/form-section-nav-groups.test.ts +75 -14
  60. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  61. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  62. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  63. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  64. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  65. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  66. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  67. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  68. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  69. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  70. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  71. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  72. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  73. package/package.json +2 -2
  74. package/skills/dms/SKILL.md +11 -3
  75. package/skills/dms-pages/REFERENCE.md +5 -0
@@ -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
+ }
@@ -0,0 +1,189 @@
1
+ import { router as inertiaRouter } from "@inertiajs/vue3";
2
+ import { onMounted, watch } from "vue";
3
+ import type { UsePeriodReturn } from "#dms-core/app/composables/period/usePeriod";
4
+ import {
5
+ isPeriodQueryCurrent,
6
+ mergePeriodSearch,
7
+ periodQueryKeys,
8
+ rememberedPeriodKey,
9
+ rememberPeriod,
10
+ type RememberedPeriods,
11
+ type PeriodQuery,
12
+ type PeriodQueryKeys,
13
+ type PeriodSelection,
14
+ type PeriodSource,
15
+ resolvePeriodSelection,
16
+ writePeriodQuery,
17
+ } from "./periodQuery";
18
+
19
+ export interface PeriodLocationOptions {
20
+ /** The selector's id: the prefix of its URL keys. */
21
+ id: string;
22
+ /** The page it is on: its last choice is kept per page and per id. */
23
+ pageId: string;
24
+ /** What the period falls back to when no source names a valid one. */
25
+ fallback: PeriodSelection;
26
+ }
27
+
28
+ /**
29
+ * The preference holding the user's last choice of each selector
30
+ * (`usePreferences`), capped to the most recent ones.
31
+ */
32
+ export const PERIOD_PREFERENCE_KEY = "periods";
33
+ const STORED_KEYS = periodQueryKeys();
34
+
35
+ function readSelection(period: UsePeriodReturn): PeriodSelection {
36
+ return {
37
+ preset: period.preset.value,
38
+ comparison: period.comparison.value,
39
+ customRange: period.customRange.value,
40
+ customCompareRange: period.customCompareRange.value,
41
+ };
42
+ }
43
+
44
+ function applySelection(
45
+ period: UsePeriodReturn,
46
+ selection: PeriodSelection,
47
+ ): void {
48
+ period.customRange.value = selection.customRange;
49
+ period.customCompareRange.value = selection.customCompareRange;
50
+ period.preset.value = selection.preset;
51
+ period.comparison.value = selection.comparison;
52
+ }
53
+
54
+ function urlKeysText(query: PeriodQuery, keys: PeriodQueryKeys): string {
55
+ return JSON.stringify(Object.values(keys).map((key) => query[key] ?? null));
56
+ }
57
+
58
+ function selectionText(selection: PeriodSelection): string {
59
+ return JSON.stringify(writePeriodQuery(selection, STORED_KEYS));
60
+ }
61
+
62
+ /**
63
+ * Writes the period into the URL: the history entry is replaced, and no
64
+ * request reloads the page. The browser's URL is written at once, so a
65
+ * second selector writing in the same tick merges into it; Inertia's
66
+ * client-side replace then moves its page URL, which the DMS route follows,
67
+ * so a component writing the query from the route (a tab, a quick action)
68
+ * carries the period on instead of the one the page was opened with.
69
+ */
70
+ function replacePeriodInUrl(
71
+ keys: PeriodQueryKeys,
72
+ selection: PeriodSelection,
73
+ ): string | null {
74
+ if (typeof window === "undefined") return null;
75
+ const url = new URL(window.location.href);
76
+ const query = Object.fromEntries(url.searchParams);
77
+ if (isPeriodQueryCurrent(query, keys, selection)) return null;
78
+ url.search = mergePeriodSearch(url.search, keys, selection);
79
+ const written = urlKeysText(Object.fromEntries(url.searchParams), keys);
80
+ window.history.replaceState(window.history.state, "", url);
81
+ inertiaRouter.replace({
82
+ url: `${url.pathname}${url.search}${url.hash}`,
83
+ preserveState: true,
84
+ preserveScroll: true,
85
+ });
86
+ return written;
87
+ }
88
+
89
+ /** The user's last choice of one selector, kept in the preferences. */
90
+ function useRememberedPeriod(pageId: string, id: string) {
91
+ const { getPreference, setPreference } = usePreferences();
92
+ const key = rememberedPeriodKey(pageId, id);
93
+ const all = () => getPreference<RememberedPeriods>(PERIOD_PREFERENCE_KEY, {});
94
+ return {
95
+ source: (): PeriodSource => ({
96
+ query: all()[key] ?? {},
97
+ keys: STORED_KEYS,
98
+ }),
99
+ remember: (selection: PeriodSelection) =>
100
+ setPreference(
101
+ PERIOD_PREFERENCE_KEY,
102
+ rememberPeriod(all(), key, writePeriodQuery(selection, STORED_KEYS)),
103
+ ),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * The URL writes of one selector. The route catches up with each write
109
+ * later, and a pick made meanwhile must not be undone by the URL of the one
110
+ * before: `isEcho` tells a route naming a URL written here from a change.
111
+ */
112
+ function usePeriodUrlWriter(keys: PeriodQueryKeys) {
113
+ const pending: string[] = [];
114
+ return {
115
+ write: (selection: PeriodSelection) => {
116
+ const written = replacePeriodInUrl(keys, selection);
117
+ if (written !== null) pending.push(written);
118
+ },
119
+ isEcho: (text: string) => {
120
+ const index = pending.indexOf(text);
121
+ if (index === -1) return false;
122
+ pending.splice(0, index + 1);
123
+ return true;
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Keeps a selector's period in the page URL (`?<id>.period=`, see
130
+ * `periodQuery`) and its last choice in the user's preferences. The period
131
+ * starts from the URL, then from that last choice, then from `fallback`. Both
132
+ * are read in setup, on the server too: the preferences are a cookie, so the
133
+ * server renders the period the browser will show.
134
+ *
135
+ * The URL is written once mounted, so a copied URL always carries the
136
+ * period, then on every change: the history entry is replaced instead of
137
+ * pushed, so Back leaves the page rather than stepping through every pick,
138
+ * and the page is not requested again, only the bound cards and tables
139
+ * refetch. A route changed from outside (Back to the page with another
140
+ * period, a link) moves the period.
141
+ */
142
+ export function usePeriodLocation(
143
+ period: UsePeriodReturn,
144
+ options: PeriodLocationOptions,
145
+ ): void {
146
+ const route = useDmsRoute();
147
+ const urlKeys = periodQueryKeys(options.id);
148
+ const remembered = useRememberedPeriod(options.pageId, options.id);
149
+ const writer = usePeriodUrlWriter(urlKeys);
150
+ const choices = {
151
+ presets: period.presets.value,
152
+ comparisons: period.comparisons.value,
153
+ };
154
+ const urlSource = (): PeriodSource => ({
155
+ query: route.query as PeriodQuery,
156
+ keys: urlKeys,
157
+ });
158
+
159
+ applySelection(
160
+ period,
161
+ resolvePeriodSelection(
162
+ [urlSource(), remembered.source()],
163
+ choices,
164
+ options.fallback,
165
+ ),
166
+ );
167
+ onMounted(() => writer.write(readSelection(period)));
168
+
169
+ watch(
170
+ () => selectionText(readSelection(period)),
171
+ () => {
172
+ const selection = readSelection(period);
173
+ remembered.remember(selection);
174
+ writer.write(selection);
175
+ },
176
+ );
177
+
178
+ // Only the route is followed here: the preferences move with the period.
179
+ watch(
180
+ () => urlKeysText(route.query as PeriodQuery, urlKeys),
181
+ (text) => {
182
+ if (writer.isEcho(text)) return;
183
+ const current = readSelection(period);
184
+ const next = resolvePeriodSelection([urlSource()], choices, current);
185
+ if (selectionText(next) === selectionText(current)) return;
186
+ applySelection(period, next);
187
+ },
188
+ );
189
+ }
@@ -18,6 +18,11 @@ export interface TableListDataOptions<T extends Record<string, unknown>> {
18
18
  archiveQuery: ComputedRef<Query>;
19
19
  /** A self-managed display (the kanban) reads its own rows. */
20
20
  isSelfManaged: ComputedRef<boolean>;
21
+ /**
22
+ * Added to each request when it leaves, never watched: the live hold's
23
+ * bound must not list the rows again by changing (see useTableLiveHold).
24
+ */
25
+ heldQuery?: () => Query;
21
26
  }
22
27
 
23
28
  /** One counter: its id and its list query, as the table view builds it. */
@@ -32,8 +37,12 @@ export interface TableCountsDataOptions<T extends Record<string, unknown>> {
32
37
  rows: TableRows<T>;
33
38
  queries: ComputedRef<TableCountRequest[]>;
34
39
  archiveQuery: ComputedRef<Query>;
40
+ /** Added to each request when it leaves, never watched (see the list's). */
41
+ heldQuery?: () => Query;
35
42
  }
36
43
 
44
+ const NO_HELD_QUERY = (): Query => ({});
45
+
37
46
  const EMPTY_LIST_RESULT: TableViewListResponse<never> = {
38
47
  results: [],
39
48
  total: 0,
@@ -51,6 +60,7 @@ export function useTableListData<T extends Record<string, unknown>>(
51
60
  options: TableListDataOptions<T>,
52
61
  ) {
53
62
  const { rows, query, archiveQuery, isSelfManaged } = options;
63
+ const heldQuery = options.heldQuery ?? NO_HELD_QUERY;
54
64
  const key = buildTableDataKey({
55
65
  componentId: options.componentId,
56
66
  pageId: options.pageId,
@@ -61,10 +71,11 @@ export function useTableListData<T extends Record<string, unknown>>(
61
71
  });
62
72
  return useServerRenderedAsyncData(
63
73
  key,
64
- (): Promise<TableViewListResponse<T>> =>
74
+ // `null` while a source waits on its period scope: the table's skeleton.
75
+ (): Promise<TableViewListResponse<T> | null> =>
65
76
  isSelfManaged.value
66
77
  ? Promise.resolve(EMPTY_LIST_RESULT)
67
- : rows.list({ ...query.value, ...archiveQuery.value }),
78
+ : rows.list({ ...query.value, ...archiveQuery.value, ...heldQuery() }),
68
79
  { watch: [query, archiveQuery, isSelfManaged, rows.sourceUrl] },
69
80
  );
70
81
  }
@@ -77,6 +88,7 @@ export function useTableCountsData<T extends Record<string, unknown>>(
77
88
  options: TableCountsDataOptions<T>,
78
89
  ) {
79
90
  const { rows, queries, archiveQuery } = options;
91
+ const heldQuery = options.heldQuery ?? NO_HELD_QUERY;
80
92
  const sourceSuffix = rows.sourceUrl.value ? `-${rows.sourceUrl.value}` : "";
81
93
  return useServerRenderedAsyncData<Record<string, number>>(
82
94
  `table-view-${options.componentId}-${options.pageId}-tab-counts${sourceSuffix}`,
@@ -85,7 +97,7 @@ export function useTableCountsData<T extends Record<string, unknown>>(
85
97
  return await rows.countBatch(
86
98
  queries.value.map(({ id, query }) => ({
87
99
  id,
88
- query: { ...query, ...archiveQuery.value },
100
+ query: { ...query, ...archiveQuery.value, ...heldQuery() },
89
101
  })),
90
102
  );
91
103
  },
@@ -0,0 +1,77 @@
1
+ import { computed, ref, watch, type ComputedRef } from "vue";
2
+ import { usePageLive } from "../../../composables/live/usePageLive";
3
+ import type { LiveGate } from "../live/currentPageLive";
4
+ import { LIVE_HELD_AT_QUERY_KEY } from "./useTableRows";
5
+
6
+ type Query = Record<string, unknown>;
7
+
8
+ export interface TableLiveHoldOptions {
9
+ /** The table declares `liveHold`. */
10
+ isEnabled: boolean;
11
+ /** A `TableView.fromSource` table: its route receives the hold's bound. */
12
+ isSource: boolean;
13
+ /**
14
+ * The table's refresh gate (useRefreshTriggers), read when used: the table
15
+ * sets it up after the list this hold bounds.
16
+ */
17
+ gate: () => LiveGate;
18
+ }
19
+
20
+ export interface TableLiveHold {
21
+ /** The rows are held: the table declares `liveHold` and the page is paused. */
22
+ isHolding: ComputedRef<boolean>;
23
+ /** Events the table received since its rows were held or last shown. */
24
+ newCount: ComputedRef<number>;
25
+ /** Reads the rows once, staying paused, and holds them from now. */
26
+ showNew: () => void;
27
+ /** The bound a source route receives with each request while held. */
28
+ heldQuery: () => Query;
29
+ }
30
+
31
+ const NO_QUERY: Query = {};
32
+
33
+ /**
34
+ * The `liveHold` of a table view: while its live page is paused, the table
35
+ * keeps its rows — the gate already requests nothing on its events — and a
36
+ * source route is told the moment they were frozen, so the user's own search,
37
+ * sort and pagination list the rows as they stood. Show reads them once more
38
+ * without leaving the pause.
39
+ *
40
+ * @internal
41
+ */
42
+ export function useTableLiveHold(options: TableLiveHoldOptions): TableLiveHold {
43
+ const { isEnabled, isPaused } = usePageLive();
44
+ const pausedAt = ref<number | null>(null);
45
+ const isHolding = computed(
46
+ () => options.isEnabled && isEnabled.value && isPaused.value,
47
+ );
48
+
49
+ watch(
50
+ isHolding,
51
+ (holding) => {
52
+ pausedAt.value = holding ? Date.now() : null;
53
+ },
54
+ { immediate: true },
55
+ );
56
+
57
+ // The rows are as fresh as the pause, or as the last read the user asked
58
+ // for since (Show, an action of theirs).
59
+ const heldAt = () =>
60
+ pausedAt.value === null
61
+ ? null
62
+ : Math.max(pausedAt.value, options.gate().lastReadAt.value);
63
+
64
+ return {
65
+ isHolding,
66
+ newCount: computed(() =>
67
+ isHolding.value ? options.gate().missed.value : 0,
68
+ ),
69
+ showNew: () => options.gate().catchUp(),
70
+ heldQuery: () => {
71
+ const at = heldAt();
72
+ return options.isSource && at !== null
73
+ ? { [LIVE_HELD_AT_QUERY_KEY]: new Date(at).toISOString() }
74
+ : NO_QUERY;
75
+ },
76
+ };
77
+ }