Time Ago
elements man recipes/time-ago Read as markdownA "5 minutes ago" timestamp that updates itself. A now value on the template
is reassigned every minute by a setInterval. Every binding that reads now
re-runs, so every timeAgo(date, now) cell on the page advances without
explicit refresh. The pattern is short, all client-side, and works for any UI
that needs a value to drift over time.
Page setup
elements create page time-ago
The page does nothing on the server beyond rendering. All the reactivity is
client-side via the template's now and the setInterval that updates it.
app/pages/time-ago/index.ts:
import timeAgo from "./template";
export default function route(req, res) {
let serverNow = new Date();
let sampleDates = [
new Date(serverNow.getTime() - 30_000),
new Date(serverNow.getTime() - 5 * 60_000),
new Date(serverNow.getTime() - 2 * 60 * 60_000),
new Date(serverNow.getTime() - 3 * 24 * 60 * 60_000),
];
return new timeAgo({ sampleDates });
}
The route passes a few sample timestamps anchored to the server's clock so the
demo shows different "ago" ranges. The dates are static (computed once on
render); the relative string against each one changes as the page's local now
ticks.
app/pages/time-ago/template.html:
import "./style.css";
function timeAgo(date: Date, now: Date): string {
let seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 5) {
return "just now";
}
if (seconds < 60) {
return `${seconds} seconds ago`;
}
let minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
}
let hours = Math.floor(minutes / 60);
if (hours < 24) {
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
}
let days = Math.floor(hours / 24);
return days === 1 ? "1 day ago" : `${days} days ago`;
}
function onTick(now: { value: Date }) {
now.value = new Date();
}
<html class="time-ago"
(sampleDates: Date[],
private now: { value: Date } = { value: new Date() })
oninit={() => setInterval(() => onTick(now), 60_000)}>
<h1>time ago</h1>
<ul>
<li e:for={d of sampleDates}>
<time>{d.toISOString()}</time>
<span class="ago">{timeAgo(d, now.value)}</span>
</li>
</ul>
<p class="now">now: {now.value.toLocaleTimeString()}</p>
</html>
Three things make the page tick:
nowis template state, wrapped as{ value: Date }so theonTickhandler can reassign it from a function parameter. Plainnow: Datewould only let the handler mutate properties; for a primitive replacement we need an object wrapper.oniniton the template instance starts asetIntervalthat firesonTickevery 60 seconds. The interval is created once per page mount.- Each binding that reads
now.value(insidetimeAgo(d, now.value)and inside the "now: {…}" cell) re-runs every timenow.valueis reassigned. The dependency tracking is automatic; the call totimeAgodoesn't need to know it's reactive.
timeAgo(date, now) is a pure function: same inputs always produce the same
output. Pure inside reactive bindings is the right shape: the function reads two
reactive sources (d, now.value), produces a string, and the binding
rerenders whenever either changes. Adding more "fuzz" tiers (seconds, weeks,
months) is just adding branches to timeAgo; no plumbing changes.
Routes
Register the page in index.ts:
import timeAgo from "#app/pages/time-ago";
// ...
app.route("/time-ago", timeAgo);
Notes
- Tick cadence. Sixty seconds matches the resolution of "minutes ago"
labels. For an "X seconds ago" label that updates per second, switch to
setInterval(..., 1000). For sparse usage where the page is open for hours, a slower interval saves CPU at the cost of slightly stale labels. - Stopping the interval. This recipe doesn't clear the interval. Browsers
garbage-collect interval handles when the page navigates away (because the
closures referencing the template state are themselves collected). For
long-lived single-page apps that keep the template alive across many
navigations, capture the handle in
oninitand clear it inonremove. - Reactivity scope.
now.valueis reactive within the template that declared it. To share the samenowacross multiple templates, lift it to a module-level reactive value (or a singleton service) so every consumer reads the same source. The recipe keeps it per-page because the tick interval should run only when at least one timestamp is visible. - Server time vs browser time. The dates the page receives from the route
are anchored to the server's clock. The
nowthe page computes is fromnew Date()on the browser, which can drift if the user's system clock is off. For most "X ago" UIs this drift is invisible; if it matters (clinical timestamps, billing), send the server's current time with the data and use that as the base instead ofnew Date(). - Pluralization and i18n. The simple
n === 1 ? ... : ...branches intimeAgocover English. For other languages, replace the function with a call into your i18n layer or useIntl.RelativeTimeFormat(browser-native, no dependency). The reactivity shape is unchanged; only the string formatting moves.