Read platform alert Started as an age that keeps moving (#2573)

The Docker, Kubernetes, TrueNAS and vSphere alert tables showed Started
as "Oct 6, 10:34 AM". It clipped on two or three rows at 390 and 768px
(TrueNAS, vSphere), cutting off the time of day.

**What changes**
- **Started reads as an age.** An open alert's Started cell answers how
long it has been going on, so it now reads "2h ago", as alert cards do.
The cell's hover title is the exact time, and the drawer's Started row
keeps it. Unset or invalid times stay "-".
- **Docker alignment.** Docker's Started column now uses the
`numeric-value` alignment kind, like the other three tables.
- **A shared clock, which fixes a wider bug.** Platform tables keep
their rows mounted across data refreshes, so an age computed at render
froze whenever its timestamp did not change: a row could read "2h ago"
three hours later. That already applied to every
`PlatformTableRelativeTimeValue` cell, including Kubernetes events,
Docker volumes and vSphere activity.
- `useRelativeTimeNow`
(`frontend-modern/src/utils/relativeTimeClock.ts`) ticks every 30
seconds while any cell reads it, and stops when none do.
  - `formatRelativeTime` takes an optional `now`.
- The shared relative-time cell and the four alert tables pass the clock
in, and the shared-primitive guardrails pin that.

**Verification**
- **Private mock stack, TrueNAS and vSphere:** Started no longer clips
at any width from 390 to 1920px. The hover shows the exact time.
- **Docker and Kubernetes:** neither has provider incidents in the mock,
so I injected one, started two hours earlier, into the API responses
with the websocket held. Both read "2h ago" with the exact time on
hover, right-aligned and unclipped at 390, 768 and 1440.
- **The clock, live:** I installed Playwright's page clock and held the
websocket so no data changed.
- vSphere Started ages went from "9m ago" to "3h ago" after a three-hour
fast-forward.
  - vSphere Activity went from "1d ago" to "2d ago" after 24 hours.
  - A unit test proves the same and fails without the clock.
- **Tests:** 20,903 frontend tests pass, along with the shared-template
audit and the completion guard. Six `agentInstallCommand` shell tests
also fail on unmodified main on macOS, so they're environmental.
- **Review:** a second reviewer found that ages froze on mounted rows,
which led to the shared clock. Its re-review found no defects. It also
listed older spots that compute ages once outside these cells:
Kubernetes Job details, the Kubernetes drawer annotation, Proxmox backup
and replication ages, and machine subtitles. Those move onto the clock
in a separate change.
This commit is contained in:
rcourtman
2026-10-06 14:57:54 +01:00
committed by GitHub
15 changed files with 246 additions and 37 deletions
@@ -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
@@ -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,
@@ -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"
]
}
@@ -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) {
@@ -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
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-10 w-[15%] md:w-[10%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-10 w-[15%] md:w-[10%]`}
>
Started
</TableHead>
@@ -276,9 +278,11 @@ export const DockerAlertsTable: Component<{
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatPlatformAlertStartedAt(incident.startedAt)}
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
{formatPlatformAlertStartedAt(incident.startedAt, now())}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-narrow-hidden hidden text-base-content xl:table-cell`}
@@ -31,6 +31,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 KubernetesAlertsTable: Component<{
emptyDescription: string;
showToolbar?: boolean;
}> = (props) => {
const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as KubernetesIncidentSeverityFilter,
@@ -288,7 +290,9 @@ export const KubernetesAlertsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatPlatformAlertStartedAt(incident.startedAt)}
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
{formatPlatformAlertStartedAt(incident.startedAt, now())}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content xl:table-cell`}
@@ -39,6 +39,7 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable';
import type { Resource } from '@/types/resource';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import {
matchesSearchTermSplit,
splitSearchExclusions,
@@ -776,6 +777,7 @@ export type PlatformTableRelativeTimeValueInput = number | string | Date | null
export type PlatformTableRelativeTimeValueOptions = {
compact?: boolean;
emptyText?: string;
now?: number;
};
export const formatPlatformTableRelativeTimeValue = (
@@ -788,6 +790,7 @@ export const formatPlatformTableRelativeTimeValue = (
formatRelativeTime(value, {
compact: options.compact ?? true,
emptyText,
now: options.now,
}) || emptyText
);
};
@@ -797,8 +800,11 @@ export function PlatformTableRelativeTimeValue(props: {
compact?: boolean;
emptyText?: string;
}) {
// Rows stay mounted across refreshes, so the age reads the shared clock to
// keep moving while the timestamp itself does not change.
const now = useRelativeTimeNow();
const options = (): PlatformTableRelativeTimeValueOptions => {
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;
@@ -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<{
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatPlatformAlertStartedAt(incident.startedAt)}
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
{formatPlatformAlertStartedAt(incident.startedAt, now())}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content xl:table-cell`}
@@ -33,6 +33,7 @@ import {
formatPlatformAlertResourceType,
formatPlatformAlertStartedAt,
} from '@/utils/alertDetailPresentation';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
import {
formatAlertSeverityLabel,
@@ -120,6 +121,7 @@ export const VsphereAlertsTable: Component<{
// column hides (the row drawer still names it).
singleVcenter?: boolean;
}> = (props) => {
const now = useRelativeTimeNow();
const tableState = createPlatformTableFilterState({
resources: () => props.incidents,
initialStatus: 'all' as VmwareIncidentSeverityFilter,
@@ -308,7 +310,9 @@ export const VsphereAlertsTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatPlatformAlertStartedAt(incident.startedAt)}
<span title={formatPlatformAlertDetailDateTime(incident.startedAt)}>
{formatPlatformAlertStartedAt(incident.startedAt, now())}
</span>
</TableCell>
</TableRow>
<Show when={isExpanded()}>
@@ -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> = {}): 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(() => (
<VsphereAlertsTable
incidents={buildVmwareIncidentRows([host])}
emptyIcon={<span />}
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(() => (
<VsphereAlertsTable
incidents={buildVmwareIncidentRows([host])}
emptyIcon={<span />}
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();
});
});
@@ -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)', () => {
@@ -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);
});
});
@@ -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 {
+4 -2
View File
@@ -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);
}
@@ -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<typeof setInterval> | 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;
}