swarm-ui: self-updating RelativeTime component

Closes #3445.

New ui/relative-time/RelativeTime — takes a UTC epoch-ms instant, not
a precomputed age, and re-renders itself on a 1s interval so a tab
left open doesn't silently show a frozen 'fresh (5s ago)' hours
later. Pauses while the document is hidden, resyncs immediately on
becoming visible again.

StatusChip's label widened from string to ComponentChildren so a
chip can embed the live ticker (a plain string couldn't carry it).
HivesPage's freshness column now derives the age from last_seen_unix
client-side via RelativeTime instead of rendering the once-computed
age_seconds from the API response.

Verified: tsc clean, build succeeds, screenshotted the components
page (ticking observed advancing within one virtual-time-budget
window) and the hives page (fresh/stale/never-reported all render
correctly).
This commit is contained in:
iris 2026-08-18 22:07:17 +02:00 committed by mara
commit a5ef31ed8b
4 changed files with 91 additions and 5 deletions

View file

@ -8,6 +8,7 @@
import { useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { Panel } from '../ui/panel/Panel.js';
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
import { TextField } from '../ui/text-field/TextField.js';
@ -115,6 +116,17 @@ export function ComponentsPage() {
</Sample>
</Section>
<Section title="RelativeTime">
<div class="components-chip-row">
<Sample label="5s ago, ticking">
<RelativeTime epochMs={Date.now() - 5_000} />
</Sample>
<Sample label="2h ago, ticking">
<RelativeTime epochMs={Date.now() - 2 * 3_600_000} />
</Sample>
</div>
</Section>
<Section title="TextField">
<Sample label="editable">
<TextFieldSample />

View file

@ -11,9 +11,9 @@ import { useEffect, useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
import { fmtAgo } from '../util.js';
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
@ -22,7 +22,10 @@ interface HiveStatus {
domain: string | null;
freshness: Freshness;
last_seen_unix: number | null;
age_seconds: number | null;
// `age_seconds` (a once-computed-at-fetch age) is part of the wire
// shape but deliberately unused here — rendering it directly is the
// exact bug `RelativeTime` exists to fix, so this page recomputes the
// age client-side from `last_seen_unix` instead.
}
// One tone + label per freshness value. `unknown` (reporting but absent
@ -54,8 +57,20 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
header: 'status',
render: (h) => {
const { tone, label } = FRESHNESS[h.freshness];
const age = h.age_seconds !== null ? ` (${fmtAgo(h.age_seconds)})` : '';
return <StatusChip tone={tone} label={label + age} />;
return (
<StatusChip
tone={tone}
label={
h.last_seen_unix !== null ? (
<>
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
</>
) : (
label
)
}
/>
);
},
},
];