Publishing a Data Freshness Badge on a Geo-Dashboard

Part of the Pipeline Observability & Alerting guide.

Operative rule: the badge must be computed from the source timestamp — when the data was produced — never from when the build ran, because those two numbers diverge in exactly the failure this badge exists to expose.

What the Badge Is Actually Reporting

There are three timestamps in a rebuild and they mean different things. The build start is when your job woke up. The publish time is when the artifact became visible. The source timestamp is when the underlying data was last genuinely updated by whoever produces it. Only the third one describes what a reader is looking at.

The gap between them is the entire subject. A pipeline can run perfectly on schedule for a fortnight while its upstream export is frozen; every build is green, every publish succeeds, and the map has been wrong for two weeks. A badge fed from the build time reports “updated 4 minutes ago” throughout. A badge fed from the source timestamp reports the truth from the first morning.

Which of the three timestamps belongs on the badge Build start describes when the job woke up and says nothing about the data. Publish time describes when the artifact appeared and is equally silent about the data. The source timestamp describes when the underlying data was last updated and is the only one that answers the reader's question. Three clocks — only one of them is about the data build start "the job woke up" — true even when the source is frozen showing this is what makes a stale map look current publish time "the artifact appeared" — also silent about the data useful for rollbacks, useless as a freshness claim source timestamp "when the data was last updated" — the badge's only honest input take it from the payload, or from the source's last-modified header

Production-Ready Implementation

The build writes an absolute instant into the manifest; the page turns it into a relative age and keeps it current.

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path


def source_timestamp_from(payload: dict, fallback_header: str | None) -> datetime:
    """Prefer the source's own field; fall back to its Last-Modified header."""
    raw = payload.get("generated_at") or payload.get("updated_at")
    if raw:
        return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).astimezone(
            timezone.utc
        )
    if fallback_header:
        from email.utils import parsedate_to_datetime
        return parsedate_to_datetime(fallback_header).astimezone(timezone.utc)
    raise ValueError(
        "no source timestamp available — treat this as a data-quality defect, "
        "not as a reason to stamp the build time"
    )


def write_manifest(out: Path, source_ts: datetime, run_id: str,
                   warn_hours: int = 30, critical_hours: int = 72) -> None:
    out.write_text(
        json.dumps(
            {
                "source_timestamp": source_ts.isoformat(),
                "run_id": run_id,
                "thresholds": {"warn_hours": warn_hours,
                               "critical_hours": critical_hours},
            },
            indent=2,
        ),
        encoding="utf-8",
    )
const UNITS = [
  ["year", 31536000], ["month", 2592000], ["day", 86400],
  ["hour", 3600], ["minute", 60],
];

function relativeAge(seconds) {
  for (const [unit, size] of UNITS) {
    if (seconds >= size) {
      const n = Math.floor(seconds / size);
      return `${n} ${unit}${n === 1 ? "" : "s"} ago`;
    }
  }
  return "just now";
}

export async function mountFreshnessBadge(el, manifestUrl) {
  const manifest = await fetch(manifestUrl, { cache: "no-cache" }).then((r) => r.json());
  const source = new Date(manifest.source_timestamp);
  const { warn_hours: warn, critical_hours: critical } = manifest.thresholds;

  function paint() {
    const ageSeconds = (Date.now() - source.getTime()) / 1000;
    const hours = ageSeconds / 3600;
    const state = hours >= critical ? "critical" : hours >= warn ? "warn" : "ok";
    el.dataset.state = state;                     // CSS decides how each state looks
    el.querySelector(".age").textContent = relativeAge(ageSeconds);
    el.querySelector(".absolute").textContent = source.toISOString().slice(0, 16)
      .replace("T", " ") + " UTC";
    el.setAttribute("aria-label", `Data as of ${source.toUTCString()}${state}`);
  }

  paint();
  setInterval(paint, 60000);                      // stays honest on a page left open
}

Fetching the manifest with no-cache is deliberate: it is the one object in the system that must never be stale, because a cached manifest would report a freshness that is itself out of date. Everything else — tiles, style, data files — can and should be cached aggressively, as covered in Cache Invalidation Strategies.

Designing the Badge’s States

Three badge states and their thresholds Below the warning threshold the badge is quiet and states the age. Between warning and critical it becomes visually distinct and says the data is older than expected. Past critical it is unmissable and states plainly that the map should not be relied on, with the last known good time. The badge changes state before anyone has to do arithmetic current — under 30 h "Data as of 2 hours ago" · quiet, low contrast, no icon present but not competing with the map ageing — 30 h to 72 h "Data is older than expected — 2 days ago" visually distinct, still not alarming — one refresh may have been missed stale — over 72 h "This map is out of date — last updated 9 days ago" unmissable; a reader must not act on it without checking

Two design rules keep the badge useful. It must never rely on colour alone to signal state — the wording changes too, so the meaning survives a monochrome screen or a reader who cannot distinguish the hues. And it must always show the absolute timestamp somewhere, because “9 days ago” is what raises the alarm but “2026-07-27 02:41 UTC” is what goes into the incident ticket.

Where the timestamp comes from, in order of preference

Not every source announces when its data was produced, so the pipeline needs a documented order of preference rather than whatever the first implementation happened to reach for. Each fallback is weaker than the one above it, and the weakest is a defect to raise upstream rather than a solution.

Where to take the source timestamp from Best is a field inside the payload stating when the data was generated. Next is the fetched file's last-modified header. Next is the upstream export job's completion time if it is available. If none exist, the age is unknown and the badge must say so rather than substituting the build time. Four sources, in descending order of trust 1 · a field in the payload — "generated_at" or equivalent · authoritative 2 · the fetched file's Last-Modified header · usually close enough 3 · the upstream export job's completion time · when it is exposed at all 4 · nothing available → "age unknown", and a data-quality issue raised upstream

Verification Steps

  • Set the manifest’s timestamp to a known past instant and confirm the rendered age matches to the minute.
  • Leave the page open past a threshold boundary and confirm the badge changes state without a reload.
  • Remove the source timestamp from the upstream payload and confirm the build fails rather than substituting the build time.
  • Confirm the manifest is fetched with revalidation while tiles are still served from cache.
  • Read the badge with a screen reader and confirm the state is announced in words, not implied by colour.

Common Errors & Fixes

The badge always says “just now”

The pipeline is stamping the manifest with its own clock. Trace where source_timestamp is set; if the upstream provides nothing, use the fetched file’s last-modified header and raise the gap as a data-quality issue with the provider.

The age is wrong by exactly one timezone offset

A naive local timestamp is being compared against a UTC one. Make every timestamp timezone-aware at the boundary where it enters the pipeline, and serialise in ISO 8601 with an offset.

The badge lags behind on a long-lived dashboard

There is no timer, so the age was computed once at load. Repaint on an interval, and also on visibilitychange so a tab restored after a day is immediately correct.

The badge disagrees with the alerting system

They are reading different fields. Both must read the same source_timestamp from the same manifest — a badge that contradicts the on-call alert costs more trust than either one earns.

Wording the Badge for the People Who Read It

The badge’s text is the part that determines whether it changes behaviour, and it is usually written last and least carefully. Three principles make it work.

State the age in the unit the reader thinks in. “Updated 4 hours ago” is immediately meaningful; “updated 14,712 seconds ago” is not, and neither is a bare timestamp for anyone who has not memorised the current time in UTC. Relative wording for the age, absolute for the record.

Say what is stale, not just that something is. A dashboard with three layers refreshed on different cadences needs a badge that either reports the oldest of them explicitly or breaks the age out per source. Reporting a single age that silently means “the freshest layer” is worse than reporting nothing, because it invites confident action on the layer that is actually a week old.

And when the state is critical, say what to do. “This map is out of date — last updated 9 days ago. Check with the operations team before acting on it.” is a complete instruction. “Stale” is a label that leaves the reader to decide what it means, which they will do in whatever direction suits their current task.

There is also a placement question. On a wide screen the badge belongs near the map’s title, where it is read as part of the map’s identity. On a phone, screen space is scarce and the badge competes with the map itself; collapsing it to a small indicator that expands on tap keeps it available without taking a permanent share of a small viewport. What it must not do is scroll out of view — a freshness warning that only exists above the fold is invisible to a reader who arrived by deep link and started panning.

Finally, keep the badge honest when the age is unknown. A missing source timestamp should render as “data age unknown”, never as a comfortable-looking default, because an unknown age is a failure state and should look like one.

The badge is the cheapest piece of observability in the whole pipeline and frequently the most effective, because it puts the one number that matters in front of the people who will act on it rather than in a dashboard only the pipeline’s authors ever open.