Feedback
Freshness
Shows how old a piece of data is, keeps itself current and flags it once it has gone stale.
- Built on
<time>Intl.RelativeTimeFormatPage Visibility APIrole="status"- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.7 kB
- Dependencies
- React
Exchange rates
Inventory
Try its options
Loading its options.
Code
"use client";
import { useEffect, useState } from "react";
import { Freshness } from "@/components/equip/freshness";
const row = { display: "grid", gap: 2 } as const;
const label = { fontSize: 13, fontWeight: 650, color: "var(--eq-ink)" } as const;
export default function Demo() {
// "Now" only exists after mount; the first frame is a quiet placeholder.
const [now, setNow] = useState<number | null>(null);
useEffect(() => setNow(Date.now()), []);
return (
<div style={{ display: "grid", gap: 12, width: 216 }}>
<div style={row}>
<span style={label}>Exchange rates</span>
{now == null ? <span style={{ height: 18 }} /> : <Freshness at={now - 3 * 60_000} />}
</div>
<div style={row}>
<span style={label}>Inventory</span>
{now == null ? <span style={{ height: 36 }} /> : <Freshness at={now - 12 * 60_000} staleAfter={600} />}
</div>
</div>
);
}Your versionchange its options in Look, This part
Examples
Going stale
Stale after 15 seconds here, so you can watch it turn; screen readers hear the change once, not every tick.
Queue depth: 1,204 jobs
Code
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { Freshness } from "@/components/equip/freshness";
export default function Example() {
const [at, setAt] = useState<number | null>(null);
useEffect(() => setAt(Date.now()), []);
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12 }}>
<div style={{ display: "grid", gap: 2, minWidth: 220 }}>
<span style={{ fontSize: 13, fontWeight: 650 }}>Queue depth: 1,204 jobs</span>
{at == null ? <span style={{ height: 18 }} /> : <Freshness at={at} staleAfter={15} expiredAfter={45} />}
</div>
<Button size="s" variant="outline" onClick={() => setAt(Date.now())}>
Refresh
</Button>
</div>
);
}Prefixes and expiry
Set expiredAfter when old data is wrong, not just old; the wording changes as well as the dot.
Team calendar
Billing webhooks
DNS for acme.dev
Code
"use client";
import { useEffect, useState } from "react";
import { Freshness } from "@/components/equip/freshness";
const rows = [
{ name: "Team calendar", prefix: "Synced", ago: 40, staleAfter: 300 },
{ name: "Billing webhooks", prefix: "Checked", ago: 25 * 60, staleAfter: 900, expiredAfter: 3600 },
{ name: "DNS for acme.dev", prefix: "Checked", ago: 2 * 3600 + 300, staleAfter: 900, expiredAfter: 3600 },
];
export default function Example() {
const [now, setNow] = useState<number | null>(null);
useEffect(() => setNow(Date.now()), []);
return (
<div style={{ display: "grid", gap: 10, width: "100%", maxWidth: 420 }}>
{rows.map((row) => (
<div
key={row.name}
style={{ display: "flex", flexWrap: "wrap", justifyContent: "space-between", gap: "2px 12px", fontSize: 13 }}
>
<span style={{ fontWeight: 650 }}>{row.name}</span>
{now == null ? (
<span style={{ height: 18 }} />
) : (
<Freshness
at={now - row.ago * 1000}
prefix={row.prefix}
staleAfter={row.staleAfter}
expiredAfter={row.expiredAfter}
/>
)}
</div>
))}
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
at | Date | string | number | none | When the data was fetched: a Date, an ISO string or epoch milliseconds. |
staleAfter | number | 300 | Seconds after which the data counts as stale. |
expiredAfter | number | none | Seconds after which the data counts as out of date. Leave unset for no expired state. |
prefix | string | "Updated" | Word before the age, such as Synced or Checked. |
onStale | () => void | none | Called once when the data turns stale while shown, or on mount if it already is. A good place to refetch. |
...rest | ComponentProps<"time"> | none | Every native time attribute. dateTime is set for you; a title you pass replaces the local date and time. |
Anatomy
| root | .eq-freshness | A time element with dateTime in ISO form and data-state fresh, stale or expired. |
|---|---|---|
| dot | .eq-freshness-dot | Filled when fresh or expired, a ring when stale. Grey until mounted. |
| text | .eq-freshness-text | |
| status | .eq-freshness-status | Visually hidden live region, filled only when the state changes. |
Notes
- The server and the first client frame show the absolute time in UTC, so the static page hydrates cleanly; relative text follows after mount.
- data-state is only set after mount, because the server cannot know how old the data will be when the page is read.
- It updates exactly when the wording would change: after 10 seconds, then every 10 seconds, then each minute, hour and day.
- Timers stop while the tab is hidden and recompute when it is shown again.
- The ticking text is not a live region; screen readers hear only the change to stale or expired, once.
- Stale and expired add words to the text, so the state never rests on the dot colour alone.
- The title shows the full local date and time on hover.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/freshness.tsx162 lines
"use client";
import { useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import "./freshness.css";
export type FreshnessProps = Omit<ComponentProps<"time">, "dateTime" | "children"> & {
/** When the data was fetched: a Date, an ISO string or epoch milliseconds. */
at: Date | string | number;
/** Seconds after which the data counts as stale. */
staleAfter?: number;
/** Seconds after which the data counts as out of date. Leave unset for no expired state. */
expiredAfter?: number;
/** Word before the age, such as "Synced" or "Checked". */
prefix?: string;
/** Called once when the data turns stale (or expired) while shown, and on mount if it already is. */
onStale?: () => void;
};
type State = "fresh" | "stale" | "expired";
const DAY = 86_400;
function toMs(at: Date | string | number) {
if (at instanceof Date) return at.getTime();
if (typeof at === "number") return at;
return Date.parse(at);
}
const two = (n: number) => String(n).padStart(2, "0");
/** The same string on the server and in the first client render, so hydration matches. */
function utcLabel(ms: number) {
const d = new Date(ms);
return `${d.getUTCFullYear()}-${two(d.getUTCMonth() + 1)}-${two(d.getUTCDate())} ${two(d.getUTCHours())}:${two(d.getUTCMinutes())} UTC`;
}
/** Relative wording for an age in seconds, and the age at which that wording next changes. */
function relative(age: number, format: Intl.RelativeTimeFormat) {
if (age < 10) return { text: "just now", next: 10 };
if (age < 60) {
const s = Math.floor(age / 10) * 10;
return { text: format.format(-s, "second"), next: s + 10 };
}
if (age < 3600) {
const m = Math.floor(age / 60);
return { text: format.format(-m, "minute"), next: (m + 1) * 60 };
}
if (age < DAY) {
const h = Math.floor(age / 3600);
return { text: format.format(-h, "hour"), next: (h + 1) * 3600 };
}
const d = Math.floor(age / DAY);
return { text: format.format(-d, "day"), next: (d + 1) * DAY };
}
function stateAt(age: number, staleAfter: number, expiredAfter: number | undefined): State {
if (expiredAfter != null && age >= expiredAfter) return "expired";
return age >= staleAfter ? "stale" : "fresh";
}
export function Freshness({
at,
staleAfter = 300,
expiredAfter,
prefix = "Updated",
onStale,
className,
title,
...rest
}: FreshnessProps) {
const ms = toMs(at);
const valid = Number.isFinite(ms);
// null until mounted: the server and the first client frame show the absolute UTC time.
const [now, setNow] = useState<number | null>(null);
const [announcement, setAnnouncement] = useState("");
const format = useMemo(() => new Intl.RelativeTimeFormat(undefined, { numeric: "always", style: "short" }), []);
const onStaleRef = useRef(onStale);
useEffect(() => {
onStaleRef.current = onStale;
});
// Wake exactly when the wording or the state would change; never tick while the tab is hidden.
useEffect(() => {
if (!valid) return;
let timer: number | undefined;
const tick = () => {
window.clearTimeout(timer);
const t = Date.now();
setNow(t);
if (document.hidden) return;
const age = (t - ms) / 1000;
const ahead = [relative(age, format).next, staleAfter, expiredAfter].filter(
(b): b is number => b != null && b > age,
);
const delay = (Math.min(...ahead) - age) * 1000;
// Long waits are capped at a day; waking early only recomputes the same text.
timer = window.setTimeout(tick, Math.min(Math.max(delay, 0) + 25, DAY * 1000));
};
tick();
document.addEventListener("visibilitychange", tick);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", tick);
};
}, [ms, valid, staleAfter, expiredAfter, format]);
const age = now == null || !valid ? null : (now - ms) / 1000;
const state = age == null ? undefined : stateAt(age, staleAfter, expiredAfter);
// Announce only a live change into stale or expired, once, then clear the region.
const seen = useRef<{ ms: number; state: State | undefined }>({ ms, state: undefined });
useEffect(() => {
if (!state) return;
const last = seen.current;
const sameData = last.ms === ms;
seen.current = { ms, state };
if (sameData && last.state === state) return;
if (state === "fresh") {
setAnnouncement("");
return;
}
// Stale to expired is not a second onStale.
if (!sameData || last.state === undefined || last.state === "fresh") onStaleRef.current?.();
if (sameData && last.state !== undefined) {
setAnnouncement(state === "stale" ? "Data may be out of date" : "Data is out of date");
} else {
setAnnouncement("");
}
}, [ms, state]);
useEffect(() => {
if (!announcement) return;
const timer = window.setTimeout(() => setAnnouncement(""), 5000);
return () => window.clearTimeout(timer);
}, [announcement]);
let text: string;
if (!valid) text = `${prefix} at an unknown time`;
else if (age == null) text = `${prefix} ${utcLabel(ms)}`;
else {
text = `${prefix} ${relative(age, format).text}`;
if (state === "stale") text += ", may be out of date";
if (state === "expired") text += ", out of date";
}
return (
<time
{...rest}
className={["eq-freshness", className].filter(Boolean).join(" ")}
dateTime={valid ? new Date(ms).toISOString() : undefined}
data-state={state}
title={title ?? (now != null && valid ? new Date(ms).toLocaleString() : undefined)}
>
<span className="eq-freshness-dot" aria-hidden="true" />
<span className="eq-freshness-text">{text}</span>
<span className="eq-freshness-status" role="status">
{announcement}
</span>
</time>
);
}components/equip/freshness.css63 lines
@layer equip {
.eq-freshness {
--_dot: var(--eq-ink-3);
display: inline-flex;
align-items: flex-start;
gap: var(--eq-space-2);
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
font-variant-numeric: tabular-nums;
}
.eq-freshness[data-state="fresh"] {
--_dot: var(--eq-ok);
}
.eq-freshness[data-state="stale"] {
--_dot: var(--eq-warn);
color: var(--eq-ink);
}
.eq-freshness[data-state="expired"] {
--_dot: var(--eq-danger);
color: var(--eq-ink);
}
/* Centred on the first line of text, so it stays put when the text wraps. */
.eq-freshness-dot {
flex: none;
box-sizing: border-box;
width: 8px;
height: 8px;
margin-top: calc((1.4em - 8px) / 2);
border: 2px solid var(--_dot);
border-radius: var(--eq-radius-full);
background: var(--_dot);
/* As the data ages the dot shifts colour, and from filled to ring, softly. */
transition:
border-color var(--eq-dur-move) var(--eq-ease),
background-color var(--eq-dur-move) var(--eq-ease);
}
/* Stale is a ring rather than a filled dot, a shape cue on top of the colour and the words. */
.eq-freshness[data-state="stale"] .eq-freshness-dot {
background: transparent;
}
.eq-freshness-text {
min-width: 0;
}
.eq-freshness-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}