Manual Recipes Time Ago

Time Ago

elements man recipes/time-ago Read as markdown

A "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:

  • now is template state, wrapped as { value: Date } so the onTick handler can reassign it from a function parameter. Plain now: Date would only let the handler mutate properties; for a primitive replacement we need an object wrapper.
  • oninit on the template instance starts a setInterval that fires onTick every 60 seconds. The interval is created once per page mount.
  • Each binding that reads now.value (inside timeAgo(d, now.value) and inside the "now: {…}" cell) re-runs every time now.value is reassigned. The dependency tracking is automatic; the call to timeAgo doesn'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 oninit and clear it in onremove.
  • Reactivity scope. now.value is reactive within the template that declared it. To share the same now across 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 now the page computes is from new 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 of new Date().
  • Pluralization and i18n. The simple n === 1 ? ... : ... branches in timeAgo cover English. For other languages, replace the function with a call into your i18n layer or use Intl.RelativeTimeFormat (browser-native, no dependency). The reactivity shape is unchanged; only the string formatting moves.