Plate № 25 · UX primitives · time
A pattern from the gf.cx specimen book
Human-friendly relative time · days ago, exact on hover
"Represent as 'days ago' and have a data-tool-tip that, on hover, as per standard, tells you the exact time / date / year."
Why it's human-centric — first
A timestamp like 2026-07-18T07:12:49-04:00 is correct and nearly unreadable. To answer the only question a reader actually has — is this fresh or stale? — they have to subtract that instant from now in their head, across a timezone, maybe across a date boundary. That's arithmetic the page can do for them, and should.
People don't reason about the recent past in ISO-8601. They reason in elapsed terms: "a minute ago", "this morning", "yesterday", "last week". Relative time answers the question directly — 3d ago lands as freshness at a glance, no mental math, no locale in the reader's head. It's the humane default because it matches how attention already works: recency is a feeling of distance, not a coordinate.
But relative time trades away precision — 3d ago can't tell you which day, or the exact minute an incident fired, and that precision genuinely matters when you're correlating logs or filing a report. The move is not to choose. Show the human read in the cell; hang the exact instant on a hover tooltip. Progressive disclosure of precision: human-scale by default, machine-exact on demand. Nobody has to trade legible for precise — the glance is free, the detail is one hover away.
The cell answers "how long ago?"; the hover answers "exactly when?" — the same datum, read at two distances.
The shape — two reads of one instant
One stored timestamp, rendered two ways that occupy the same DOM node — the visible text is the human read, the data-tip attribute is the exact read. Three tiny stdlib-only helpers, one shared parser.
| Helper | Reads | Output |
|---|---|---|
fmt_age(iso) | the cell — the human glance | 7s / 12m / 3h / 5d ago |
fmt_exact(iso) | the data-tip — precision on demand | 18 Jul 2026, 07:12:49 -0400 |
parse_ts(iso) | the shared foundation both call | a datetime, or None on garbage |
The element, in-page
Below is the exact markup a rendered cell produces — the human age in the text node, the exact instant in data-tip. The tooltip CSS is already loaded by this page, so hovering the cell shows the styled card live (dotted underline = "there's more here"):
Last run 3d ago
Last synced 3d ago · next run in 20h
The reference implementation
Stateless, stdlib-only, and — this is the load-bearing rule — one parser. Every humanised time on the surface flows through parse_ts, so the "what does a malformed / oddly-offset timestamp mean?" decision lives in exactly one place. From ~/bin/gfcx_hub_format.py:
import re
from datetime import datetime, timezone
def parse_ts(iso):
"""The ONE place ISO parsing lives. Tolerates a trailing Z and a
colon-less TZ offset. Returns None on empty/garbage input so callers
stop each hand-rolling fromisoformat()+except."""
if not iso:
return None
s = str(iso).strip().replace("Z", "+00:00")
s = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s) # -0400 -> -04:00
try:
return datetime.fromisoformat(s)
except (ValueError, TypeError, AttributeError):
return None
def fmt_age(iso):
"""The human read for the cell: '7s/12m/3h/5d ago'."""
ts = parse_ts(iso)
if ts is None:
return iso # degrade to the raw string, never crash
s = int((datetime.now(timezone.utc) - ts).total_seconds())
if s < 60: return f"{s}s ago"
if s < 3600: return f"{s // 60}m ago"
if s < 86400: return f"{s // 3600}h ago"
return f"{s // 86400}d ago"
def fmt_exact(iso):
"""The precise read for the tooltip: '18 Jul 2026, 07:12:49 -0400'.
'' when unparseable, so the caller then omits the tooltip."""
ts = parse_ts(iso)
if ts is None:
return ""
return ts.strftime("%d %b %Y, %H:%M:%S %z").strip()
Wiring the pair into a cell — the age is the text, the exact instant is the data-tip, and the tooltip is omitted (not blank) when the instant can't be parsed:
exact = fmt_exact(iso)
tip = f' data-tip="{html_escape(exact)}"' if exact else ''
cell = f'<span class="sched-age"{tip}>{fmt_age(iso)}</span>'
The trap — the colon-less offset
The subtle failure that motivated pulling parsing into one function. A shell producing timestamps with date +%z emits an offset with no colon — -0400, not -04:00. datetime.fromisoformat rejected that before Python 3.11.
So on a status board, one row — the one job whose script stamped %z — silently showed a raw 2026-07-18T07:12:49-0400 in a column of tidy "Xh ago" cells, because parse_ts returned None and fmt_age degraded to the raw string. Worse: it rendered fine on the dev machine (Python 3.14) and only broke under the scheduled render on an older interpreter — the classic generated-artifact revert.
Fix: normalise the offset inside the one shared parser — re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s) — and every caller, on every Python version, is fixed at once. The one-parser discipline is what made a one-line fix possible.
When exactly one row of a humanised-time table shows a raw timestamp, suspect a timezone-offset or format the parser silently rejected — and fix the shared parser, not the row.
Client-side equivalent
When the cell is rendered in the browser instead of server-side, the same two-read shape in a few lines of JS — Intl gives you the exact read for free, locale-aware:
function fmtAge(iso) {
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s/60)}m ago`;
if (s < 86400) return `${Math.floor(s/3600)}h ago`;
return `${Math.floor(s/86400)}d ago`;
}
function cell(iso) {
const exact = new Date(iso).toLocaleString(undefined,
{ day:'2-digit', month:'short', year:'numeric',
hour:'2-digit', minute:'2-digit', second:'2-digit', timeZoneName:'short' });
const el = document.createElement('span');
el.textContent = fmtAge(iso);
el.setAttribute('data-tip', exact); // assets.gf.cx/tooltip picks it up
return el;
}
JS's new Date() parses the colon-less offset fine, so the trap above is Python-specific — but keep the raw-string fallback either way: a cell should never render NaN ago.
Import
The tooltip is the shared portfolio primitive — one stylesheet, keyed on the [data-tip] attribute. The relative-time helpers are copy-in (no runtime dependency).
<link rel="stylesheet" href="https://assets.gf.cx/tooltip/tooltip.css">
Limitations
- Coarse buckets by design.
fmt_agestops at days — it won't say "3 weeks" or "2 months". For long-lived rows where the calendar date matters more than elapsed distance, switch the cell to an absolute date (fmt_date→2026-07-18) and keep the exact instant on hover. - No live ticking. The age is computed at render time; "3m ago" goes stale until the next render. A long-lived dashboard should re-render (or re-run the client
fmtAge) on an interval, or the reader will trust a frozen number. - Tooltip needs a hover surface. Touch devices have no hover — the exact instant is reachable via long-press on most, but don't hide load-bearing precision behind the tooltip alone if the audience is mobile-first.
- Future instants read oddly.
fmt_ageassumes the past; a next-run time in the future needs an "in Xh" variant (shown in the demo) rather than the "ago" form.
Where it applies
- status.gf.cx — every LAST RUN / next-run cell in the job and backup tables: "
1h ago" in the column, the exact stamp on hover - Any report or dashboard listing when something last synced, deployed, ran, or was captured — freshness is the question, the exact instant is the audit detail
- Feeds, changelogs, "updated" stamps — anywhere a reader scans for recency before they need the precise moment
- Skip for scheduled/calendar dates a reader must plan around (an appointment, a deadline) — those want the absolute date up front, not "in 12d"
Reusable elements
The named, copyable pieces — lift any one without the others:
parse_ts(iso)— the single ISO parser: tolerates trailingZand colon-less offsets, returnsNoneon garbage. The one place the malformed-timestamp decision lives.fmt_age(iso)— the human read for the cell (Xs/Xm/Xh/Xd ago), degrading to the raw string rather than crashing.fmt_exact(iso)— the precise read for the tooltip (18 Jul 2026, 07:12:49 -0400), empty string when unparseable so the caller omits the tip.- the two-read cell idiom —
fmt_agein the text node +fmt_exactindata-tip, tooltip omitted (not blank) when the instant won't parse. assets.gf.cx/tooltip/tooltip.css— the shared[data-tip]primitive that renders the exact-instant hover card. One<link>. See Plate № 10.
Reference
- Source
- Dan, 2026-07-18 — a nit-pick on a status-board bug: a GitHub off-site backup row showing a raw ISO stamp instead of "days ago", plus the standing rule that the exact time/date/year must live on a hover tooltip.
- In use / example
- status.gf.cx — the backup and job tables render every LAST RUN cell as
fmt_age+ adata-tipexact instant. Hover any age to see the styled card live. - Reusable elements
parse_ts·fmt_age·fmt_exactingfcx_hub_format.py· the two-read cell idiom ·tooltip.css(listed above).- Origin
- A humanised-time landmine, 2026-07-18 — one row of raw ISO in a column of "Xh ago" cells, traced to a colon-less
%zoffset the parser rejected. The fix (normalise in the one shared parser) crystallised the deeper pattern: readable by default, precise on demand, parsed in exactly one place.