@antelopejs/dms 0.7.9 → 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 (172) hide show
  1. package/CHANGELOG.md +29 -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/unit/api-base-url.test.d.ts +1 -0
  15. package/dist/test/unit/api-base-url.test.js +31 -0
  16. package/dist/test/unit/api-base-url.test.js.map +1 -0
  17. package/dist/test/unit/interfaces/dms/page-layout-query.test.d.ts +1 -0
  18. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +106 -0
  19. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -0
  20. package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -0
  21. package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
  22. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.d.ts +1 -0
  23. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +46 -0
  24. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -0
  25. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.d.ts +1 -0
  26. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +26 -0
  27. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -0
  28. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +10 -0
  29. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
  30. package/dist/test/unit/interfaces/dms-base/guard.test.d.ts +1 -0
  31. package/dist/test/unit/interfaces/dms-base/guard.test.js +53 -0
  32. package/dist/test/unit/interfaces/dms-base/guard.test.js.map +1 -0
  33. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +23 -0
  34. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/page-live.test.js +55 -0
  36. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -1
  37. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +17 -0
  38. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -1
  39. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +2 -0
  40. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  41. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +28 -0
  42. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  43. package/frontend-vue/layers/dms-core/app/build/composables/user/preferencesCookie.ts +149 -0
  44. package/frontend-vue/layers/dms-core/app/build/composables/user/tablePreferences.ts +321 -0
  45. package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +4 -3
  46. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +4 -3
  47. package/frontend-vue/layers/dms-core/app/composables/period/types.ts +3 -1
  48. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +2 -2
  49. package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +9 -2
  50. package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +48 -16
  51. package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +21 -4
  52. package/frontend-vue/layers/dms-core/app/composables/user/usePreferences.ts +81 -49
  53. package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +35 -0
  54. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
  55. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
  56. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +10 -1
  57. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +32 -1
  58. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +13 -3
  59. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +27 -3
  60. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +16 -0
  61. package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +75 -0
  62. package/frontend-vue/layers/dms-layout/app/composables/page/usePageLayout.ts +8 -1
  63. package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +13 -2
  64. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +7 -0
  65. package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +7 -0
  66. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -4
  67. package/frontend-vue/layers/dms-layout/app/utils/pageQuery.ts +59 -0
  68. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  69. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  70. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +2 -0
  71. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockInlineStatus.vue +67 -0
  72. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +60 -20
  73. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +15 -8
  74. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +14 -0
  75. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +29 -4
  76. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +76 -28
  77. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +26 -2
  78. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useGuard.ts +115 -0
  79. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +1 -1
  80. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +58 -3
  81. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +86 -0
  82. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +5 -0
  83. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +6 -0
  84. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveCadence.ts +42 -0
  85. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveHolds.ts +66 -0
  86. package/frontend-vue/layers/dms-ui/app/build/composables/live/livePoller.ts +42 -0
  87. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveStorage.ts +42 -0
  88. package/frontend-vue/layers/dms-ui/app/build/composables/live/missedTally.ts +65 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +247 -88
  90. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLivePeriodSpan.ts +33 -0
  91. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +79 -5
  92. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodComparisonControl.ts +70 -0
  93. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +55 -0
  94. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +41 -5
  95. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodDraft.ts +22 -9
  96. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +13 -6
  97. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTable.ts +1 -0
  98. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableColumns.ts +27 -2
  99. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +18 -3
  100. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +19 -26
  101. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useScrolledPastTop.ts +66 -0
  102. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +7 -1
  103. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +65 -11
  104. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTablePreferences.ts +149 -0
  105. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +39 -8
  106. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  107. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +16 -11
  108. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +39 -16
  109. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/pinnedRows.ts +29 -0
  110. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -5
  111. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/sortableColumns.ts +17 -0
  112. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +79 -0
  113. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +46 -1
  114. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +4 -0
  115. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +40 -2
  116. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +11 -1
  117. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +12 -2
  118. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +129 -4
  119. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +6 -0
  120. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +6 -1
  121. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +33 -0
  122. package/frontend-vue/layers/dms-ui/app/components/form/components/ChoiceCards.vue +22 -4
  123. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +74 -0
  124. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -17
  125. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +15 -0
  126. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +12 -15
  127. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +104 -16
  128. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +104 -98
  129. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +11 -1
  130. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +2 -0
  131. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +11 -1
  132. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +15 -1
  133. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +86 -10
  134. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewShiftF.ts +20 -0
  135. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +12 -0
  136. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +5 -0
  137. package/frontend-vue/layers/dms-ui/app/composables/table-view/useTableViewEmptyState.ts +55 -0
  138. package/frontend-vue/layers/dms-ui/app/types/live.ts +47 -1
  139. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +13 -4
  140. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +12 -3
  141. package/frontend-vue/tests/activity-feed-fill-height.test.ts +1 -0
  142. package/frontend-vue/tests/activity-feed-head.test.ts +140 -0
  143. package/frontend-vue/tests/block-not-found.test.ts +220 -0
  144. package/frontend-vue/tests/choice-cards-columns.test.ts +60 -0
  145. package/frontend-vue/tests/confirm-secondary-action.test.ts +269 -0
  146. package/frontend-vue/tests/guard.test.ts +242 -0
  147. package/frontend-vue/tests/header-action-page-query.test.ts +286 -0
  148. package/frontend-vue/tests/live-toggle.test.ts +87 -1
  149. package/frontend-vue/tests/page-layout-registration.test.ts +1 -0
  150. package/frontend-vue/tests/page-live-parity.test.ts +367 -0
  151. package/frontend-vue/tests/page-realtime-remount.test.ts +185 -0
  152. package/frontend-vue/tests/period-selector-parity.test.ts +138 -0
  153. package/frontend-vue/tests/period-selector-url.test.ts +84 -6
  154. package/frontend-vue/tests/period-url-query.test.ts +52 -0
  155. package/frontend-vue/tests/preferences-cookie-size.test.ts +311 -0
  156. package/frontend-vue/tests/row-side-panel.test.ts +109 -10
  157. package/frontend-vue/tests/table-actions-search-label.test.ts +57 -1
  158. package/frontend-vue/tests/table-clear-filters.test.ts +99 -1
  159. package/frontend-vue/tests/table-density-menu.test.ts +4 -3
  160. package/frontend-vue/tests/table-live-hold.test.ts +95 -5
  161. package/frontend-vue/tests/table-row-action-labels.test.ts +181 -0
  162. package/frontend-vue/tests/table-source-period-scope.test.ts +31 -3
  163. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +14 -17
  164. package/frontend-vue/tests/table-view-pinned-rows.test.ts +143 -0
  165. package/frontend-vue/tests/table-view-sortable-columns.test.ts +45 -0
  166. package/frontend-vue/tests/table-view-views.test.ts +56 -15
  167. package/frontend-vue/tests/top-list-card-value-suffix.test.ts +107 -0
  168. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  169. package/package.json +2 -2
  170. package/skills/dms/SKILL.md +9 -3
  171. package/skills/dms-module/SKILL.md +5 -0
  172. package/skills/dms-pages/REFERENCE.md +23 -2
@@ -1,23 +1,46 @@
1
- import { readonly, ref, type Ref } from "vue";
2
- import type { PageLiveOptions } from "../../../types/live";
1
+ import {
2
+ computed,
3
+ readonly,
4
+ ref,
5
+ shallowRef,
6
+ type ComputedRef,
7
+ type Ref,
8
+ } from "vue";
9
+ import type {
10
+ PageLiveNote,
11
+ PageLiveNoteInput,
12
+ PageLiveOptions,
13
+ } from "../../../types/live";
14
+ import { refreshLivePageBlocks } from "../../../utils/blockRefresh";
15
+ import { liveIntervalMs } from "./liveCadence";
16
+ import { LiveHolds, toLiveNote } from "./liveHolds";
17
+ import { LivePoller } from "./livePoller";
18
+ import { liveStorageKey, readStoredPause, storePause } from "./liveStorage";
19
+ import { MissedTally } from "./missedTally";
3
20
 
4
- const MS_PER_SECOND = 1000;
5
- const STORAGE_KEY_PREFIX = "dms.live.paused:";
6
- const PAUSED_VALUE = "1";
21
+ /**
22
+ * Why a page hidden in a background tab is paused.
23
+ *
24
+ * @internal
25
+ */
26
+ export const HIDDEN_TAB_NOTE: PageLiveNote = {
27
+ text: "$dms.live.reason_hidden",
28
+ };
7
29
 
8
30
  /**
9
31
  * One block or table of a live page: what it reads again, and what it missed
10
- * while the page was paused.
32
+ * while the page was paused or the block held.
11
33
  *
12
34
  * @internal
13
35
  */
14
36
  export interface LiveSubscription {
15
37
  /**
16
38
  * An event behind the block arrived. `event` identifies it, so one event
17
- * reaching several blocks counts once in the page's `missedCount`.
39
+ * reaching several blocks counts once in the page's `missedCount`; its
40
+ * `payload.ids` or `payload.count` say how many rows it stands for.
18
41
  */
19
42
  trigger(event?: object): void;
20
- /** Events the block received while paused and has not read yet. */
43
+ /** Rows and events the block received while paused or held, not read yet. */
21
44
  readonly missed: Readonly<Ref<number>>;
22
45
  /** When the block last read through the gate (ms since the epoch), 0 before. */
23
46
  readonly lastReadAt: Readonly<Ref<number>>;
@@ -28,11 +51,29 @@ export interface LiveSubscription {
28
51
  * forgets what was missed, and a waiting trailing read is dropped.
29
52
  */
30
53
  acknowledge(): void;
54
+ /**
55
+ * Holds the block alone while the page stays live: its events are counted,
56
+ * not read. Released on a live page, it reads once if it missed any.
57
+ */
58
+ setHeld(isHeld: boolean): void;
31
59
  unsubscribe(): void;
32
60
  }
33
61
 
62
+ /** How the page starts, beside what the browser remembers. @internal */
63
+ export interface PageLiveStart {
64
+ /** The URL asked for the page paused (`?live=0`): not remembered. */
65
+ isPausedByUrl?: boolean;
66
+ /** Reads the page's blocks while the stream is down; `refreshLivePageBlocks`. */
67
+ poll?: () => void;
68
+ /**
69
+ * Starts live whatever the browser remembers, until `restoreChoice()`: the
70
+ * page renderer restores it once mounted, so hydration matches the server.
71
+ */
72
+ isRestoreDeferred?: boolean;
73
+ }
74
+
34
75
  /**
35
- * The Direct / Pause state of one page and the gate every refetch of its
76
+ * The Live / Pause state of one page and the gate every refetch of its
36
77
  * blocks goes through (see `PageLiveOptions` in interface-dms).
37
78
  *
38
79
  * @internal
@@ -40,97 +81,110 @@ export interface LiveSubscription {
40
81
  export interface PageLiveController {
41
82
  readonly pageId: string;
42
83
  readonly isPaused: Readonly<Ref<boolean>>;
84
+ /** Why the page paused by itself; `null` when live or paused by the user. */
85
+ readonly pauseReason: ComputedRef<PageLiveNote | null>;
43
86
  /** When the page last read its data (ms since the epoch). */
44
87
  readonly lastUpdatedAt: Readonly<Ref<number>>;
45
- /** Distinct events received since the pause. */
88
+ /** Rows and distinct events received since the pause. */
46
89
  readonly missedCount: Readonly<Ref<number>>;
90
+ /** What a module's component says the missed events hold. */
91
+ readonly missedDetail: Readonly<Ref<PageLiveNote | null>>;
92
+ /** The stream is down and the page reads its blocks on a clock. */
93
+ readonly isPolling: Readonly<Ref<boolean>>;
94
+ /**
95
+ * Applies the remembered choice and `?live=0`, once: what the controller
96
+ * does on creation unless `isRestoreDeferred`.
97
+ */
98
+ restoreChoice(): void;
47
99
  pause(): void;
100
+ /** Leaves the user's pause and lifts the holds of the moment. */
48
101
  resume(): void;
49
102
  toggle(): void;
103
+ hold(owner: symbol, reason: PageLiveNoteInput): void;
104
+ release(owner: symbol): void;
105
+ setMissedDetail(detail: PageLiveNoteInput | null): void;
106
+ setHidden(isHidden: boolean): void;
107
+ setStreamConnected(isConnected: boolean): void;
108
+ /** The span of a period the page shows, `null` once it is gone. */
109
+ reportPeriodSpan(owner: symbol, spanMs: number | null): void;
50
110
  subscribe(refresh: () => void): LiveSubscription;
51
111
  dispose(): void;
52
112
  }
53
113
 
54
114
  interface SubscriberState {
55
115
  refresh: () => void;
56
- missed: Ref<number>;
116
+ tally: MissedTally;
57
117
  lastRunAt: Ref<number>;
58
118
  timer: ReturnType<typeof setTimeout> | null;
59
- /** The event the trailing call stands for, counted if the page pauses first. */
60
- pendingEvent: object | undefined;
61
- }
62
-
63
- /** Browser storage of the paused pages; absent on the server or when denied. */
64
- function liveStorage(): Storage | undefined {
65
- try {
66
- return typeof window === "undefined" ? undefined : window.localStorage;
67
- } catch {
68
- return undefined;
69
- }
70
- }
71
-
72
- function readStoredPause(pageId: string): boolean {
73
- return liveStorage()?.getItem(STORAGE_KEY_PREFIX + pageId) === PAUSED_VALUE;
74
- }
75
-
76
- function storePause(pageId: string, isPaused: boolean): void {
77
- const storage = liveStorage();
78
- if (!storage) return;
79
- try {
80
- if (isPaused) storage.setItem(STORAGE_KEY_PREFIX + pageId, PAUSED_VALUE);
81
- else storage.removeItem(STORAGE_KEY_PREFIX + pageId);
82
- } catch {
83
- // A full or locked storage only loses the memory of the choice.
84
- }
119
+ /** The events the trailing call stands for, counted if the page pauses first. */
120
+ pendingEvents: Array<object | undefined>;
121
+ isHeld: boolean;
85
122
  }
86
123
 
87
124
  class PageLive implements PageLiveController {
88
125
  readonly isPaused: Readonly<Ref<boolean>>;
126
+ readonly pauseReason: ComputedRef<PageLiveNote | null>;
89
127
  readonly lastUpdatedAt: Readonly<Ref<number>>;
90
128
  readonly missedCount: Readonly<Ref<number>>;
91
- private readonly paused: Ref<boolean>;
129
+ readonly missedDetail: Readonly<Ref<PageLiveNote | null>>;
130
+ readonly isPolling: Readonly<Ref<boolean>>;
131
+ private readonly paused = ref(false);
132
+ private readonly isUserPaused = ref(false);
133
+ private readonly isPausedByUrl: boolean;
134
+ private isRestored = false;
135
+ private readonly isHidden = ref(false);
136
+ private readonly holds = new LiveHolds();
137
+ private readonly detail = shallowRef<PageLiveNote | null>(null);
92
138
  private readonly updatedAt = ref(Date.now());
93
- private readonly missedTotal = ref(0);
139
+ private readonly tally = new MissedTally();
94
140
  private readonly subscribers = new Set<SubscriberState>();
95
- private countedEvents = new WeakSet<object>();
96
- private readonly minIntervalMs: number;
141
+ private readonly periodSpans = new Map<symbol, number>();
142
+ private readonly poller: LivePoller;
143
+ private readonly storageKey: string;
144
+ private isStreamConnected = true;
97
145
 
98
146
  constructor(
99
147
  readonly pageId: string,
100
- options: PageLiveOptions,
148
+ private readonly options: PageLiveOptions,
149
+ start: PageLiveStart = {},
101
150
  ) {
102
- this.minIntervalMs = (options.maxRate ?? 0) * MS_PER_SECOND;
103
- this.paused = ref(readStoredPause(pageId));
151
+ this.storageKey = liveStorageKey(pageId, options.rememberKey);
152
+ this.isPausedByUrl = !!start.isPausedByUrl;
153
+ this.poller = new LivePoller(
154
+ () => this.intervalMs(),
155
+ start.poll ?? refreshLivePageBlocks,
156
+ );
104
157
  this.isPaused = readonly(this.paused);
158
+ this.pauseReason = computed(() => this.reason());
105
159
  this.lastUpdatedAt = readonly(this.updatedAt);
106
- this.missedCount = readonly(this.missedTotal);
160
+ this.missedCount = this.tally.total;
161
+ this.missedDetail = readonly(this.detail);
162
+ this.isPolling = this.poller.isPolling;
163
+ if (!start.isRestoreDeferred) this.restoreChoice();
164
+ }
165
+
166
+ restoreChoice(): void {
167
+ if (this.isRestored) return;
168
+ this.isRestored = true;
169
+ if (!this.isPausedByUrl && !readStoredPause(this.storageKey)) return;
170
+ this.isUserPaused.value = true;
171
+ this.sync();
107
172
  }
108
173
 
109
174
  pause(): void {
110
- if (this.paused.value) return;
111
- this.paused.value = true;
112
- storePause(this.pageId, true);
113
- // A trailing read still waiting stands for an event not shown yet.
114
- for (const state of this.subscribers) {
115
- if (!state.timer) continue;
116
- const event = state.pendingEvent;
117
- clearTimer(state);
118
- this.countMissed(state, event);
119
- }
175
+ if (this.isUserPaused.value) return;
176
+ this.isUserPaused.value = true;
177
+ storePause(this.storageKey, true);
178
+ this.sync();
120
179
  }
121
180
 
122
181
  resume(): void {
123
- if (!this.paused.value) return;
124
- this.paused.value = false;
125
- storePause(this.pageId, false);
126
- this.missedTotal.value = 0;
127
- this.countedEvents = new WeakSet();
128
- this.updatedAt.value = Date.now();
129
- for (const state of this.subscribers) {
130
- if (state.missed.value === 0) continue;
131
- state.missed.value = 0;
132
- this.run(state);
182
+ if (this.isUserPaused.value) {
183
+ this.isUserPaused.value = false;
184
+ storePause(this.storageKey, false);
133
185
  }
186
+ this.holds.liftAll();
187
+ this.sync();
134
188
  }
135
189
 
136
190
  toggle(): void {
@@ -138,27 +192,55 @@ class PageLive implements PageLiveController {
138
192
  else this.pause();
139
193
  }
140
194
 
195
+ hold(owner: symbol, reason: PageLiveNoteInput): void {
196
+ this.holds.set(owner, reason);
197
+ this.sync();
198
+ }
199
+
200
+ release(owner: symbol): void {
201
+ this.holds.release(owner);
202
+ this.sync();
203
+ }
204
+
205
+ setMissedDetail(detail: PageLiveNoteInput | null): void {
206
+ this.detail.value = detail === null ? null : toLiveNote(detail);
207
+ }
208
+
209
+ setHidden(isHidden: boolean): void {
210
+ this.isHidden.value = isHidden;
211
+ this.sync();
212
+ }
213
+
214
+ setStreamConnected(isConnected: boolean): void {
215
+ this.isStreamConnected = isConnected;
216
+ this.updatePolling();
217
+ }
218
+
219
+ reportPeriodSpan(owner: symbol, spanMs: number | null): void {
220
+ if (spanMs === null) this.periodSpans.delete(owner);
221
+ else this.periodSpans.set(owner, spanMs);
222
+ }
223
+
141
224
  subscribe(refresh: () => void): LiveSubscription {
142
225
  const state: SubscriberState = {
143
226
  refresh,
144
- missed: ref(0),
227
+ tally: new MissedTally(),
145
228
  lastRunAt: ref(0),
146
229
  timer: null,
147
- pendingEvent: undefined,
230
+ pendingEvents: [],
231
+ isHeld: false,
148
232
  };
149
233
  this.subscribers.add(state);
150
234
  return {
151
235
  trigger: (event) => this.trigger(state, event),
152
- missed: readonly(state.missed),
236
+ missed: state.tally.total,
153
237
  lastReadAt: readonly(state.lastRunAt),
154
- catchUp: () => {
155
- state.missed.value = 0;
156
- this.run(state);
157
- },
238
+ catchUp: () => this.catchUp(state),
158
239
  acknowledge: () => {
159
- state.missed.value = 0;
240
+ state.tally.reset();
160
241
  this.markRead(state);
161
242
  },
243
+ setHeld: (isHeld) => this.setHeld(state, isHeld),
162
244
  unsubscribe: () => {
163
245
  clearTimer(state);
164
246
  this.subscribers.delete(state);
@@ -169,27 +251,100 @@ class PageLive implements PageLiveController {
169
251
  dispose(): void {
170
252
  for (const state of this.subscribers) clearTimer(state);
171
253
  this.subscribers.clear();
254
+ this.poller.stop();
255
+ }
256
+
257
+ // A hidden tab outranks everything: nobody sees the page. The user's pause
258
+ // comes next, and says nothing more; a hold names its reason.
259
+ private reason(): PageLiveNote | null {
260
+ if (this.isHidden.value) return HIDDEN_TAB_NOTE;
261
+ if (this.isUserPaused.value) return null;
262
+ return this.holds.activeNote.value;
263
+ }
264
+
265
+ private sync(): void {
266
+ const shouldPause =
267
+ this.isUserPaused.value || this.isHidden.value || this.holds.isActive;
268
+ if (shouldPause !== this.paused.value) {
269
+ if (shouldPause) this.enterPause();
270
+ else this.leavePause();
271
+ }
272
+ this.updatePolling();
273
+ }
274
+
275
+ private enterPause(): void {
276
+ this.paused.value = true;
277
+ // A trailing read still waiting stands for events not shown yet.
278
+ for (const state of this.subscribers) this.flushPending(state, true);
279
+ }
280
+
281
+ private leavePause(): void {
282
+ this.paused.value = false;
283
+ this.tally.reset();
284
+ this.detail.value = null;
285
+ this.updatedAt.value = Date.now();
286
+ for (const state of this.subscribers) {
287
+ if (state.isHeld || state.tally.total.value === 0) continue;
288
+ this.catchUp(state);
289
+ }
290
+ }
291
+
292
+ private updatePolling(): void {
293
+ this.poller.update(!this.isStreamConnected && !this.paused.value);
294
+ }
295
+
296
+ private intervalMs(): number {
297
+ const spans = [...this.periodSpans.values()];
298
+ return liveIntervalMs(
299
+ this.options,
300
+ spans.length > 0 ? Math.max(...spans) : null,
301
+ );
302
+ }
303
+
304
+ private setHeld(state: SubscriberState, isHeld: boolean): void {
305
+ if (state.isHeld === isHeld) return;
306
+ state.isHeld = isHeld;
307
+ if (isHeld) this.flushPending(state, false);
308
+ else if (!this.paused.value && state.tally.total.value > 0) {
309
+ this.catchUp(state);
310
+ }
172
311
  }
173
312
 
174
313
  private trigger(state: SubscriberState, event: object | undefined): void {
175
- if (this.paused.value) this.countMissed(state, event);
314
+ if (this.paused.value) this.countMissed(state, event, true);
315
+ else if (state.isHeld) this.countMissed(state, event, false);
176
316
  else this.triggerDirect(state, event);
177
317
  }
178
318
 
179
319
  // Leading and trailing: the first event of a quiet block reads at once, the
180
- // ones within `maxRate` of a read collapse into one read when it ends, so
181
- // the last event of a burst is never lost.
320
+ // ones within the interval of a read collapse into one read when it ends,
321
+ // so the last event of a burst is never lost.
182
322
  private triggerDirect(state: SubscriberState, event: object | undefined) {
183
- if (state.timer) return;
184
- const wait = state.lastRunAt.value + this.minIntervalMs - Date.now();
323
+ if (state.timer) {
324
+ state.pendingEvents.push(event);
325
+ return;
326
+ }
327
+ const wait = state.lastRunAt.value + this.intervalMs() - Date.now();
185
328
  if (wait <= 0) {
186
329
  this.run(state);
187
330
  return;
188
331
  }
189
- state.pendingEvent = event;
332
+ state.pendingEvents = [event];
190
333
  state.timer = setTimeout(() => this.run(state), wait);
191
334
  }
192
335
 
336
+ private flushPending(state: SubscriberState, isPageMissed: boolean): void {
337
+ if (!state.timer) return;
338
+ const events = state.pendingEvents;
339
+ clearTimer(state);
340
+ for (const event of events) this.countMissed(state, event, isPageMissed);
341
+ }
342
+
343
+ private catchUp(state: SubscriberState): void {
344
+ state.tally.reset();
345
+ this.run(state);
346
+ }
347
+
193
348
  private run(state: SubscriberState): void {
194
349
  this.markRead(state);
195
350
  state.refresh();
@@ -201,29 +356,33 @@ class PageLive implements PageLiveController {
201
356
  this.updatedAt.value = state.lastRunAt.value;
202
357
  }
203
358
 
204
- private countMissed(state: SubscriberState, event: object | undefined) {
205
- state.missed.value += 1;
206
- if (event && this.countedEvents.has(event)) return;
207
- if (event) this.countedEvents.add(event);
208
- this.missedTotal.value += 1;
359
+ private countMissed(
360
+ state: SubscriberState,
361
+ event: object | undefined,
362
+ isPageMissed: boolean,
363
+ ): void {
364
+ state.tally.add(event);
365
+ if (isPageMissed) this.tally.add(event);
209
366
  }
210
367
  }
211
368
 
212
369
  function clearTimer(state: SubscriberState): void {
213
370
  if (state.timer) clearTimeout(state.timer);
214
371
  state.timer = null;
215
- state.pendingEvent = undefined;
372
+ state.pendingEvents = [];
216
373
  }
217
374
 
218
375
  /**
219
- * The controller of a page declared with `live`. Starts in the state the
220
- * browser remembers for `pageId`, Direct otherwise.
376
+ * The controller of a page declared with `live`. Starts paused when the URL
377
+ * asks for it or the browser remembers the page (or its `rememberKey`) paused,
378
+ * live otherwise.
221
379
  *
222
380
  * @internal
223
381
  */
224
382
  export function createPageLiveController(
225
383
  pageId: string,
226
384
  options: PageLiveOptions,
385
+ start?: PageLiveStart,
227
386
  ): PageLiveController {
228
- return new PageLive(pageId, options);
387
+ return new PageLive(pageId, options, start);
229
388
  }
@@ -0,0 +1,33 @@
1
+ import { onScopeDispose, watch } from "vue";
2
+ import { currentPageLive } from "./currentPageLive";
3
+
4
+ /** The range a period selector shows. */
5
+ interface ShownRange {
6
+ from: Date;
7
+ to: Date;
8
+ }
9
+
10
+ /**
11
+ * Tells the live page on screen how long a period the selector shows, so a
12
+ * page declaring `live.slowRateAfter` slows down over a long one. Follows
13
+ * the page's controller as it is installed or replaced, and withdraws the
14
+ * span when the selector goes. A range without bounds (all time) spans more
15
+ * than any period.
16
+ *
17
+ * @internal
18
+ */
19
+ export function useLivePeriodSpan(range: () => ShownRange | null): void {
20
+ if (typeof window === "undefined") return;
21
+ const owner = Symbol("live-period");
22
+ const spanOf = (shown: ShownRange | null) =>
23
+ shown ? shown.to.getTime() - shown.from.getTime() : Number.POSITIVE_INFINITY;
24
+ const stop = watch(
25
+ [currentPageLive, () => spanOf(range())],
26
+ ([controller, span], _previous, onCleanup) => {
27
+ controller?.reportPeriodSpan(owner, span);
28
+ onCleanup(() => controller?.reportPeriodSpan(owner, null));
29
+ },
30
+ { immediate: true },
31
+ );
32
+ onScopeDispose(stop);
33
+ }
@@ -1,4 +1,4 @@
1
- import { onScopeDispose, watch } from "vue";
1
+ import { onMounted, onScopeDispose, watch } from "vue";
2
2
  import type { PageLiveOptions } from "../../../types/live";
3
3
  import { setCurrentPageLive } from "./currentPageLive";
4
4
  import {
@@ -6,25 +6,93 @@ import {
6
6
  type PageLiveController,
7
7
  } from "./pageLiveController";
8
8
 
9
+ /** The URL query key opening a live page paused: `?live=0`. */
10
+ const LIVE_URL_KEY = "live";
11
+ const LIVE_URL_PAUSED = "0";
12
+
9
13
  /** The page the renderer shows: its id and its `live` option. */
10
14
  export interface PageLiveSource {
11
15
  pageId: string | undefined;
12
16
  live: PageLiveOptions | undefined;
13
17
  }
14
18
 
19
+ /** What the page's controller follows around it. @internal */
20
+ export interface PageLiveEnvironment {
21
+ /** The realtime stream is open; while it is not, the page polls. */
22
+ isStreamConnected?: () => boolean;
23
+ }
24
+
25
+ /** The URL asks for the page paused (`?live=0`). @internal */
26
+ export function isPausedByUrl(): boolean {
27
+ return (
28
+ new URLSearchParams(window.location.search).get(LIVE_URL_KEY) ===
29
+ LIVE_URL_PAUSED
30
+ );
31
+ }
32
+
33
+ // Resumed, the page no longer opens paused: a reload keeps it live.
34
+ function dropUrlPause(): void {
35
+ if (!isPausedByUrl()) return;
36
+ const url = new URL(window.location.href);
37
+ url.searchParams.delete(LIVE_URL_KEY);
38
+ window.history.replaceState(window.history.state, "", url.href);
39
+ }
40
+
41
+ /**
42
+ * Binds a controller to the browser around it: a hidden tab pauses it, the
43
+ * stream state switches its polling, and resuming drops `?live=0` from the
44
+ * URL. Returns the unbinding.
45
+ *
46
+ * @internal
47
+ */
48
+ export function bindPageLiveEnvironment(
49
+ controller: PageLiveController,
50
+ environment: PageLiveEnvironment,
51
+ ): () => void {
52
+ const onVisibility = () =>
53
+ controller.setHidden(document.visibilityState === "hidden");
54
+ onVisibility();
55
+ document.addEventListener("visibilitychange", onVisibility);
56
+ const stops = [
57
+ () => document.removeEventListener("visibilitychange", onVisibility),
58
+ watch(
59
+ () => environment.isStreamConnected?.() ?? true,
60
+ (isConnected) => controller.setStreamConnected(isConnected),
61
+ { immediate: true },
62
+ ),
63
+ watch(controller.isPaused, (isPaused) => {
64
+ if (!isPaused) dropUrlPause();
65
+ }),
66
+ ];
67
+ return () => stops.forEach((stop) => stop());
68
+ }
69
+
15
70
  /**
16
71
  * Gives the page on screen its live controller while it declares `live`, and
17
72
  * a new one when the renderer moves to another page. Browser only.
18
73
  *
19
74
  * @internal
20
75
  */
21
- export function usePageLiveHost(source: () => PageLiveSource): void {
76
+ export function usePageLiveHost(
77
+ source: () => PageLiveSource,
78
+ environment: PageLiveEnvironment = {},
79
+ ): void {
22
80
  if (typeof window === "undefined") return;
23
81
  let controller: PageLiveController | null = null;
82
+ let unbind: (() => void) | null = null;
83
+ // The server renders every page live: the remembered pause applies once
84
+ // mounted, so the hydrating render draws the same rows.
85
+ let isMounted = false;
86
+ onMounted(() => {
87
+ isMounted = true;
88
+ controller?.restoreChoice();
89
+ });
24
90
 
25
91
  const install = (next: PageLiveController | null) => {
92
+ unbind?.();
26
93
  controller?.dispose();
27
94
  controller = next;
95
+ unbind = next ? bindPageLiveEnvironment(next, environment) : null;
28
96
  setCurrentPageLive(next);
29
97
  };
30
98
 
@@ -35,9 +103,15 @@ export function usePageLiveHost(source: () => PageLiveSource): void {
35
103
  },
36
104
  (key) => {
37
105
  const { pageId, live } = source();
38
- install(
39
- key && pageId && live ? createPageLiveController(pageId, live) : null,
40
- );
106
+ const next =
107
+ key && pageId && live
108
+ ? createPageLiveController(pageId, live, {
109
+ isPausedByUrl: isPausedByUrl(),
110
+ isRestoreDeferred: true,
111
+ })
112
+ : null;
113
+ install(next);
114
+ if (isMounted) next?.restoreChoice();
41
115
  },
42
116
  { immediate: true },
43
117
  );
@@ -0,0 +1,70 @@
1
+ import {
2
+ DEFAULT_COMPARISONS,
3
+ type PeriodComparison,
4
+ } from "#dms-core/app/composables/period/types";
5
+
6
+ /** How a selector offers its comparisons. */
7
+ export type ComparisonControl = "menu" | "toggle" | "hidden";
8
+
9
+ /** What the selector was given about its comparisons. */
10
+ export interface ComparisonControlOptions {
11
+ comparisons?: PeriodComparison[];
12
+ defaultComparison?: PeriodComparison;
13
+ comparisonControl?: ComparisonControl;
14
+ }
15
+
16
+ /** The comparison control a selector draws, and what its period offers. */
17
+ export interface ResolvedComparisonControl {
18
+ control: ComparisonControl;
19
+ /** The comparisons the period accepts: `none` too behind a toggle. */
20
+ comparisons: PeriodComparison[];
21
+ defaultComparison: PeriodComparison | undefined;
22
+ /** The comparison a toggle switches on, else `null`. */
23
+ toggled: PeriodComparison | null;
24
+ }
25
+
26
+ const NO_COMPARISON: PeriodComparison = "none";
27
+
28
+ function defaultControl(activeCount: number): ComparisonControl {
29
+ return activeCount === 1 ? "toggle" : "menu";
30
+ }
31
+
32
+ function preferredComparison(
33
+ active: PeriodComparison[],
34
+ defaultComparison: PeriodComparison | undefined,
35
+ ): PeriodComparison | null {
36
+ const isActiveDefault =
37
+ defaultComparison !== undefined && active.includes(defaultComparison);
38
+ return isActiveDefault ? defaultComparison : (active[0] ?? null);
39
+ }
40
+
41
+ /**
42
+ * The comparison control of a selector: a `menu` of its comparisons, an
43
+ * inline `toggle` switching one comparison on and off (the default when it
44
+ * offers a single one besides `none`), or `hidden`, the comparison kept on.
45
+ * A selector offering no comparison besides `none` draws nothing.
46
+ */
47
+ export function resolveComparisonControl(
48
+ options: ComparisonControlOptions,
49
+ ): ResolvedComparisonControl {
50
+ const offered = options.comparisons ?? DEFAULT_COMPARISONS;
51
+ const active = offered.filter((entry) => entry !== NO_COMPARISON);
52
+ const preferred = preferredComparison(active, options.defaultComparison);
53
+ const control: ComparisonControl = preferred
54
+ ? (options.comparisonControl ?? defaultControl(active.length))
55
+ : "hidden";
56
+ const isToggle = control === "toggle";
57
+ const isHidden = control === "hidden";
58
+ return {
59
+ control,
60
+ comparisons:
61
+ isToggle && !offered.includes(NO_COMPARISON)
62
+ ? [NO_COMPARISON, ...offered]
63
+ : [...offered],
64
+ defaultComparison:
65
+ isHidden && preferred
66
+ ? (options.defaultComparison ?? preferred)
67
+ : options.defaultComparison,
68
+ toggled: isToggle ? preferred : null,
69
+ };
70
+ }