@antelopejs/dms 0.7.15 → 0.7.16

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 (25) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +16 -0
  3. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -1
  4. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +66 -0
  5. package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
  6. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +19 -1
  7. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +52 -28
  8. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
  9. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
  10. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +48 -4
  11. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +6 -2
  12. package/frontend-vue/layers/dms-layout/app/composables/layout/usePageHeaderActions.ts +32 -1
  13. package/frontend-vue/layers/dms-ui/app/build/components/period/PeriodRangeCalendar.vue +17 -1
  14. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +49 -0
  15. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +34 -11
  16. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +47 -0
  17. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +32 -6
  18. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +14 -4
  19. package/frontend-vue/tests/header-action-page-query.test.ts +96 -1
  20. package/frontend-vue/tests/period-day-rollover.test.ts +80 -0
  21. package/frontend-vue/tests/period-max-days.test.ts +128 -0
  22. package/frontend-vue/tests/period-selector-url.test.ts +44 -8
  23. package/frontend-vue/tests/table-view-url-tab.test.ts +115 -0
  24. package/package.json +2 -2
  25. package/skills/dms-pages/REFERENCE.md +6 -1
@@ -53,6 +53,55 @@ export function calendarToRange(range: CalendarRange): PeriodRange {
53
53
  };
54
54
  }
55
55
 
56
+ /** Which bound of a range stays put when the range has to shrink. */
57
+ export type CalendarRangeAnchor = "start" | "end";
58
+
59
+ const RANGE_SHRINKERS: Record<
60
+ CalendarRangeAnchor,
61
+ (range: CalendarRange, span: number) => CalendarRange
62
+ > = {
63
+ start: (range, span) => ({
64
+ start: range.start,
65
+ end: range.start.add({ days: span }),
66
+ }),
67
+ end: (range, span) => ({
68
+ start: range.end.subtract({ days: span }),
69
+ end: range.end,
70
+ }),
71
+ };
72
+
73
+ /** The number of days a range covers, both ends included. */
74
+ export function calendarRangeDays(range: CalendarRange): number {
75
+ return range.end.compare(range.start) + 1;
76
+ }
77
+
78
+ /**
79
+ * Shrinks a range to at most `maxDays` days, keeping its `anchor` bound and
80
+ * moving the other one. Without a limit, or within it, the range is returned
81
+ * as is.
82
+ */
83
+ export function limitCalendarRange(
84
+ range: CalendarRange,
85
+ maxDays: number | undefined,
86
+ anchor: CalendarRangeAnchor,
87
+ ): CalendarRange {
88
+ if (!maxDays || calendarRangeDays(range) <= maxDays) return range;
89
+ return RANGE_SHRINKERS[anchor](range, maxDays - 1);
90
+ }
91
+
92
+ /**
93
+ * Whether a range starting (or ending) on `pickedDay` and reaching `date`
94
+ * would cover more than `maxDays` days.
95
+ */
96
+ export function isBeyondMaxDays(
97
+ date: CalendarDate,
98
+ pickedDay: CalendarDate,
99
+ maxDays: number | undefined,
100
+ ): boolean {
101
+ if (!maxDays) return false;
102
+ return Math.abs(date.compare(pickedDay)) >= maxDays;
103
+ }
104
+
56
105
  function isStartOfDay(date: Date): boolean {
57
106
  return (
58
107
  date.getHours() === 0 &&
@@ -63,15 +63,23 @@ function selectionText(selection: PeriodSelection): string {
63
63
  return JSON.stringify(writePeriodQuery(selection, STORED_KEYS));
64
64
  }
65
65
 
66
+ type DmsRouteState = ReturnType<typeof useDmsRoute>;
67
+
68
+ function browserQuery(): PeriodQuery {
69
+ return Object.fromEntries(new URLSearchParams(window.location.search));
70
+ }
71
+
66
72
  /**
67
73
  * Writes the period into the URL: the history entry is replaced, and no
68
74
  * request reloads the page. The browser's URL is written at once, so a
69
- * second selector writing in the same tick merges into it; Inertia's
70
- * client-side replace then moves its page URL, which the DMS route follows,
71
- * so a component writing the query from the route (a tab, a quick action)
72
- * carries the period on instead of the one the page was opened with.
75
+ * second selector writing in the same tick merges into it, and so is the
76
+ * route's query: a component or a module building a query from the route
77
+ * (a tab, a quick action) carries the new period on at once, instead of the
78
+ * one the page was opened with. Inertia's client-side replace then moves its
79
+ * page URL, which the route follows, a few ticks later, to the same query.
73
80
  */
74
81
  function replacePeriodInUrl(
82
+ route: DmsRouteState,
75
83
  keys: PeriodQueryKeys,
76
84
  selection: PeriodSelection,
77
85
  ): string | null {
@@ -81,15 +89,29 @@ function replacePeriodInUrl(
81
89
  if (isPeriodQueryCurrent(query, keys, selection)) return null;
82
90
  url.search = mergePeriodSearch(url.search, keys, selection);
83
91
  const written = urlKeysText(Object.fromEntries(url.searchParams), keys);
92
+ const fullPath = `${url.pathname}${url.search}${url.hash}`;
84
93
  window.history.replaceState(window.history.state, "", url);
94
+ route.query = Object.fromEntries(url.searchParams);
95
+ route.fullPath = fullPath;
85
96
  inertiaRouter.replace({
86
- url: `${url.pathname}${url.search}${url.hash}`,
97
+ url: fullPath,
87
98
  preserveState: true,
88
99
  preserveScroll: true,
89
100
  });
90
101
  return written;
91
102
  }
92
103
 
104
+ /**
105
+ * Until Inertia's page catches up with a URL written in place, the route can
106
+ * still be set back to the page's previous query (a component reading the
107
+ * route meanwhile refreshes it from that page). The address bar is ahead of
108
+ * it, never behind: a route whose period keys differ from it is stale.
109
+ */
110
+ function isRouteBehindBrowser(text: string, keys: PeriodQueryKeys): boolean {
111
+ if (typeof window === "undefined") return false;
112
+ return text !== urlKeysText(browserQuery(), keys);
113
+ }
114
+
93
115
  /** The user's last choice of one selector, kept in the preferences. */
94
116
  function useRememberedPeriod(target: RememberedPeriodTarget) {
95
117
  const { getPreference, setPreference } = usePreferences();
@@ -113,11 +135,11 @@ function useRememberedPeriod(target: RememberedPeriodTarget) {
113
135
  * later, and a pick made meanwhile must not be undone by the URL of the one
114
136
  * before: `isEcho` tells a route naming a URL written here from a change.
115
137
  */
116
- function usePeriodUrlWriter(keys: PeriodQueryKeys) {
138
+ function usePeriodUrlWriter(route: DmsRouteState, keys: PeriodQueryKeys) {
117
139
  const pending: string[] = [];
118
140
  return {
119
141
  write: (selection: PeriodSelection) => {
120
- const written = replacePeriodInUrl(keys, selection);
142
+ const written = replacePeriodInUrl(route, keys, selection);
121
143
  if (written !== null) pending.push(written);
122
144
  },
123
145
  isEcho: (text: string) => {
@@ -142,8 +164,9 @@ function usePeriodUrlWriter(keys: PeriodQueryKeys) {
142
164
  * period, then on every change: the history entry is replaced instead of
143
165
  * pushed, so Back leaves the page rather than stepping through every pick,
144
166
  * and the page is not requested again, only the bound cards and tables
145
- * refetch. A route changed from outside (Back to the page with another
146
- * period, a link) moves the period.
167
+ * refetch. The route's query (`useDmsRoute().query`) follows each write at
168
+ * once. A route changed from outside (Back to the page with another period,
169
+ * a link) moves the period.
147
170
  */
148
171
  export function usePeriodLocation(
149
172
  period: UsePeriodReturn,
@@ -152,7 +175,7 @@ export function usePeriodLocation(
152
175
  const route = useDmsRoute();
153
176
  const urlKeys = periodQueryKeys(options.id);
154
177
  const remembered = useRememberedPeriod(options);
155
- const writer = usePeriodUrlWriter(urlKeys);
178
+ const writer = usePeriodUrlWriter(route, urlKeys);
156
179
  const choices = {
157
180
  presets: period.presets.value,
158
181
  comparisons: period.comparisons.value,
@@ -186,7 +209,7 @@ export function usePeriodLocation(
186
209
  watch(
187
210
  () => urlKeysText(route.query as PeriodQuery, urlKeys),
188
211
  (text) => {
189
- if (writer.isEcho(text)) return;
212
+ if (writer.isEcho(text) || isRouteBehindBrowser(text, urlKeys)) return;
190
213
  const current = readSelection(period);
191
214
  const next = resolvePeriodSelection([urlSource()], choices, current);
192
215
  if (selectionText(next) === selectionText(current)) return;
@@ -0,0 +1,47 @@
1
+ import { watch, type Ref } from "vue";
2
+ import { readTableUrlKey, type TableUrlScope } from "./utils/views";
3
+
4
+ const TAB_URL_KEY = "tab";
5
+
6
+ /** The route fields the URL tab is read from. */
7
+ export interface TableUrlTabRoute {
8
+ query: Record<string, unknown>;
9
+ }
10
+
11
+ /** What `useTableUrlTab` reads the tab from and drives. */
12
+ export interface TableUrlTabOptions {
13
+ route: TableUrlTabRoute;
14
+ urlScope: TableUrlScope;
15
+ /** The open tab: set from the URL on arrival by the caller, then here. */
16
+ activeTabId: Ref<string>;
17
+ /** Whether the table has a tab of that id it can open in place. */
18
+ isOpenable: (tabId: string) => boolean;
19
+ }
20
+
21
+ /**
22
+ * The tab the URL names for a table (`?<tableId>.tab=`, or `?tab=` on a page
23
+ * carrying a single table view), as it reads on arrival.
24
+ */
25
+ export function readTableUrlTab(
26
+ query: Record<string, unknown>,
27
+ urlScope: TableUrlScope,
28
+ ): string | undefined {
29
+ return readTableUrlKey(query, urlScope, TAB_URL_KEY).value;
30
+ }
31
+
32
+ /**
33
+ * Follows the URL's tab after arrival: a link or a `router.replace` naming
34
+ * another tab of the same page switches to it without remounting the page.
35
+ * A URL dropping the key, or naming a tab the table cannot open in place (an
36
+ * unknown id, a link tab), leaves the open tab alone.
37
+ */
38
+ export function useTableUrlTab(options: TableUrlTabOptions): void {
39
+ watch(
40
+ () => readTableUrlTab(options.route.query, options.urlScope),
41
+ (tabId) => {
42
+ if (!tabId || tabId === options.activeTabId.value) return;
43
+ if (!options.isOpenable(tabId)) return;
44
+ options.activeTabId.value = tabId;
45
+ },
46
+ );
47
+ }
@@ -23,9 +23,11 @@ import { isRelativePreset } from "#dms-core/app/composables/period/presetResolve
23
23
  import { usePeriodLocation } from "../../build/composables/period/usePeriodLocation";
24
24
  import {
25
25
  type CalendarRange,
26
+ type CalendarRangeAnchor,
26
27
  calendarToRange,
27
28
  formatPeriodRange,
28
29
  formatRelativeInstant,
30
+ limitCalendarRange,
29
31
  periodDuration,
30
32
  rangeToCalendar,
31
33
  } from "../../build/composables/period/periodDisplay";
@@ -49,6 +51,7 @@ interface Props extends DefaultComponentProps {
49
51
  comparisonControl?: ComparisonControl;
50
52
  rememberKey?: string;
51
53
  variant?: "default" | "segmented";
54
+ maxDays?: number;
52
55
  }
53
56
 
54
57
  const CALENDAR_ICON = "i-ph-calendar-blank-light";
@@ -232,9 +235,18 @@ function setOpen(open: boolean) {
232
235
  isOpen.value = open;
233
236
  }
234
237
 
238
+ // A custom range starts from the days on screen, which a long preset can
239
+ // stretch past `maxDays`: it keeps its most recent days.
240
+ function selectDraftPreset(preset: PeriodPreset) {
241
+ draft.selectPreset(preset);
242
+ const custom = draft.customRange.value;
243
+ if (preset !== CUSTOM_PRESET || !custom) return;
244
+ selectCalendarRange(rangeToCalendar(custom), "end");
245
+ }
246
+
235
247
  function openCustom() {
236
248
  draft.reset();
237
- draft.selectPreset(CUSTOM_PRESET);
249
+ selectDraftPreset(CUSTOM_PRESET);
238
250
  pendingStart.value = null;
239
251
  isOpen.value = true;
240
252
  }
@@ -253,9 +265,14 @@ const draftCompareCalendar = computed<CalendarRange | null>(() => {
253
265
  return compare ? rangeToCalendar(compare) : null;
254
266
  });
255
267
 
256
- function selectCalendarRange(range: CalendarRange) {
268
+ function selectCalendarRange(
269
+ range: CalendarRange,
270
+ anchor: CalendarRangeAnchor = "start",
271
+ ) {
257
272
  if (!isCustomEnabled.value) return;
258
- draft.selectRange(calendarToRange(range));
273
+ draft.selectRange(
274
+ calendarToRange(limitCalendarRange(range, props.maxDays, anchor)),
275
+ );
259
276
  }
260
277
 
261
278
  function updateStart(value: DateValue | null | undefined) {
@@ -273,6 +290,7 @@ function updateEnd(value: DateValue | null | undefined) {
273
290
  const start = draftCalendar.value.start;
274
291
  selectCalendarRange(
275
292
  end.compare(start) < 0 ? { start: end, end } : { start, end },
293
+ "end",
276
294
  );
277
295
  }
278
296
 
@@ -297,6 +315,12 @@ const draftDurationLabel = computed(() => {
297
315
  return t(`dms.period.popover.${unit}`, { count }, count);
298
316
  });
299
317
 
318
+ const maxDaysHint = computed(() =>
319
+ props.maxDays && isCustomEnabled.value
320
+ ? t("dms.period.popover.max_days", { count: props.maxDays }, props.maxDays)
321
+ : null,
322
+ );
323
+
300
324
  const draftCompareLabel = computed(() => {
301
325
  const compare = draft.compareRange.value;
302
326
  return compare ? formatPeriodRange(compare, locale.value) : null;
@@ -557,7 +581,7 @@ function onSegmentedClick(event: MouseEvent) {
557
581
  role="option"
558
582
  :aria-selected="draft.preset.value === entry.preset"
559
583
  :class="presetItemClass(entry.preset)"
560
- @click="draft.selectPreset(entry.preset)"
584
+ @click="selectDraftPreset(entry.preset)"
561
585
  >
562
586
  <span class="truncate">{{ entry.label }}</span>
563
587
  <span
@@ -583,7 +607,7 @@ function onSegmentedClick(event: MouseEvent) {
583
607
  role="option"
584
608
  :aria-selected="draft.preset.value === CUSTOM_PRESET"
585
609
  :class="presetItemClass(CUSTOM_PRESET)"
586
- @click="draft.selectPreset(CUSTOM_PRESET)"
610
+ @click="selectDraftPreset(CUSTOM_PRESET)"
587
611
  >
588
612
  <UIcon :name="CALENDAR_ICON" class="size-4 shrink-0" />
589
613
  {{ $t("dms.period.popover.custom_range", "Custom range") }}
@@ -662,7 +686,8 @@ function onSegmentedClick(event: MouseEvent) {
662
686
  :week-starts-on="weekStartsOn"
663
687
  :number-of-months="isWide ? 2 : 1"
664
688
  :readonly="!isCustomEnabled"
665
- @update:model-value="selectCalendarRange"
689
+ :max-days="maxDays"
690
+ @update:model-value="selectCalendarRange($event)"
666
691
  @pending="pendingStart = $event"
667
692
  />
668
693
 
@@ -680,6 +705,7 @@ function onSegmentedClick(event: MouseEvent) {
680
705
  </span>
681
706
  {{ draftCompareLabel }}
682
707
  </template>
708
+ <template v-if="maxDaysHint">· {{ maxDaysHint }}</template>
683
709
  </span>
684
710
  <UButton
685
711
  color="neutral"
@@ -151,7 +151,10 @@ import {
151
151
  groupedSorting,
152
152
  isGroupableColumn,
153
153
  } from "../../build/composables/table-view/utils/groupedRows";
154
- import { readTableUrlKey } from "../../build/composables/table-view/utils/views";
154
+ import {
155
+ readTableUrlTab,
156
+ useTableUrlTab,
157
+ } from "../../build/composables/table-view/useTableUrlTab";
155
158
  import TableViews, {
156
159
  type TableViewItem,
157
160
  } from "../../build/components/table/Views.vue";
@@ -606,11 +609,10 @@ const ALL_TAB_ID = "all";
606
609
 
607
610
  const { t, te } = useI18n();
608
611
 
609
- const TAB_URL_KEY = "tab";
610
- const urlTab = readTableUrlKey(useDmsRoute().query, urlScope, TAB_URL_KEY);
611
612
  // A tab named by the URL wins over the one kept from the last visit.
612
613
  const activeTabId = ref<string>(
613
- urlTab.value ?? tablePreferences.get<string>("activeTab", ALL_TAB_ID),
614
+ readTableUrlTab(route.query, urlScope) ??
615
+ tablePreferences.get<string>("activeTab", ALL_TAB_ID),
614
616
  );
615
617
 
616
618
  interface ResolvedTab {
@@ -661,6 +663,14 @@ const activeTabFilters = computed<TableFilter[]>(() => {
661
663
  return tab?.filters || [];
662
664
  });
663
665
 
666
+ useTableUrlTab({
667
+ route,
668
+ urlScope,
669
+ activeTabId,
670
+ isOpenable: (tabId) =>
671
+ resolvedTabs.value.some((tab) => !tab.to && tab.id === tabId),
672
+ });
673
+
664
674
  watch(resolvedTabs, (next) => {
665
675
  if (next.length === 0) return;
666
676
  if (!next.some((tab) => !tab.to && tab.id === activeTabId.value)) {
@@ -3,7 +3,8 @@
3
3
  * A page header's buttons follow the page URL: a target URL takes the
4
4
  * `{{params.X}}` / `{{query.X}}` tokens of a block's `fetchUrl`, and a
5
5
  * button whose availability reads the query is asked for again when the
6
- * query changes.
6
+ * query changes. The served buttons and a page's own actions
7
+ * (`usePageHeaderActions`) share one order.
7
8
  */
8
9
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
10
  import {
@@ -23,6 +24,11 @@ import {
23
24
  layoutUrlWithQuery,
24
25
  pageQueryValues,
25
26
  } from "../layers/dms-layout/app/utils/pageQuery";
27
+ import {
28
+ providePageHeaderActions,
29
+ usePageHeaderActions,
30
+ type PageHeaderActionsOptions,
31
+ } from "../layers/dms-layout/app/composables/layout/usePageHeaderActions";
26
32
 
27
33
  const handleCustomButton = vi.fn();
28
34
  vi.mock("#dms-ui/app/build/composables/actions/useActionTargets", () => ({
@@ -106,6 +112,7 @@ const mountBar = (actions: unknown[]) =>
106
112
  );
107
113
 
108
114
  const buttons = () => [...host.querySelectorAll("button")];
115
+ const labels = () => buttons().map((button) => button.textContent);
109
116
 
110
117
  describe("resolveLinkVariables", () => {
111
118
  const context = {
@@ -277,6 +284,33 @@ describe("a header button's availability", () => {
277
284
  expect(buttons()[0]?.disabled).toBe(false);
278
285
  });
279
286
 
287
+ it("shows a button it hides only for the queries it does not hide it for", async () => {
288
+ const addKey = {
289
+ id: "add-key",
290
+ label: "Add key",
291
+ target: download(false).target,
292
+ };
293
+ authFetch.mockResolvedValue({
294
+ components: {},
295
+ layout: { options: { headerActions: [download(false)] } },
296
+ });
297
+ await mount(
298
+ "../layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue",
299
+ { actions: [download(false), addKey], servedQuery: { workspace: "web" } },
300
+ );
301
+ expect(labels()).toEqual(["Download", "Add key"]);
302
+
303
+ route.query = { workspace: "modules" };
304
+ await nextTick();
305
+ await new Promise((resolve) => setTimeout(resolve));
306
+ expect(authFetch).toHaveBeenCalledWith(`${LAYOUT_URL}?workspace=modules`);
307
+ expect(labels()).toEqual(["Download"]);
308
+
309
+ route.query = { workspace: "web" };
310
+ await nextTick();
311
+ expect(labels()).toEqual(["Download", "Add key"]);
312
+ });
313
+
280
314
  it("is never asked again when no button declares one", async () => {
281
315
  await mountOutlet(undefined);
282
316
  route.query = { workspace: "gone" };
@@ -284,3 +318,64 @@ describe("a header button's availability", () => {
284
318
  expect(authFetch).not.toHaveBeenCalled();
285
319
  });
286
320
  });
321
+
322
+ describe("the order of the header's buttons", () => {
323
+ const served = (id: string, order?: number) => ({
324
+ id,
325
+ label: id,
326
+ target: { type: "page", url: `/${id}` },
327
+ order,
328
+ });
329
+
330
+ // A layout's page body: the outlet, then the page calling
331
+ // usePageHeaderActions, then the marker saying the page has rendered.
332
+ async function mountPage(
333
+ actions: unknown[],
334
+ options?: PageHeaderActionsOptions,
335
+ ) {
336
+ const { default: Outlet } = await import(
337
+ "../layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue"
338
+ );
339
+ const Page = defineComponent({
340
+ setup() {
341
+ usePageHeaderActions(() => h("button", "Export"), options);
342
+ return () => null;
343
+ },
344
+ });
345
+ const Body = defineComponent({
346
+ setup() {
347
+ const pageHost = providePageHeaderActions();
348
+ const Rendered = defineComponent({
349
+ setup() {
350
+ pageHost.markSlotRendered();
351
+ return () => null;
352
+ },
353
+ });
354
+ return () => [h(Outlet, { actions }), h(Page), h(Rendered)];
355
+ },
356
+ });
357
+ app = createApp(Body);
358
+ app.component("UButton", ButtonStub);
359
+ app.component("UTooltip", SlotStub);
360
+ app.mount(host);
361
+ await nextTick();
362
+ }
363
+
364
+ it("puts the page's actions after the served buttons by default", async () => {
365
+ await mountPage([served("add-key"), served("add-language")]);
366
+ expect(labels()).toEqual(["add-key", "add-language", "Export"]);
367
+ });
368
+
369
+ it("puts them before the served buttons of a greater order", async () => {
370
+ await mountPage([served("add-key")], { order: -1 });
371
+ expect(labels()).toEqual(["Export", "add-key"]);
372
+ });
373
+
374
+ it("sorts the served buttons by order, the declared order breaking ties", async () => {
375
+ await mountPage(
376
+ [served("last", 2), served("first"), served("second"), served("mid", 1)],
377
+ { order: 1 },
378
+ );
379
+ expect(labels()).toEqual(["first", "second", "mid", "Export", "last"]);
380
+ });
381
+ });
@@ -0,0 +1,80 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ref } from "vue";
3
+ import { usePeriod } from "../layers/dms-core/app/composables/period/usePeriod";
4
+ import {
5
+ isCalendarPreset,
6
+ msUntilNextMidnight,
7
+ } from "../layers/dms-core/app/composables/period/presetResolvers";
8
+
9
+ const MS_PER_MINUTE = 60000;
10
+ const MS_PER_DAY = 24 * 60 * MS_PER_MINUTE;
11
+ const LATE_EVENING = new Date(2026, 9, 12, 23, 58);
12
+ const NEXT_DAY = new Date(2026, 9, 13);
13
+
14
+ describe("calendar presets at midnight", () => {
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ vi.setSystemTime(LATE_EVENING);
18
+ vi.stubGlobal("window", globalThis);
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.useRealTimers();
23
+ vi.unstubAllGlobals();
24
+ });
25
+
26
+ it("classifies the day-bound presets as calendar presets", () => {
27
+ expect(isCalendarPreset("today")).toBe(true);
28
+ expect(isCalendarPreset("last-7-days")).toBe(true);
29
+ expect(isCalendarPreset("this-month")).toBe(true);
30
+ expect(isCalendarPreset("last-hour")).toBe(false);
31
+ expect(isCalendarPreset("custom")).toBe(false);
32
+ expect(isCalendarPreset("all")).toBe(false);
33
+ });
34
+
35
+ it("measures the time left until the next local midnight", () => {
36
+ expect(msUntilNextMidnight(LATE_EVENING)).toBe(2 * MS_PER_MINUTE);
37
+ });
38
+
39
+ it("moves `today` onto the new day once midnight passes", () => {
40
+ const period = usePeriod({ defaultPreset: "today" });
41
+ const initialKey = period.state.value.key;
42
+ vi.advanceTimersByTime(MS_PER_MINUTE);
43
+ expect(period.state.value.key).toBe(initialKey);
44
+ vi.advanceTimersByTime(MS_PER_MINUTE);
45
+ expect(period.state.value.range?.from).toEqual(NEXT_DAY);
46
+ });
47
+
48
+ it("keeps moving on the following midnights", () => {
49
+ const period = usePeriod({ defaultPreset: "last-7-days" });
50
+ vi.advanceTimersByTime(2 * MS_PER_MINUTE);
51
+ expect(period.state.value.range?.to.getDate()).toBe(13);
52
+ vi.advanceTimersByTime(MS_PER_DAY);
53
+ expect(period.state.value.range?.to.getDate()).toBe(14);
54
+ });
55
+
56
+ it("waits for the hold to end before moving", async () => {
57
+ const isHeld = ref(true);
58
+ const period = usePeriod({
59
+ defaultPreset: "today",
60
+ holdClock: () => isHeld.value,
61
+ });
62
+ const initialKey = period.state.value.key;
63
+ await vi.advanceTimersByTimeAsync(3 * MS_PER_MINUTE);
64
+ expect(period.state.value.key).toBe(initialKey);
65
+ isHeld.value = false;
66
+ await vi.advanceTimersByTimeAsync(0);
67
+ expect(period.state.value.range?.from).toEqual(NEXT_DAY);
68
+ });
69
+
70
+ it("leaves a custom range alone", async () => {
71
+ const period = usePeriod({ defaultPreset: "today" });
72
+ period.setCustomRange({
73
+ from: new Date(2026, 9, 1),
74
+ to: new Date(2026, 9, 2),
75
+ });
76
+ const initialKey = period.state.value.key;
77
+ await vi.advanceTimersByTimeAsync(3 * MS_PER_MINUTE);
78
+ expect(period.state.value.key).toBe(initialKey);
79
+ });
80
+ });