@antelopejs/dms 0.7.8 → 0.7.10

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 (184) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/config.d.ts +2 -0
  3. package/dist/config.js +9 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/implementations/dms/api-base-url.d.ts +1 -0
  6. package/dist/implementations/dms/api-base-url.js +6 -0
  7. package/dist/implementations/dms/api-base-url.js.map +1 -0
  8. package/dist/implementations/dms/frontend-modules.js +7 -6
  9. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  10. package/dist/implementations/dms/page.js +8 -2
  11. package/dist/implementations/dms/page.js.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/test/attachment-host/capabilities.d.ts +25 -0
  15. package/dist/test/attachment-host/capabilities.js +53 -1
  16. package/dist/test/attachment-host/capabilities.js.map +1 -1
  17. package/dist/test/integration/table-view-capabilities.test.js +13 -0
  18. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  19. package/dist/test/unit/api-base-url.test.d.ts +1 -0
  20. package/dist/test/unit/api-base-url.test.js +31 -0
  21. package/dist/test/unit/api-base-url.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms/page-layout-query.test.d.ts +1 -0
  23. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +106 -0
  24. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -0
  25. package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -0
  26. package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
  27. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.d.ts +1 -0
  28. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +46 -0
  29. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -0
  30. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.d.ts +1 -0
  31. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +26 -0
  32. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -0
  33. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +10 -0
  34. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/guard.test.d.ts +1 -0
  36. package/dist/test/unit/interfaces/dms-base/guard.test.js +53 -0
  37. package/dist/test/unit/interfaces/dms-base/guard.test.js.map +1 -0
  38. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +23 -0
  39. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
  40. package/dist/test/unit/interfaces/dms-base/page-live.test.js +55 -0
  41. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -1
  42. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +17 -0
  43. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -1
  44. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +8 -1
  45. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  46. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +28 -0
  47. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  48. package/frontend-vue/layers/dms-core/app/build/composables/user/preferencesCookie.ts +149 -0
  49. package/frontend-vue/layers/dms-core/app/build/composables/user/tablePreferences.ts +321 -0
  50. package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +4 -3
  51. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +4 -3
  52. package/frontend-vue/layers/dms-core/app/composables/period/types.ts +3 -1
  53. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +2 -2
  54. package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +9 -2
  55. package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +48 -16
  56. package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +21 -4
  57. package/frontend-vue/layers/dms-core/app/composables/user/usePreferences.ts +81 -49
  58. package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +35 -0
  59. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  60. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  61. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +10 -1
  62. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +32 -1
  63. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +13 -3
  64. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +27 -3
  65. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +16 -0
  66. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +75 -0
  67. package/frontend-vue/layers/dms-layout/app/composables/page/usePageLayout.ts +8 -1
  68. package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +13 -2
  69. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +7 -0
  70. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +7 -0
  71. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -4
  72. package/frontend-vue/layers/dms-layout/app/utils/pageQuery.ts +59 -0
  73. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  74. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  75. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +2 -0
  76. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockInlineStatus.vue +67 -0
  77. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +60 -20
  78. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +15 -8
  79. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +14 -0
  80. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +29 -4
  81. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +76 -28
  82. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +26 -2
  83. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useGuard.ts +115 -0
  84. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +1 -1
  85. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +58 -3
  86. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +86 -0
  87. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +5 -0
  88. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +6 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveCadence.ts +42 -0
  90. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveHolds.ts +66 -0
  91. package/frontend-vue/layers/dms-ui/app/build/composables/live/livePoller.ts +42 -0
  92. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveStorage.ts +42 -0
  93. package/frontend-vue/layers/dms-ui/app/build/composables/live/missedTally.ts +65 -0
  94. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +247 -88
  95. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLivePeriodSpan.ts +33 -0
  96. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +79 -5
  97. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodComparisonControl.ts +70 -0
  98. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +55 -0
  99. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +41 -5
  100. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodDraft.ts +22 -9
  101. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +13 -6
  102. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTable.ts +1 -0
  103. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableColumns.ts +27 -2
  104. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +18 -3
  105. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +19 -26
  106. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useScrolledPastTop.ts +66 -0
  107. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +7 -1
  108. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +65 -11
  109. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTablePreferences.ts +149 -0
  110. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +39 -8
  111. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  112. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +16 -11
  113. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +39 -16
  114. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/pinnedRows.ts +29 -0
  115. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -5
  116. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/sortableColumns.ts +17 -0
  117. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +79 -0
  118. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +46 -1
  119. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
  120. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +4 -0
  121. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +40 -2
  122. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +11 -1
  123. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +12 -2
  124. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +129 -4
  125. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +6 -0
  126. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +6 -1
  127. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +33 -0
  128. package/frontend-vue/layers/dms-ui/app/components/form/components/ChoiceCards.vue +22 -4
  129. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +74 -0
  130. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -17
  131. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +15 -0
  132. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +12 -15
  133. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +104 -16
  134. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +113 -99
  135. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +11 -1
  136. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +2 -0
  137. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +11 -1
  138. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +15 -1
  139. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +86 -10
  140. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewShiftF.ts +20 -0
  141. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +12 -0
  142. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +5 -0
  143. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
  144. package/frontend-vue/layers/dms-ui/app/composables/table-view/useTableViewEmptyState.ts +55 -0
  145. package/frontend-vue/layers/dms-ui/app/types/live.ts +47 -1
  146. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +2 -0
  147. package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
  148. package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
  149. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +13 -4
  150. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +12 -3
  151. package/frontend-vue/tests/activity-feed-fill-height.test.ts +1 -0
  152. package/frontend-vue/tests/activity-feed-head.test.ts +140 -0
  153. package/frontend-vue/tests/block-not-found.test.ts +220 -0
  154. package/frontend-vue/tests/choice-cards-columns.test.ts +60 -0
  155. package/frontend-vue/tests/component-id.test.ts +18 -0
  156. package/frontend-vue/tests/confirm-secondary-action.test.ts +269 -0
  157. package/frontend-vue/tests/guard.test.ts +242 -0
  158. package/frontend-vue/tests/header-action-page-query.test.ts +286 -0
  159. package/frontend-vue/tests/live-toggle.test.ts +87 -1
  160. package/frontend-vue/tests/page-layout-registration.test.ts +1 -0
  161. package/frontend-vue/tests/page-live-parity.test.ts +367 -0
  162. package/frontend-vue/tests/page-realtime-remount.test.ts +185 -0
  163. package/frontend-vue/tests/period-selector-parity.test.ts +138 -0
  164. package/frontend-vue/tests/period-selector-url.test.ts +84 -6
  165. package/frontend-vue/tests/period-url-query.test.ts +52 -0
  166. package/frontend-vue/tests/preferences-cookie-size.test.ts +311 -0
  167. package/frontend-vue/tests/row-side-panel.test.ts +109 -10
  168. package/frontend-vue/tests/table-actions-search-label.test.ts +57 -1
  169. package/frontend-vue/tests/table-clear-filters.test.ts +99 -1
  170. package/frontend-vue/tests/table-density-menu.test.ts +4 -3
  171. package/frontend-vue/tests/table-live-hold.test.ts +95 -5
  172. package/frontend-vue/tests/table-row-action-labels.test.ts +181 -0
  173. package/frontend-vue/tests/table-row-hover.test.ts +32 -0
  174. package/frontend-vue/tests/table-source-period-scope.test.ts +31 -3
  175. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +14 -17
  176. package/frontend-vue/tests/table-view-pinned-rows.test.ts +143 -0
  177. package/frontend-vue/tests/table-view-sortable-columns.test.ts +45 -0
  178. package/frontend-vue/tests/table-view-views.test.ts +56 -15
  179. package/frontend-vue/tests/top-list-card-value-suffix.test.ts +107 -0
  180. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  181. package/package.json +2 -2
  182. package/skills/dms/SKILL.md +9 -3
  183. package/skills/dms-module/SKILL.md +5 -0
  184. package/skills/dms-pages/REFERENCE.md +23 -2
@@ -0,0 +1,321 @@
1
+ import { isBranch } from "./preferencesCookie";
2
+
3
+ /**
4
+ * @internal
5
+ * The state of a table view that the server render reads to list and draw
6
+ * the right rows: kept in the preferences cookie, compactly.
7
+ */
8
+ export type ServerTableField =
9
+ | "sorting"
10
+ | "columnFilters"
11
+ | "columnVisibility"
12
+ | "pagination"
13
+ | "paginationScope"
14
+ | "activeTab"
15
+ | "viewMode"
16
+ | "activeView";
17
+
18
+ /**
19
+ * @internal
20
+ * The state of a table view that only the browser draws: kept in
21
+ * `localStorage`, applied once the table is mounted.
22
+ */
23
+ export type LocalTableField =
24
+ | "columnOrder"
25
+ | "columnSizing"
26
+ | "density"
27
+ | "filtersOpen"
28
+ | "kanbanGroupBy"
29
+ | "groupedGroupBy"
30
+ | "userViews";
31
+
32
+ /** @internal */
33
+ export type TableField = ServerTableField | LocalTableField;
34
+
35
+ /** @internal A table's entry in the cookie, by short field key. */
36
+ export type CompactTableEntry = Record<string, unknown>;
37
+
38
+ /** @internal The collection of the preferences tree holding table entries. */
39
+ export const TABLES_PREFERENCE_KEY = "tables";
40
+
41
+ /** @internal The prefix of a table's `localStorage` key, versioned. */
42
+ export const LOCAL_TABLE_KEY_PREFIX = "dms-table-preferences:v1:";
43
+
44
+ const TABLE_ID_LENGTH = 8;
45
+ const HASH_SEEDS = [0xdeadbeef, 0x41c6ce57];
46
+ const HASH_MULTIPLIERS = [2654435761, 1597334677];
47
+ const HASH_MIX = [2246822507, 3266489909];
48
+ const HASH_RADIX = 36;
49
+ const HIGH_BITS = 2097151;
50
+ const HIGH_BITS_SHIFT = 4294967296;
51
+ const DESCENDING_PREFIX = "-";
52
+ const HIDDEN_PREFIX = "-";
53
+ const SHOWN_PREFIX = "+";
54
+ const DEFAULT_FILTER_MODE = "is";
55
+ const FIRST_PAGE_INDEX = 0;
56
+
57
+ /**
58
+ * @internal
59
+ * A 53-bit hash of `text` in base 36 (cyrb53): short, stable on the server
60
+ * and in the browser.
61
+ */
62
+ export const hashText = (text: string): string => {
63
+ let [first, second] = HASH_SEEDS as [number, number];
64
+ for (let index = 0; index < text.length; index += 1) {
65
+ const code = text.charCodeAt(index);
66
+ first = Math.imul(first ^ code, HASH_MULTIPLIERS[0]!);
67
+ second = Math.imul(second ^ code, HASH_MULTIPLIERS[1]!);
68
+ }
69
+ first = Math.imul(first ^ (first >>> 16), HASH_MIX[0]!);
70
+ first ^= Math.imul(second ^ (second >>> 13), HASH_MIX[1]!);
71
+ second = Math.imul(second ^ (second >>> 16), HASH_MIX[0]!);
72
+ second ^= Math.imul(first ^ (first >>> 13), HASH_MIX[1]!);
73
+ return (HIGH_BITS_SHIFT * (HIGH_BITS & second) + (first >>> 0)).toString(
74
+ HASH_RADIX,
75
+ );
76
+ };
77
+
78
+ /**
79
+ * @internal
80
+ * The short key of a table view's preferences: its component and its page,
81
+ * hashed, so that neither a long id nor a dotted page id reaches the cookie.
82
+ */
83
+ export const tablePreferenceId = (
84
+ componentId: string,
85
+ pageId: string,
86
+ ): string =>
87
+ hashText(`${componentId}\u0000${pageId}`)
88
+ .padStart(TABLE_ID_LENGTH, "0")
89
+ .slice(0, TABLE_ID_LENGTH);
90
+
91
+ interface SortEntry {
92
+ id: string;
93
+ desc: boolean;
94
+ }
95
+
96
+ interface FilterEntry {
97
+ accessorKey: string;
98
+ value?: unknown;
99
+ mode: string;
100
+ pinned?: boolean;
101
+ initialValue?: unknown;
102
+ }
103
+
104
+ interface PaginationEntry {
105
+ pageIndex: number;
106
+ pageSize: number;
107
+ }
108
+
109
+ /**
110
+ * @internal
111
+ * How a server field is kept: its key in the table's entry, and its compact
112
+ * form. `encode` gets the field's default so that it can keep only what
113
+ * differs from it; a value whose form equals the default's is not written.
114
+ */
115
+ export interface ServerFieldCodec {
116
+ key: string;
117
+ encode: (value: unknown, defaultValue: unknown) => unknown;
118
+ decode: (stored: unknown, defaultValue: unknown) => unknown;
119
+ }
120
+
121
+ const encodeSorting = (value: unknown): unknown => {
122
+ const sorts = (Array.isArray(value) ? value : []) as SortEntry[];
123
+ const tokens = sorts.map(
124
+ (sort) => `${sort.desc ? DESCENDING_PREFIX : ""}${sort.id}`,
125
+ );
126
+ return tokens.length === 1 ? tokens[0] : tokens;
127
+ };
128
+
129
+ const decodeSorting = (stored: unknown): SortEntry[] => {
130
+ const tokens = typeof stored === "string" ? [stored] : stored;
131
+ if (!Array.isArray(tokens)) throw new TypeError("sorting");
132
+ return tokens.map((token: string) => ({
133
+ id: token.startsWith(DESCENDING_PREFIX) ? token.slice(1) : token,
134
+ desc: token.startsWith(DESCENDING_PREFIX),
135
+ }));
136
+ };
137
+
138
+ const isSameValue = (left: unknown, right: unknown): boolean =>
139
+ JSON.stringify(left) === JSON.stringify(right);
140
+
141
+ // A pinned filter still on its initial value comes back from the module's
142
+ // own declaration: only the user's own filters and changed values are kept.
143
+ const encodeFilters = (value: unknown): unknown =>
144
+ ((Array.isArray(value) ? value : []) as FilterEntry[])
145
+ .filter(
146
+ (filter) =>
147
+ !filter.pinned || !isSameValue(filter.value, filter.initialValue),
148
+ )
149
+ .map((filter) =>
150
+ filter.mode === DEFAULT_FILTER_MODE
151
+ ? [filter.accessorKey, filter.value ?? null]
152
+ : [filter.accessorKey, filter.value ?? null, filter.mode],
153
+ );
154
+
155
+ const decodeFilters = (stored: unknown): FilterEntry[] => {
156
+ if (!Array.isArray(stored)) throw new TypeError("columnFilters");
157
+ return stored.map(([accessorKey, value, mode]: unknown[]) => ({
158
+ accessorKey: String(accessorKey),
159
+ value,
160
+ mode: typeof mode === "string" ? mode : DEFAULT_FILTER_MODE,
161
+ }));
162
+ };
163
+
164
+ const visibilityOf = (value: unknown): Record<string, boolean> =>
165
+ isBranch(value) ? (value as Record<string, boolean>) : {};
166
+
167
+ // A column missing from a visibility state is shown.
168
+ const encodeVisibility = (value: unknown, defaultValue: unknown): unknown => {
169
+ const defaults = visibilityOf(defaultValue);
170
+ return Object.entries(visibilityOf(value))
171
+ .filter(([column, isShown]) => isShown !== (defaults[column] ?? true))
172
+ .map(
173
+ ([column, isShown]) =>
174
+ `${isShown ? SHOWN_PREFIX : HIDDEN_PREFIX}${column}`,
175
+ );
176
+ };
177
+
178
+ const decodeVisibility = (stored: unknown, defaultValue: unknown): unknown => {
179
+ if (!Array.isArray(stored)) throw new TypeError("columnVisibility");
180
+ const changes = stored.map((token: string) => [
181
+ token.slice(1),
182
+ token.startsWith(SHOWN_PREFIX),
183
+ ]);
184
+ return { ...visibilityOf(defaultValue), ...Object.fromEntries(changes) };
185
+ };
186
+
187
+ const encodePagination = (value: unknown): unknown => {
188
+ const { pageIndex, pageSize } = value as PaginationEntry;
189
+ return pageIndex === FIRST_PAGE_INDEX ? pageSize : [pageSize, pageIndex];
190
+ };
191
+
192
+ const decodePagination = (stored: unknown): PaginationEntry => {
193
+ const [pageSize, pageIndex] = Array.isArray(stored)
194
+ ? stored
195
+ : [stored, FIRST_PAGE_INDEX];
196
+ if (typeof pageSize !== "number" || typeof pageIndex !== "number") {
197
+ throw new TypeError("pagination");
198
+ }
199
+ return { pageIndex, pageSize };
200
+ };
201
+
202
+ const plainField = (key: string): ServerFieldCodec => ({
203
+ key,
204
+ encode: (value) => value,
205
+ decode: (stored) => stored,
206
+ });
207
+
208
+ /**
209
+ * @internal
210
+ * The server fields, each with its one-letter key and compact form: a sort is
211
+ * `"-name"`, a filter `["status", "open"]`, a hidden column `"-email"`, a
212
+ * page size `50`.
213
+ */
214
+ export const SERVER_TABLE_FIELDS: Record<ServerTableField, ServerFieldCodec> = {
215
+ sorting: { key: "s", encode: encodeSorting, decode: decodeSorting },
216
+ columnFilters: { key: "f", encode: encodeFilters, decode: decodeFilters },
217
+ columnVisibility: {
218
+ key: "v",
219
+ encode: encodeVisibility,
220
+ decode: decodeVisibility,
221
+ },
222
+ pagination: { key: "p", encode: encodePagination, decode: decodePagination },
223
+ paginationScope: plainField("q"),
224
+ activeTab: plainField("t"),
225
+ viewMode: plainField("d"),
226
+ activeView: plainField("w"),
227
+ };
228
+
229
+ /** @internal Whether `field` is kept in the cookie. */
230
+ export const isServerTableField = (
231
+ field: TableField,
232
+ ): field is ServerTableField => field in SERVER_TABLE_FIELDS;
233
+
234
+ /**
235
+ * @internal
236
+ * `value`'s form in the entry, or `undefined` when it equals the default's
237
+ * and is not to be written.
238
+ */
239
+ export const encodeServerField = (
240
+ field: ServerTableField,
241
+ value: unknown,
242
+ defaultValue: unknown,
243
+ ): unknown => {
244
+ const codec = SERVER_TABLE_FIELDS[field];
245
+ if (value === undefined) return undefined;
246
+ const encoded = codec.encode(value, defaultValue);
247
+ const encodedDefault =
248
+ defaultValue === undefined
249
+ ? undefined
250
+ : codec.encode(defaultValue, defaultValue);
251
+ return isSameValue(encoded, encodedDefault) ? undefined : encoded;
252
+ };
253
+
254
+ /** @internal The value `entry` keeps for `field`, else `defaultValue`. */
255
+ export const decodeServerField = <T>(
256
+ field: ServerTableField,
257
+ entry: CompactTableEntry | undefined,
258
+ defaultValue: T,
259
+ ): T => {
260
+ const codec = SERVER_TABLE_FIELDS[field];
261
+ const stored = entry?.[codec.key];
262
+ if (stored === undefined) return defaultValue;
263
+ try {
264
+ return codec.decode(stored, defaultValue) as T;
265
+ } catch {
266
+ return defaultValue;
267
+ }
268
+ };
269
+
270
+ /** @internal Whether `value` is the field's default, not to be written. */
271
+ export const isDefaultValue = (
272
+ value: unknown,
273
+ defaultValue: unknown,
274
+ ): boolean => value === undefined || isSameValue(value, defaultValue);
275
+
276
+ const localKey = (id: string): string => `${LOCAL_TABLE_KEY_PREFIX}${id}`;
277
+
278
+ const browserStorage = (): Storage | undefined =>
279
+ typeof localStorage === "undefined" ? undefined : localStorage;
280
+
281
+ /**
282
+ * @internal
283
+ * The browser-only state of a table; empty on the server, or when the
284
+ * storage is unavailable or holds something else.
285
+ */
286
+ export const readLocalTableEntry = (id: string): Record<string, unknown> => {
287
+ try {
288
+ const stored = browserStorage()?.getItem(localKey(id));
289
+ const entry: unknown = stored ? JSON.parse(stored) : {};
290
+ return isBranch(entry) ? entry : {};
291
+ } catch {
292
+ return {};
293
+ }
294
+ };
295
+
296
+ /**
297
+ * @internal
298
+ * Writes the browser-only state of a table, removing its key once empty. A
299
+ * storage that refuses the write (full, disabled) leaves the table as it is.
300
+ */
301
+ export const writeLocalTableEntry = (
302
+ id: string,
303
+ entry: Record<string, unknown>,
304
+ ): void => {
305
+ try {
306
+ const storage = browserStorage();
307
+ if (Object.keys(entry).length === 0) storage?.removeItem(localKey(id));
308
+ else storage?.setItem(localKey(id), JSON.stringify(entry));
309
+ } catch {
310
+ // The state is lost on the next visit, not the current one.
311
+ }
312
+ };
313
+
314
+ /** @internal Drops the browser-only state of every table. */
315
+ export const clearLocalTableEntries = (): void => {
316
+ const storage = browserStorage();
317
+ if (!storage) return;
318
+ Object.keys(storage)
319
+ .filter((key) => key.startsWith(LOCAL_TABLE_KEY_PREFIX))
320
+ .forEach((key) => storage.removeItem(key));
321
+ };
@@ -65,14 +65,15 @@ const COMPARISON_RESOLVERS: Record<
65
65
  * `previous-period` returns the equal-length span immediately before the
66
66
  * range, offset by a single millisecond so the two never overlap. Ranges of
67
67
  * whole days are measured in calendar days, so daylight saving changes don't
68
- * move their midnight boundaries.
68
+ * move their midnight boundaries. A range with no bounds (`all`) has nothing
69
+ * to compare against: `null`.
69
70
  */
70
71
  export function resolveComparisonRange(
71
72
  comparison: PeriodComparison,
72
- range: PeriodRange,
73
+ range: PeriodRange | null,
73
74
  customRange: PeriodRange | null,
74
75
  ): PeriodRange | null {
75
- if (comparison === "none") return null;
76
+ if (comparison === "none" || !range) return null;
76
77
  if (comparison === "custom") return customRange;
77
78
  return COMPARISON_RESOLVERS[comparison](range);
78
79
  }
@@ -67,7 +67,7 @@ function endOfYear(date: Date, yearOffset = 0): Date {
67
67
 
68
68
  const PRESET_RANGE_RESOLVERS: Record<
69
69
  Exclude<PeriodPreset, "custom">,
70
- (now: Date) => PeriodRange
70
+ (now: Date) => PeriodRange | null
71
71
  > = {
72
72
  "last-hour": (now) => ({ from: addHours(now, -1), to: now }),
73
73
  "last-24h": (now) => ({ from: addDays(now, -1), to: now }),
@@ -112,6 +112,7 @@ const PRESET_RANGE_RESOLVERS: Record<
112
112
  from: startOfYear(now, -1),
113
113
  to: endOfYear(now, -1),
114
114
  }),
115
+ all: () => null,
115
116
  };
116
117
 
117
118
  const RELATIVE_PRESETS = new Set<PeriodPreset>(["last-hour", "last-24h"]);
@@ -130,13 +131,13 @@ export function isRelativePreset(preset: PeriodPreset): boolean {
130
131
  *
131
132
  * Calendar presets snap to day boundaries (start-of-day to end-of-day), while
132
133
  * the relative ones (`last-hour`, `last-24h`) keep instant bounds so an ops
133
- * dashboard can read sub-day granularity.
134
+ * dashboard can read sub-day granularity. `all` has no bounds: `null`.
134
135
  */
135
136
  export function resolvePresetRange(
136
137
  preset: PeriodPreset,
137
138
  now: Date,
138
139
  fallbackRange: PeriodRange,
139
- ): PeriodRange {
140
+ ): PeriodRange | null {
140
141
  if (preset === "custom") return fallbackRange;
141
142
  return PRESET_RANGE_RESOLVERS[preset](now);
142
143
  }
@@ -12,6 +12,7 @@ export type PeriodPreset =
12
12
  | "last-quarter"
13
13
  | "ytd"
14
14
  | "last-year"
15
+ | "all"
15
16
  | "custom";
16
17
 
17
18
  export type PeriodComparison =
@@ -28,7 +29,8 @@ export interface PeriodRange {
28
29
  export interface PeriodState {
29
30
  preset: PeriodPreset;
30
31
  comparison: PeriodComparison;
31
- range: PeriodRange;
32
+ /** The window, or `null` for `all`: every period, with no bounds. */
33
+ range: PeriodRange | null;
32
34
  compareRange: PeriodRange | null;
33
35
  key: string;
34
36
  }
@@ -100,8 +100,8 @@ function buildKey(state: Omit<PeriodState, "key">): string {
100
100
  return [
101
101
  state.preset,
102
102
  state.comparison,
103
- state.range.from.toISOString(),
104
- state.range.to.toISOString(),
103
+ state.range ? state.range.from.toISOString() : "-",
104
+ state.range ? state.range.to.toISOString() : "-",
105
105
  state.compareRange ? state.compareRange.from.toISOString() : "-",
106
106
  state.compareRange ? state.compareRange.to.toISOString() : "-",
107
107
  ].join("|");
@@ -67,14 +67,21 @@ export function usePeriodScope(
67
67
  });
68
68
  }
69
69
 
70
+ /**
71
+ * `url` with `state` as query parameters: `from` / `to`, `compareFrom` /
72
+ * `compareTo` when a comparison range is set, `preset` and `comparison`.
73
+ * Every period (`all`) has no bounds, so it sends no `from` / `to`.
74
+ */
70
75
  export function appendPeriodToUrl(
71
76
  url: string | undefined,
72
77
  state: PeriodState | null,
73
78
  ): string | undefined {
74
79
  if (!url || !state) return url;
75
80
  const params = new URLSearchParams();
76
- params.set("from", state.range.from.toISOString());
77
- params.set("to", state.range.to.toISOString());
81
+ if (state.range) {
82
+ params.set("from", state.range.from.toISOString());
83
+ params.set("to", state.range.to.toISOString());
84
+ }
78
85
  if (state.compareRange) {
79
86
  params.set("compareFrom", state.compareRange.from.toISOString());
80
87
  params.set("compareTo", state.compareRange.to.toISOString());
@@ -12,27 +12,59 @@ export interface PageRealtimeApi {
12
12
  pageId: Ref<string | undefined>;
13
13
  }
14
14
 
15
+ interface PageClaim {
16
+ pageId: string | undefined;
17
+ }
18
+
19
+ /**
20
+ * Every mounted page instance holding a page id, in the order each last set it.
21
+ * A page remounted at the same path (only its query changed) sets up its new
22
+ * instance before or after the old one is disposed, depending on the
23
+ * transition; the session follows the latest live claim, so neither order
24
+ * drops the page.
25
+ */
26
+ const pageClaims: PageClaim[] = [];
27
+
28
+ const applyLatestClaim = (user: UserRealtimeApi): void => {
29
+ void user.setPage(pageClaims.at(-1)?.pageId ?? null);
30
+ };
31
+
32
+ const raiseClaim = (claim: PageClaim): void => {
33
+ const index = pageClaims.indexOf(claim);
34
+ if (index !== -1) pageClaims.splice(index, 1);
35
+ pageClaims.push(claim);
36
+ };
37
+
38
+ const dropClaim = (claim: PageClaim): void => {
39
+ const index = pageClaims.indexOf(claim);
40
+ if (index !== -1) pageClaims.splice(index, 1);
41
+ };
42
+
15
43
  export function usePageRealtime(
16
44
  pageId: MaybeRefOrGetter<string>,
17
45
  ): PageRealtimeApi {
18
46
  const user: UserRealtimeApi = useUserRealtime();
19
47
  const resolvedPageId = computed(() => toValue(pageId) || undefined);
20
- let lastSet: string | undefined;
21
-
22
- watch(
23
- resolvedPageId,
24
- (next) => {
25
- lastSet = next;
26
- void user.setPage(next ?? null);
27
- },
28
- { immediate: true },
29
- );
30
-
31
- onScopeDispose(() => {
32
- if (user.pageId.value === lastSet) {
33
- void user.setPage(null);
34
- }
35
- });
48
+
49
+ if (import.meta.env.SSR) {
50
+ // No dispose runs on the server, so a claim there would never be released.
51
+ void user.setPage(resolvedPageId.value ?? null);
52
+ } else {
53
+ const claim: PageClaim = { pageId: undefined };
54
+ watch(
55
+ resolvedPageId,
56
+ (next) => {
57
+ claim.pageId = next;
58
+ raiseClaim(claim);
59
+ applyLatestClaim(user);
60
+ },
61
+ { immediate: true },
62
+ );
63
+ onScopeDispose(() => {
64
+ dropClaim(claim);
65
+ applyLatestClaim(user);
66
+ });
67
+ }
36
68
 
37
69
  return {
38
70
  subscribe: user.subscribe,
@@ -60,6 +60,7 @@ interface UserRealtimeState {
60
60
  appliedPageId: Ref<string | null>;
61
61
  handlers: Map<string, Set<RealtimeTopicHandler>>;
62
62
  snapshots: Map<string, RealtimeSnapshotEvent>;
63
+ reconciling: Promise<void>;
63
64
  cancelConnect?: () => void;
64
65
  }
65
66
 
@@ -247,7 +248,7 @@ const callPageEndpoint = async (
247
248
  });
248
249
  };
249
250
 
250
- const reconcilePage = async (
251
+ const applyDesiredPage = async (
251
252
  state: UserRealtimeState,
252
253
  baseUrl: string,
253
254
  ): Promise<void> => {
@@ -259,9 +260,24 @@ const reconcilePage = async (
259
260
  // A reconnect during the call replaced the session: the page is applied to
260
261
  // the dead one, and the new one still needs it.
261
262
  if (state.sessionId.value !== sessionId) return;
262
- if ((state.pageId.value ?? null) === desired) {
263
- state.appliedPageId.value = desired;
264
- }
263
+ state.appliedPageId.value = desired;
264
+ };
265
+
266
+ /**
267
+ * Runs one page call at a time. Two overlapping calls would each compare the
268
+ * desired page with what the server held before either landed: an unsubscribe
269
+ * in flight followed by a subscribe to the same page would skip the
270
+ * subscribe, and the session would end up with no page at all.
271
+ */
272
+ const reconcilePage = (
273
+ state: UserRealtimeState,
274
+ baseUrl: string,
275
+ ): Promise<void> => {
276
+ const run = state.reconciling
277
+ .catch(() => undefined)
278
+ .then(() => applyDesiredPage(state, baseUrl));
279
+ state.reconciling = run;
280
+ return run;
265
281
  };
266
282
 
267
283
  /**
@@ -290,6 +306,7 @@ const buildApi = (): UserRealtimeApi => {
290
306
  appliedPageId,
291
307
  handlers: new Map(),
292
308
  snapshots: new Map(),
309
+ reconciling: Promise.resolve(),
293
310
  };
294
311
 
295
312
  const config = useDmsRuntimeConfig();