From ff3df6749201cb150df3abd2493cdfad3073701c Mon Sep 17 00:00:00 2001
From: rcourtman <8825017+rcourtman@users.noreply.github.com>
Date: Tue, 6 Oct 2026 14:12:45 +0100
Subject: [PATCH] Read platform alert Started as an age that keeps moving
The Docker, Kubernetes, TrueNAS and vSphere alert tables showed
Started as "Oct 6, 10:34 AM". It clipped on phones and at 768px. An
open alert's Started cell answers how long it has been going on, so
it now reads as an age ("2h ago"), as alert cards do. The exact time
is the cell's hover text and stays in the drawer. Docker's column
takes the numeric-value alignment the other three use.
Making it an age exposed a wider bug: platform tables keep rows
mounted across data refreshes, so an age computed at render froze
while its timestamp did not change, reading "2h ago" three hours
later. That already applied to every PlatformTableRelativeTimeValue
cell (Kubernetes events, Docker volumes, vSphere activity and others).
A shared clock, useRelativeTimeNow, now ticks every 30 seconds while
any cell reads it and stops when none do. The relative-time cell and
the alert tables pass it to formatRelativeTime as `now`, and the
guardrails pin that.
---
.../subsystems/frontend-primitives.md | 15 ++++
.../internal/subsystems/unified-resources.md | 3 +-
.../a33fb3954747f9d2.json | 71 +++++++++++++++++++
.../SharedPrimitives.guardrails.test.ts | 7 ++
.../src/features/docker/DockerAlertsTable.tsx | 10 ++-
.../kubernetes/KubernetesAlertsTable.tsx | 6 +-
.../platformPage/sharedPlatformPage.tsx | 8 ++-
.../features/truenas/TrueNASAlertsTable.tsx | 6 +-
.../features/vmware/VsphereAlertsTable.tsx | 6 +-
.../__tests__/VsphereAlertsTable.test.tsx | 41 ++++++++++-
...DetailPresentation.branchcov0724pm.test.ts | 27 +++----
.../utils/__tests__/relativeTimeClock.test.ts | 31 ++++++++
.../src/utils/alertDetailPresentation.ts | 14 ++--
frontend-modern/src/utils/format.ts | 6 +-
.../src/utils/relativeTimeClock.ts | 32 +++++++++
15 files changed, 246 insertions(+), 37 deletions(-)
create mode 100644 frontend-modern/browser-verification/a33fb3954747f9d2.json
create mode 100644 frontend-modern/src/utils/__tests__/relativeTimeClock.test.ts
create mode 100644 frontend-modern/src/utils/relativeTimeClock.ts
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 308d1470e2..4772ac4a62 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -5175,6 +5175,21 @@ Docker, Kubernetes, TrueNAS, and vSphere alert tables. Those tables must call
`formatPlatformAlertEntityType`, `formatPlatformAlertStartedAt`, and
`formatPlatformAlertDetailDateTime` instead of declaring local formatter
helpers.
+`formatPlatformAlertStartedAt` reads as an age ("2h ago"), because an open
+alert's Started cell answers how long it has been going on, as alert cards do;
+the absolute "Oct 6, 10:34 AM" also clipped at phone and tablet widths. Each
+table's Started cell carries `formatPlatformAlertDetailDateTime` as its hover
+title, the drawer's Started row keeps that exact time, an unset (pre-2000) or
+invalid time stays "-", and the column uses the `numeric-value` alignment kind
+on all four tables.
+Relative ages read one shared clock. `useRelativeTimeNow`
+(`frontend-modern/src/utils/relativeTimeClock.ts`) ticks every 30 seconds while
+any mounted cell reads it and stops when none do; `PlatformTableRelativeTimeValue`
+and the platform alert tables pass it as `now` to `formatRelativeTime` /
+`formatPlatformAlertStartedAt`. Platform tables keep their rows mounted across
+data refreshes, so an age computed only at render froze ("2h ago" three hours
+later) whenever its timestamp did not change. A relative-age cell must read the
+shared clock rather than `Date.now()` at render or a table-local interval.
Read-only metadata badges follow the same primitive-owned shell rule.
`frontend-modern/src/components/shared/MetadataBadge.tsx` owns filled and
outlined appearances, compact sizing, shape, typed tone vocabulary, fit
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index a22df85cf1..3804b3df91 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -4293,7 +4293,8 @@ and platform alert detail fields must use
`formatPlatformAlertDetailDateTime` from
`frontend-modern/src/utils/alertDetailPresentation.ts` instead of restoring
table-local provider code, resource-type, entity-type, or timestamp formatter
-helpers,
+helpers (the Started cell reads as an age with the exact time as its hover
+title, the drawer keeps the exact time),
and local operator identity labels now split from governed detail summaries:
infrastructure tables, selectors, links, and drawer headings must preserve the
canonical local instance identity (`displayName`, canonical display name,
diff --git a/frontend-modern/browser-verification/a33fb3954747f9d2.json b/frontend-modern/browser-verification/a33fb3954747f9d2.json
new file mode 100644
index 0000000000..dcad5c670f
--- /dev/null
+++ b/frontend-modern/browser-verification/a33fb3954747f9d2.json
@@ -0,0 +1,71 @@
+{
+ "version": 1,
+ "base_sha": "13d604e1c71a4b8388ecaf04ea4dbdcd731f7e0b",
+ "verified_at": "2026-10-06T13:10:03Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/features/docker/DockerAlertsTable.tsx",
+ "frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx",
+ "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
+ "frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx",
+ "frontend-modern/src/features/vmware/VsphereAlertsTable.tsx",
+ "frontend-modern/src/utils/alertDetailPresentation.ts",
+ "frontend-modern/src/utils/format.ts",
+ "frontend-modern/src/utils/relativeTimeClock.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/features/docker/DockerAlertsTable.tsx": "752554fff4c5091823aed3b5f12434fa45a8c0ca43d5ad4367280dea938c8b4d",
+ "frontend-modern/src/features/kubernetes/KubernetesAlertsTable.tsx": "7af91f77431771b4691a7b4b7e1ebd92656c38dc5aa4ecc72cd0e40f47df7664",
+ "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "6315591667690bdf7b588524b5c2506643a3a24d2269b8cf050d83e1e274fa97",
+ "frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx": "7361527eedafc6a3e5ef9fbea8689fd802c4bcc487f4741e3c5a10649ea64d03",
+ "frontend-modern/src/features/vmware/VsphereAlertsTable.tsx": "e8154ec75bcbb6daa6f3bf32fd8bc0749d6649bae8e31ac7e3c7be345feebcae",
+ "frontend-modern/src/utils/alertDetailPresentation.ts": "5c3e6beb5df3d255c01b38e566163e630dd93ed1eebb82539c68110c3bf027ea",
+ "frontend-modern/src/utils/format.ts": "7757da44a87231405223b7a88bbf820ed905b4823f7f2955b9c9f7f488672a57",
+ "frontend-modern/src/utils/relativeTimeClock.ts": "877686e957292e07dc233be81b52d97bccfa1fb8aeddc63a82b0abf4d5c5e41f"
+ },
+ "routes": [
+ "/vmware/overview",
+ "/vmware/activity",
+ "/truenas/overview",
+ "/docker/overview",
+ "/kubernetes/overview"
+ ],
+ "viewports": [
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 600,
+ "height": 844
+ },
+ {
+ "width": 768,
+ "height": 1000
+ },
+ {
+ "width": 1024,
+ "height": 1000
+ },
+ {
+ "width": 1440,
+ "height": 1000
+ },
+ {
+ "width": 1920,
+ "height": 1000
+ }
+ ],
+ "states": [
+ "Private mock stack on main 92d946d057 plus this change (rebased onto main 13d604e1c7, whose node-temperature alert and release-preflight changes touch no frontend file here)",
+ "Before: alert-table Started read 'Oct 6, 10:34 AM' and clipped on 2-3 rows at 390 and 768 on TrueNAS and vSphere (by 13-31px)",
+ "After: Started reads as an age ('9m ago', '2h ago') and clips at no width from 390 to 1920 on TrueNAS and vSphere; the cell's hover title is the exact time ('Oct 6, 2026, 11:52 AM') and the drawer's Started row keeps it",
+ "Docker and Kubernetes overviews have no provider incidents in the mock, so an incident started two hours earlier was injected into the API responses (websocket held): both read '2h ago' with the exact time on hover, right-aligned, unclipped at 390, 768 and 1440; Docker's Started column now uses the numeric-value alignment kind like the other three",
+ "Shared clock: with Playwright's page clock installed and the websocket held so no data changed, vSphere Started ages went from '9m ago' to '3h ago' after a 3-hour fast-forward and vSphere Activity from '1d ago' to '2d ago' after 24 hours; before this change the ages stayed frozen (unit test fails without the clock)"
+ ],
+ "interactions": [
+ "Measured the Started column and every other column for clipping at six widths",
+ "Read the Started cell's hover title",
+ "Fast-forwarded the page clock with live data held and re-read the ages"
+ ]
+}
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index 7973df8bb9..527c07f433 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -2556,6 +2556,10 @@ describe('shared primitive guardrails', () => {
expect(source).toContain('formatPlatformAlertResourceType');
expect(source).toContain('formatPlatformAlertStartedAt');
expect(source).toContain('formatPlatformAlertDetailDateTime');
+ // Started reads as an age on the shared clock, the exact time on hover.
+ expect(source).toContain('useRelativeTimeNow');
+ expect(source).toContain('formatPlatformAlertStartedAt(incident.startedAt, now())');
+ expect(source).toContain('title={formatPlatformAlertDetailDateTime(incident.startedAt)}');
expect(source).not.toContain('severityVariant');
expect(source).not.toContain('severityTextClass');
expect(source).not.toContain('const alertTone');
@@ -6448,6 +6452,9 @@ describe('shared primitive guardrails', () => {
expect(sharedPlatformPageSource).toContain('export function PlatformTableRelativeTimeValue');
expect(sharedPlatformPageSource).toContain('formatPlatformTableRelativeTimeValue');
+ // Ages read the shared clock: rows stay mounted across refreshes, so an age
+ // computed only at render froze while its timestamp did not change.
+ expect(sharedPlatformPageSource).toContain('useRelativeTimeNow');
expect(sharedPlatformPageSource).toContain('formatRelativeTime(value');
for (const [path, source] of platformRelativeTimeConsumers) {
diff --git a/frontend-modern/src/features/docker/DockerAlertsTable.tsx b/frontend-modern/src/features/docker/DockerAlertsTable.tsx
index 95168b2761..295ab82f03 100644
--- a/frontend-modern/src/features/docker/DockerAlertsTable.tsx
+++ b/frontend-modern/src/features/docker/DockerAlertsTable.tsx
@@ -31,6 +31,7 @@ import {
formatPlatformAlertResourceType,
formatPlatformAlertStartedAt,
} from '@/utils/alertDetailPresentation';
+import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
import {
formatAlertSeverityLabel,
@@ -112,6 +113,7 @@ export const DockerAlertsTable: Component<{
emptyDescription: string;
showToolbar?: boolean;
}> = (props) => {
+ const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as DockerIncidentSeverityFilter,
@@ -186,7 +188,7 @@ export const DockerAlertsTable: Component<{
Host
Started
@@ -276,9 +278,11 @@ export const DockerAlertsTable: Component<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
= (props) => {
+ const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as KubernetesIncidentSeverityFilter,
@@ -288,7 +290,9 @@ export const KubernetesAlertsTable: Component<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
{
- const resolved: PlatformTableRelativeTimeValueOptions = {};
+ const resolved: PlatformTableRelativeTimeValueOptions = { now: now() };
if (props.compact !== undefined) resolved.compact = props.compact;
if (props.emptyText !== undefined) resolved.emptyText = props.emptyText;
return resolved;
diff --git a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
index 6fce7bebc7..30b1cd1476 100644
--- a/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
+++ b/frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx
@@ -38,6 +38,7 @@ import {
formatPlatformAlertResourceType,
formatPlatformAlertStartedAt,
} from '@/utils/alertDetailPresentation';
+import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
import {
formatAlertSeverityLabel,
@@ -116,6 +117,7 @@ export const TrueNASAlertsTable: Component<{
emptyDescription: string;
showToolbar?: boolean;
}> = (props) => {
+ const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as TrueNASIncidentSeverityFilter,
@@ -280,7 +282,9 @@ export const TrueNASAlertsTable: Component<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
= (props) => {
+ const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as VmwareIncidentSeverityFilter,
@@ -308,7 +310,9 @@ export const VsphereAlertsTable: Component<{
- {formatPlatformAlertStartedAt(incident.startedAt)}
+
+ {formatPlatformAlertStartedAt(incident.startedAt, now())}
+
diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
index d62aa6f3f7..1ef9afea0d 100644
--- a/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
+++ b/frontend-modern/src/features/vmware/__tests__/VsphereAlertsTable.test.tsx
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { VsphereAlertsTable } from '@/features/vmware/VsphereAlertsTable';
import { buildVmwareIncidentRows } from '@/features/vmware/vmwarePageModel';
@@ -39,6 +39,7 @@ const makeHost = (overrides: Partial = {}): Resource =>
afterEach(() => {
cleanup();
+ vi.useRealTimers();
});
describe('VsphereAlertsTable', () => {
@@ -171,4 +172,42 @@ describe('VsphereAlertsTable', () => {
await fireEvent.click(row);
expect(screen.getByText('lab-vcenter')).toBeInTheDocument();
});
+ it('reads Started as an age with the exact time on hover', () => {
+ const startedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
+ const host = makeHost();
+ host.incidents = [{ ...host.incidents![0]!, startedAt }];
+
+ render(() => (
+ }
+ emptyTitle="No signals"
+ emptyDescription="No signals"
+ showToolbar={false}
+ />
+ ));
+
+ const age = screen.getByText('2h ago');
+ expect(age).toHaveAttribute('title', expect.stringMatching(/\d{4}/));
+ });
+ it('keeps the Started age moving while the row stays mounted', () => {
+ vi.useFakeTimers({ now: new Date('2026-10-06T12:00:00Z') });
+ const host = makeHost();
+ host.incidents = [{ ...host.incidents![0]!, startedAt: '2026-10-06T10:00:00Z' }];
+
+ render(() => (
+ }
+ emptyTitle="No signals"
+ emptyDescription="No signals"
+ showToolbar={false}
+ />
+ ));
+ expect(screen.getByText('2h ago')).toBeInTheDocument();
+
+ // No data change: only the clock moves.
+ vi.advanceTimersByTime(3 * 60 * 60 * 1000);
+ expect(screen.getByText('5h ago')).toBeInTheDocument();
+ });
});
diff --git a/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts b/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts
index e0c568abae..4fa2af944e 100644
--- a/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts
+++ b/frontend-modern/src/utils/__tests__/alertDetailPresentation.branchcov0724pm.test.ts
@@ -11,7 +11,7 @@
* the provider's presentation table does not cover).
* - formatPlatformAlertEntityType: the `normalized === 'host'` arm (line 92)
* existing tests cover 'vm' / 'datastore' but never 'host'.
- * - formatPlatformAlertStartedAt: the valid-date fall-through to toLocaleString
+ * - formatPlatformAlertStartedAt: the valid-date fall-through to a compact age
* (line 102 -> 110); existing tests only feed absent / pre-2000 values.
* - formatPlatformAlertDetailDateTime: the valid-date fall-through to
* toLocaleString (line 117 -> 126); existing tests only feed absent /
@@ -98,25 +98,14 @@ describe('platform alert date formatting - present, post-2000 timestamps', () =>
else process.env.TZ = previousTz;
});
- it('formatPlatformAlertStartedAt renders a valid timestamp, omitting the year (table format)', () => {
- const value = '2024-03-15T14:30:00Z';
- const result = formatPlatformAlertStartedAt(value);
+ it('formatPlatformAlertStartedAt renders a valid timestamp as an age (table format)', () => {
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
+ const result = formatPlatformAlertStartedAt(twoHoursAgo);
- // Happy path, not the placeholder and not a raw passthrough.
- expect(result).not.toBe('-');
- expect(result).not.toBe(value);
-
- // The compact table format has no year — the structural difference
- // from the detail format and from the raw ISO string.
- expect(result).not.toMatch(/2024/);
-
- // Under TZ=UTC the day-of-month renders as 15 in every locale.
- expect(result).toMatch(/15/);
-
- // Mirrors the exact option set the function hardcodes; pinned to the
- // concrete en-US rendering under TZ=UTC (the structural regex checks
- // above carry the locale-independent verification).
- expect(result).toBe('Mar 15, 02:30 PM');
+ // An open alert's table cell answers how long it has been going on; the
+ // exact time is the hover text and the drawer row.
+ expect(result).toBe('2h ago');
+ expect(result).not.toBe(twoHoursAgo);
});
it('formatPlatformAlertDetailDateTime renders a valid timestamp, including the year (detail format)', () => {
diff --git a/frontend-modern/src/utils/__tests__/relativeTimeClock.test.ts b/frontend-modern/src/utils/__tests__/relativeTimeClock.test.ts
new file mode 100644
index 0000000000..5a92bbd0a3
--- /dev/null
+++ b/frontend-modern/src/utils/__tests__/relativeTimeClock.test.ts
@@ -0,0 +1,31 @@
+import { createRoot } from 'solid-js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { RELATIVE_TIME_TICK_MS, useRelativeTimeNow } from '@/utils/relativeTimeClock';
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('useRelativeTimeNow', () => {
+ it('ticks while read inside a component and stops when nothing reads it', () => {
+ vi.useFakeTimers({ now: new Date('2026-10-06T10:00:00Z') });
+
+ const { now, dispose } = createRoot((dispose) => ({ now: useRelativeTimeNow(), dispose }));
+ expect(now()).toBe(Date.parse('2026-10-06T10:00:00Z'));
+
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ expect(now()).toBe(Date.parse('2026-10-06T10:00:30Z'));
+
+ dispose();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('reads the time directly outside a reactive owner', () => {
+ vi.useFakeTimers({ now: new Date('2026-10-06T10:00:00Z') });
+ const now = useRelativeTimeNow();
+ vi.setSystemTime(new Date('2026-10-06T12:00:00Z'));
+ expect(now()).toBe(Date.parse('2026-10-06T12:00:00Z'));
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/frontend-modern/src/utils/alertDetailPresentation.ts b/frontend-modern/src/utils/alertDetailPresentation.ts
index 4dcd883bd0..ed57208313 100644
--- a/frontend-modern/src/utils/alertDetailPresentation.ts
+++ b/frontend-modern/src/utils/alertDetailPresentation.ts
@@ -1,4 +1,5 @@
import type { ResourceType } from '@/types/resource';
+import { formatRelativeTime } from '@/utils/format';
export type PlatformAlertProvider = 'docker' | 'kubernetes' | 'truenas' | 'vmware';
@@ -95,18 +96,17 @@ export function formatPlatformAlertEntityType(value: string): string {
return normalized ? titleCaseToken(normalized) : '-';
}
-export function formatPlatformAlertStartedAt(value: string | undefined): string {
+// An open alert's Started cell answers how long it has been going on, so it
+// reads as an age ("2h ago"), as alert cards do. The exact time is the cell's
+// hover text and the drawer's Started row (formatPlatformAlertDetailDateTime).
+// Tables pass the shared relative-time clock as `now` so the age keeps moving.
+export function formatPlatformAlertStartedAt(value: string | undefined, now?: number): string {
if (!value) return '-';
const parsed = new Date(value);
if (isUnsetDate(parsed)) return '-';
- return parsed.toLocaleString(undefined, {
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
+ return formatRelativeTime(parsed, { compact: true, emptyText: '-', now }) || '-';
}
export function formatPlatformAlertDetailDateTime(value?: string): string {
diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts
index c8d180b13e..b6f3ba94ec 100644
--- a/frontend-modern/src/utils/format.ts
+++ b/frontend-modern/src/utils/format.ts
@@ -148,10 +148,12 @@ export function formatAbsoluteTime(timestamp: number | undefined): string {
* @param timestamp - Unix ms number, ISO date string, Date object, or undefined
* @param options.compact - Use short format: "5m ago" instead of "5 mins ago"
* @param options.emptyText - Text for falsy input (default: '')
+ * @param options.now - The time to measure from (default: Date.now()). A cell
+ * that must keep its age current passes the shared relative-time clock.
*/
export function formatRelativeTime(
timestamp: number | string | Date | undefined,
- options?: { compact?: boolean; emptyText?: string },
+ options?: { compact?: boolean; emptyText?: string; now?: number },
): string {
if (!timestamp) return options?.emptyText ?? '';
@@ -164,7 +166,7 @@ export function formatRelativeTime(
ms = timestamp.getTime();
}
- const diffMs = Date.now() - ms;
+ const diffMs = (options?.now ?? Date.now()) - ms;
return formatTimeDiff(diffMs, options?.compact);
}
diff --git a/frontend-modern/src/utils/relativeTimeClock.ts b/frontend-modern/src/utils/relativeTimeClock.ts
new file mode 100644
index 0000000000..43cd334e7e
--- /dev/null
+++ b/frontend-modern/src/utils/relativeTimeClock.ts
@@ -0,0 +1,32 @@
+import { createSignal, getOwner, onCleanup } from 'solid-js';
+
+// One clock for every relative time on screen ("2h ago"), so ages keep moving
+// while rows stay mounted. Tables keep their rows across data refreshes, and a
+// timestamp that does not change would otherwise freeze its age at whatever it
+// read when the row first rendered. It ticks while anything reads it and stops
+// when nothing does.
+export const RELATIVE_TIME_TICK_MS = 30_000;
+
+const [now, setNow] = createSignal(Date.now());
+let readers = 0;
+let timer: ReturnType | undefined;
+
+// Inside a component or reactive root, the returned accessor is the shared
+// ticking signal. Outside one there is nothing to stop a timer, so it reads the
+// time directly.
+export function useRelativeTimeNow(): () => number {
+ if (!getOwner()) return () => Date.now();
+ readers += 1;
+ if (timer === undefined) {
+ setNow(Date.now());
+ timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_TICK_MS);
+ }
+ onCleanup(() => {
+ readers -= 1;
+ if (readers === 0 && timer !== undefined) {
+ clearInterval(timer);
+ timer = undefined;
+ }
+ });
+ return now;
+}