@runwisp/ui 0.11.0 → 0.13.0
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.
- package/package.json +4 -4
- package/src/lib/components/FormField.svelte +8 -1
- package/src/lib/components/dashboard/RunDetailPanel.svelte +53 -20
- package/src/lib/components/dashboard/RunsList.svelte +25 -3
- package/src/lib/components/dashboard/cron-format.ts +4 -1
- package/src/lib/components/dashboard/run-filters.ts +2 -2
- package/src/lib/components/dashboard/run-helpers.test.ts +13 -0
- package/src/lib/components/dashboard/run-helpers.ts +5 -2
- package/src/lib/index.ts +1 -0
- package/src/lib/log-console/LogCache.svelte.ts +8 -3
- package/src/lib/theme.css +8 -5
- package/src/lib/utils/ticking-now.svelte.test.ts +45 -0
- package/src/lib/utils/ticking-now.svelte.ts +32 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runwisp/ui",
|
|
3
3
|
"description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@chromatic-com/storybook": "^4.1.3",
|
|
51
51
|
"@eslint/compat": "^1.4.0",
|
|
52
52
|
"@eslint/js": "^9.39.1",
|
|
53
|
-
"@runwisp/eslint-config": "0.
|
|
54
|
-
"@runwisp/typescript-config": "0.
|
|
53
|
+
"@runwisp/eslint-config": "0.13.0",
|
|
54
|
+
"@runwisp/typescript-config": "0.13.0",
|
|
55
55
|
"@storybook/addon-a11y": "^10.1.9",
|
|
56
56
|
"@storybook/addon-docs": "^10.1.9",
|
|
57
57
|
"@storybook/addon-svelte-csf": "^5.0.10",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@floating-ui/dom": "^1.7.4",
|
|
100
100
|
"@fontsource/tasa-orbiter": "^5.2.2",
|
|
101
101
|
"@lucide/svelte": "^0.561.0",
|
|
102
|
-
"@runwisp/common": "0.
|
|
102
|
+
"@runwisp/common": "0.13.0",
|
|
103
103
|
"@tanstack/svelte-virtual": "^3.13.25",
|
|
104
104
|
"ansi-to-html": "^0.7.2",
|
|
105
105
|
"cron-parser": "^5.4.0",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
required?: boolean;
|
|
13
13
|
children: Snippet;
|
|
14
14
|
class?: string;
|
|
15
|
+
// The id of the control this field labels. Pass the SAME id to the wrapped
|
|
16
|
+
// input/select so the <label for> actually points at it — otherwise the
|
|
17
|
+
// label is dead (clicking it does nothing; screen readers can't associate
|
|
18
|
+
// it). Falls back to a generated id when the field has no focusable target.
|
|
19
|
+
id?: string;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
let {
|
|
@@ -22,14 +27,16 @@
|
|
|
22
27
|
required = false,
|
|
23
28
|
children,
|
|
24
29
|
class: className = "",
|
|
30
|
+
id,
|
|
25
31
|
}: Props = $props();
|
|
26
32
|
|
|
27
33
|
const generatedId = `field-${Math.random().toString(36).slice(2, 10)}`;
|
|
34
|
+
const fieldId = $derived(id ?? generatedId);
|
|
28
35
|
</script>
|
|
29
36
|
|
|
30
37
|
<div class="space-y-1.5 {className}">
|
|
31
38
|
{#if label}
|
|
32
|
-
<label for={
|
|
39
|
+
<label for={fieldId} class="block text-sm font-medium text-on-surface-muted">
|
|
33
40
|
{label}
|
|
34
41
|
{#if required}
|
|
35
42
|
<span class="text-danger-soft-text">*</span>
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
Maximize2,
|
|
22
22
|
Minimize2,
|
|
23
23
|
TextWrap,
|
|
24
|
+
SearchX,
|
|
24
25
|
} from "@lucide/svelte";
|
|
25
26
|
import Button from "../Button.svelte";
|
|
26
27
|
import EmptyState from "../EmptyState.svelte";
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
formatTimeHM,
|
|
39
40
|
} from "../../utils/format.js";
|
|
40
41
|
import { formatShortId } from "../../utils/id.js";
|
|
42
|
+
import { TickingNow } from "../../utils/ticking-now.svelte.js";
|
|
41
43
|
import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
|
|
42
44
|
import {
|
|
43
45
|
runDuration,
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
historyVisible = false,
|
|
67
69
|
highlightLine = null,
|
|
68
70
|
getInstanceCount = () => 1,
|
|
71
|
+
notFound = false,
|
|
69
72
|
}: {
|
|
70
73
|
run: Run | undefined;
|
|
71
74
|
fetchLogs: (
|
|
@@ -111,6 +114,10 @@
|
|
|
111
114
|
// Resolves a task's currently configured instance count so multi-instance
|
|
112
115
|
// services render a 1-based #N suffix. Defaults to single-instance.
|
|
113
116
|
getInstanceCount?: (taskName: string) => number;
|
|
117
|
+
// True when a deep-linked run id resolved to no run (deleted by retention,
|
|
118
|
+
// or never existed). The empty state then says so plainly instead of the
|
|
119
|
+
// generic "Select a run" — the caller must not silently substitute another.
|
|
120
|
+
notFound?: boolean;
|
|
114
121
|
} = $props();
|
|
115
122
|
|
|
116
123
|
let canDelete = $derived.by(() => {
|
|
@@ -119,6 +126,15 @@
|
|
|
119
126
|
return status !== "running" && status !== "pending";
|
|
120
127
|
});
|
|
121
128
|
|
|
129
|
+
// Optimistic per-second clock so a live run's "Ran for" duration counts up
|
|
130
|
+
// between SSE events. The interval only runs while the run is in-flight; it
|
|
131
|
+
// is torn down as soon as the run ends (or the panel is destroyed).
|
|
132
|
+
const durationTicker = new TickingNow(1000);
|
|
133
|
+
$effect(() => {
|
|
134
|
+
if (run?.status !== "running") return;
|
|
135
|
+
return durationTicker.start();
|
|
136
|
+
});
|
|
137
|
+
|
|
122
138
|
const TAIL_LINES = 1000;
|
|
123
139
|
|
|
124
140
|
let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
|
|
@@ -299,7 +315,10 @@
|
|
|
299
315
|
{@const status = runDisplayStatus(run)}
|
|
300
316
|
{@const config = getRunStatusConfig(status)}
|
|
301
317
|
{@const DetailIcon = config.icon}
|
|
302
|
-
{@const duration = runDuration(
|
|
318
|
+
{@const duration = runDuration(
|
|
319
|
+
run,
|
|
320
|
+
run.status === "running" ? durationTicker.now.getTime() : undefined,
|
|
321
|
+
)}
|
|
303
322
|
{@const startDelay = runStartDelay(run)}
|
|
304
323
|
{@const startedAt = run.start_at ?? run.created_at}
|
|
305
324
|
{@const retry = runRetryLabel(run)}
|
|
@@ -473,20 +492,32 @@
|
|
|
473
492
|
{/if}
|
|
474
493
|
|
|
475
494
|
{#if onStopService || onRestartService}
|
|
476
|
-
<!-- Service lifecycle replaces the run control.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
495
|
+
<!-- Service lifecycle replaces the run control.
|
|
496
|
+
Restart is always available (it cancels + respawns
|
|
497
|
+
all instances in one call); Stop appears only while
|
|
498
|
+
the service is running. Each still confirms, so
|
|
499
|
+
neither fires accidentally. -->
|
|
500
|
+
{#if onRestartService}
|
|
501
|
+
<button
|
|
502
|
+
type="button"
|
|
503
|
+
onclick={() => onRestartService()}
|
|
504
|
+
disabled={serviceBusy}
|
|
505
|
+
title={serviceStopped
|
|
506
|
+
? "Starts the service"
|
|
507
|
+
: "Cancels and respawns every instance"}
|
|
508
|
+
class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border border-primary-soft bg-surface-raised px-3 text-sm font-medium text-primary transition-colors hover:bg-primary-soft disabled:cursor-not-allowed disabled:opacity-60"
|
|
509
|
+
>
|
|
510
|
+
{#if serviceStopped}
|
|
511
|
+
<Play size={15} fill="currentColor" stroke="none" />
|
|
512
|
+
{:else}
|
|
485
513
|
<RefreshCcw size={15} />
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
514
|
+
{/if}
|
|
515
|
+
<span class="@max-md:hidden"
|
|
516
|
+
>{serviceStopped ? "Start" : "Restart"}</span
|
|
517
|
+
>
|
|
518
|
+
</button>
|
|
519
|
+
{/if}
|
|
520
|
+
{#if !serviceStopped && onStopService}
|
|
490
521
|
<button
|
|
491
522
|
type="button"
|
|
492
523
|
onclick={() => onStopService()}
|
|
@@ -812,13 +843,15 @@
|
|
|
812
843
|
class="flex min-w-0 flex-1 flex-col items-center justify-center gap-4 bg-surface-sunken/30"
|
|
813
844
|
>
|
|
814
845
|
<EmptyState
|
|
815
|
-
title={onRunTask ? "No runs yet" : "Select a run"}
|
|
816
|
-
description={
|
|
817
|
-
? "This
|
|
818
|
-
:
|
|
819
|
-
|
|
846
|
+
title={notFound ? "Run not found" : onRunTask ? "No runs yet" : "Select a run"}
|
|
847
|
+
description={notFound
|
|
848
|
+
? "This run doesn't exist — it may have been deleted by retention, or the link is wrong. Pick a run from the list to continue."
|
|
849
|
+
: onRunTask
|
|
850
|
+
? "This task hasn't run yet. Trigger it to see its output here."
|
|
851
|
+
: "Pick a run from the list to view details and logs."}
|
|
852
|
+
icon={notFound ? SearchX : MousePointerClick}
|
|
820
853
|
/>
|
|
821
|
-
{#if onRunTask}
|
|
854
|
+
{#if onRunTask && !notFound}
|
|
822
855
|
<button
|
|
823
856
|
type="button"
|
|
824
857
|
onclick={() => onRunTask()}
|
|
@@ -119,8 +119,8 @@
|
|
|
119
119
|
outputSearchPending?: boolean;
|
|
120
120
|
} = $props();
|
|
121
121
|
|
|
122
|
-
// Task-rail rows are
|
|
123
|
-
//
|
|
122
|
+
// Task-rail rows are one dense line (44px); the cross-task /runs view adds
|
|
123
|
+
// the task name on a second line (64px).
|
|
124
124
|
const rowHeight = $derived(showTaskName ? 64 : 44);
|
|
125
125
|
const OVERSCAN = 8;
|
|
126
126
|
const LOAD_AHEAD = 10;
|
|
@@ -203,6 +203,29 @@
|
|
|
203
203
|
if (last.index >= items.length - LOAD_AHEAD) onLoadMore?.();
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// Scroll a programmatically-selected run (deep link, detail-panel click) into
|
|
207
|
+
// view, since its row may be outside the virtual window. Tracked on
|
|
208
|
+
// selectedRunId/items only; the virtualizer access is untracked to avoid the
|
|
209
|
+
// setOptions-style notify loop (effect_update_depth_exceeded, see above).
|
|
210
|
+
let lastScrolled: string | null = null;
|
|
211
|
+
$effect(() => {
|
|
212
|
+
const id = selectedRunId;
|
|
213
|
+
const index = items.findIndex((r: Run) => r.id === id);
|
|
214
|
+
if (!id) {
|
|
215
|
+
lastScrolled = null;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// -1: not loaded yet. The effect re-runs as items grows, so a deep link
|
|
219
|
+
// resolves once its page arrives; we don't chase it with onLoadMore.
|
|
220
|
+
if (index === -1) return;
|
|
221
|
+
if (id === lastScrolled) return; // already handled; don't re-yank on re-render
|
|
222
|
+
if (!scrollElement) return;
|
|
223
|
+
lastScrolled = id;
|
|
224
|
+
// "auto" only scrolls when the row is off-screen, so visible selections
|
|
225
|
+
// (and manual clicks) don't jump.
|
|
226
|
+
untrack(() => $virtualizer.scrollToIndex(index, { align: "auto" }));
|
|
227
|
+
});
|
|
228
|
+
|
|
206
229
|
function isRowSelected(id: string): boolean {
|
|
207
230
|
return selectAllMode ? !exceptIds.has(id) : explicitIds.has(id);
|
|
208
231
|
}
|
|
@@ -361,7 +384,6 @@
|
|
|
361
384
|
? "flex h-full w-full flex-col overflow-hidden border-b border-outline bg-surface md:w-[300px] md:shrink-0 md:border-r md:border-b-0"
|
|
362
385
|
: "flex flex-col overflow-hidden rounded-xl border border-outline bg-surface-raised shadow-sm md:col-span-4 lg:col-span-3"}
|
|
363
386
|
>
|
|
364
|
-
<!-- Inline heading: master checkbox, label, selection count or controls -->
|
|
365
387
|
<div
|
|
366
388
|
class="flex shrink-0 items-center gap-2 border-b px-3 py-2 {hasSelection
|
|
367
389
|
? 'border-outline-faint bg-primary-soft/40'
|
|
@@ -22,7 +22,10 @@ const UNIT_NAMES: Record<string, [string, string]> = {
|
|
|
22
22
|
// humanizeEvery turns robfig's `@every 1h30m` (Go duration syntax) into
|
|
23
23
|
// "Every 1 hour 30 minutes". Returns null when the duration doesn't parse.
|
|
24
24
|
function humanizeEvery(duration: string): string | null {
|
|
25
|
-
|
|
25
|
+
// The number is matched atomically (lookahead-capture + backreference) so a
|
|
26
|
+
// long digit run with no trailing unit fails fast instead of backtracking
|
|
27
|
+
// through every length (super-linear). `\1` is the number; group 2 the unit.
|
|
28
|
+
const parts = [...duration.matchAll(/(?=(\d+(?:\.\d+)?))\1(h|m|s|ms|us|µs|ns)/g)];
|
|
26
29
|
if (parts.length === 0 || parts.map((p) => p[0]).join("") !== duration) {
|
|
27
30
|
return null;
|
|
28
31
|
}
|
|
@@ -208,7 +208,7 @@ export function clearPopoverFilters(f: RunsListFilters): RunsListFilters {
|
|
|
208
208
|
/** Human label for a status value, e.g. `log_overflow` → "Log overflow". */
|
|
209
209
|
export function humanizeStatus(status: string): string {
|
|
210
210
|
if (!status) return status;
|
|
211
|
-
const spaced = status.
|
|
211
|
+
const spaced = status.replaceAll("_", " ");
|
|
212
212
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
213
213
|
}
|
|
214
214
|
|
|
@@ -266,7 +266,7 @@ export function statusChipLabel(statuses: string[]): string {
|
|
|
266
266
|
function localDay(dateStr: string): Date | null {
|
|
267
267
|
const parts = dateStr.split("-");
|
|
268
268
|
if (parts.length !== 3) return null;
|
|
269
|
-
const [y, m, d] = parts.map(
|
|
269
|
+
const [y, m, d] = parts.map(Number);
|
|
270
270
|
if (y === undefined || m === undefined || d === undefined) return null;
|
|
271
271
|
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return null;
|
|
272
272
|
const date = new Date(y, m - 1, d);
|
|
@@ -40,6 +40,19 @@ describe("runDuration", () => {
|
|
|
40
40
|
expect(runDuration({ start_at: "2024-06-15T12:00:00.000Z" })).toBe("2s");
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
it("counts against an injected now for an in-progress run", () => {
|
|
45
|
+
const start = "2024-06-15T12:00:00.000Z";
|
|
46
|
+
const now = new Date("2024-06-15T12:00:07.000Z").getTime();
|
|
47
|
+
expect(runDuration({ start_at: start }, now)).toBe("7s");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("ignores the injected now once the run has ended", () => {
|
|
51
|
+
const start = "2024-06-15T12:00:00.000Z";
|
|
52
|
+
const end = "2024-06-15T12:00:05.000Z";
|
|
53
|
+
const now = new Date("2024-06-15T12:01:00.000Z").getTime();
|
|
54
|
+
expect(runDuration({ start_at: start, end_at: end }, now)).toBe("5s");
|
|
55
|
+
});
|
|
43
56
|
});
|
|
44
57
|
|
|
45
58
|
describe("runStartDelay", () => {
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
import type { Run } from "@runwisp/common";
|
|
5
5
|
import { formatDuration } from "../../utils/format.js";
|
|
6
6
|
|
|
7
|
-
export function runDuration(
|
|
7
|
+
export function runDuration(
|
|
8
|
+
run: Pick<Run, "start_at" | "end_at">,
|
|
9
|
+
now: number = Date.now(),
|
|
10
|
+
): string | undefined {
|
|
8
11
|
if (!run.start_at) return undefined;
|
|
9
12
|
const start = new Date(run.start_at).getTime();
|
|
10
|
-
const end = run.end_at ? new Date(run.end_at).getTime() :
|
|
13
|
+
const end = run.end_at ? new Date(run.end_at).getTime() : now;
|
|
11
14
|
return formatDuration(end - start);
|
|
12
15
|
}
|
|
13
16
|
|
package/src/lib/index.ts
CHANGED
|
@@ -12,6 +12,12 @@ import { visibleColumns } from "./ansi.js";
|
|
|
12
12
|
* the cache must aggressively prune data that is far from the current viewport.
|
|
13
13
|
* It uses a SvelteMap for reactive, efficient line lookups and updates.
|
|
14
14
|
*/
|
|
15
|
+
// Streams pinned to the front of the overlay, in render order. The companion
|
|
16
|
+
// Set backs the membership test in `overlayRows`. Both are module-level,
|
|
17
|
+
// immutable, and non-reactive — a fixed lookup table, not component state.
|
|
18
|
+
const PREFERRED_STREAM_ORDER = ["stdout", "stderr"] as const;
|
|
19
|
+
const PREFERRED_STREAMS: ReadonlySet<string> = new Set(PREFERRED_STREAM_ORDER);
|
|
20
|
+
|
|
15
21
|
export class LogCache {
|
|
16
22
|
lines = $state(new SvelteMap<number, string>());
|
|
17
23
|
// Live-region overlay, keyed by stream (stdout/stderr). Each stream has at
|
|
@@ -61,12 +67,11 @@ export class LogCache {
|
|
|
61
67
|
// other streams, sorted) for stable ordering. Reactive: reads the regions
|
|
62
68
|
// map so it recomputes on update.
|
|
63
69
|
get overlayRows(): string[] {
|
|
64
|
-
const preferred = ["stdout", "stderr"];
|
|
65
70
|
const rest = [...this.regions.keys()]
|
|
66
|
-
.filter((s) => !
|
|
71
|
+
.filter((s) => !PREFERRED_STREAMS.has(s))
|
|
67
72
|
.sort((a, b) => a.localeCompare(b));
|
|
68
73
|
const out: string[] = [];
|
|
69
|
-
for (const stream of [...
|
|
74
|
+
for (const stream of [...PREFERRED_STREAM_ORDER, ...rest]) {
|
|
70
75
|
const region = this.regions.get(stream);
|
|
71
76
|
if (region) out.push(...region.rows);
|
|
72
77
|
}
|
package/src/lib/theme.css
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
/* Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0 */
|
|
2
2
|
|
|
3
|
-
@import "tailwindcss";
|
|
4
|
-
@plugin "@tailwindcss/forms";
|
|
5
|
-
@plugin "@tailwindcss/typography";
|
|
6
|
-
|
|
7
3
|
/* RunWisp Design System — Tailwind v4 entrypoint.
|
|
8
4
|
*
|
|
9
5
|
* Raw OKLch palette + semantic tokens live in ./theme-tokens.css (plain CSS),
|
|
10
6
|
* so apps/docs (Starlight, no Tailwind pipeline) can consume the same source.
|
|
11
7
|
* This file republishes those tokens under Tailwind's --color-* namespace so
|
|
12
8
|
* utilities like `bg-wisp-500`, `text-surface`, etc. generate.
|
|
9
|
+
*
|
|
10
|
+
* All @import rules precede the @plugin directives so the stylesheet stays
|
|
11
|
+
* valid CSS (an @import must come before any other at-rule besides @charset
|
|
12
|
+
* / @layer); Tailwind's @plugin registrations are order-independent here.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
@import "tailwindcss";
|
|
15
16
|
@import "./theme-tokens.css";
|
|
16
|
-
|
|
17
17
|
@import "@fontsource/tasa-orbiter/400.css";
|
|
18
18
|
@import "@fontsource/tasa-orbiter/500.css";
|
|
19
19
|
@import "@fontsource/tasa-orbiter/600.css";
|
|
20
20
|
@import "@fontsource/tasa-orbiter/700.css";
|
|
21
21
|
|
|
22
|
+
@plugin "@tailwindcss/forms";
|
|
23
|
+
@plugin "@tailwindcss/typography";
|
|
24
|
+
|
|
22
25
|
@theme {
|
|
23
26
|
/* Palette aliases — Tailwind utilities (bg-wisp-500, text-mist-900, …)
|
|
24
27
|
* resolve through to --rw-color-* declared in theme-tokens.css. */
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { TickingNow } from "./ticking-now.svelte.js";
|
|
6
|
+
|
|
7
|
+
describe("TickingNow", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("reports the current time before it starts ticking", () => {
|
|
17
|
+
const clock = new TickingNow();
|
|
18
|
+
expect(clock.now.getTime()).toBe(new Date("2024-06-15T12:00:00.000Z").getTime());
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("advances on its default 30s interval", () => {
|
|
22
|
+
const clock = new TickingNow();
|
|
23
|
+
const stop = clock.start();
|
|
24
|
+
vi.advanceTimersByTime(30_000);
|
|
25
|
+
expect(clock.now.getTime()).toBe(new Date("2024-06-15T12:00:30.000Z").getTime());
|
|
26
|
+
stop();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("advances on a custom interval", () => {
|
|
30
|
+
const clock = new TickingNow(1000);
|
|
31
|
+
const stop = clock.start();
|
|
32
|
+
vi.advanceTimersByTime(3000);
|
|
33
|
+
expect(clock.now.getTime()).toBe(new Date("2024-06-15T12:00:03.000Z").getTime());
|
|
34
|
+
stop();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("stops advancing once the cleanup is called", () => {
|
|
38
|
+
const clock = new TickingNow(1000);
|
|
39
|
+
const stop = clock.start();
|
|
40
|
+
vi.advanceTimersByTime(1000);
|
|
41
|
+
stop();
|
|
42
|
+
vi.advanceTimersByTime(9000);
|
|
43
|
+
expect(clock.now.getTime()).toBe(new Date("2024-06-15T12:00:01.000Z").getTime());
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { SvelteDate } from "svelte/reactivity";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TICK_INTERVAL_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
// TickingNow is a reactive clock that advances on a fixed interval, so
|
|
9
|
+
// relative-time labels re-render without waiting for a data event.
|
|
10
|
+
// Call start() inside an $effect and return its cleanup.
|
|
11
|
+
export class TickingNow {
|
|
12
|
+
#ms = $state(Date.now());
|
|
13
|
+
readonly #intervalMs: number;
|
|
14
|
+
|
|
15
|
+
constructor(intervalMs: number = DEFAULT_TICK_INTERVAL_MS) {
|
|
16
|
+
this.#intervalMs = intervalMs;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start(): () => void {
|
|
20
|
+
this.#ms = Date.now();
|
|
21
|
+
const timer = setInterval(() => {
|
|
22
|
+
this.#ms = Date.now();
|
|
23
|
+
}, this.#intervalMs);
|
|
24
|
+
return () => {
|
|
25
|
+
clearInterval(timer);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get now(): Date {
|
|
30
|
+
return new SvelteDate(this.#ms);
|
|
31
|
+
}
|
|
32
|
+
}
|