Ver Fonte

feat(telemetry): dashboard charts — SQL API over D1 + the Chart.js views (CG-12, CG-13)

Replaces the scaffold page with the dashboard proper: 19 panels covering every
view of the PostHog dashboard this retires, driven by one filter row.

src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries,
breakdown,activation,retention}, all range-scoped, all parameterized against a
closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend
does no arithmetic. Rollups answer everything except the activation funnel,
which needs raw events and says where they start.

The frontend splits into a DOM-free panel registry (public/panels.js) and the
page that mounts it (public/app.js), so the render check can drive the same
registry the browser rendered from. Panels fail alone, refetch dims rather than
flashing, and every chart carries a table twin.

Two numbers are labelled rather than rounded off: range-wide "users" per
dimension is machine-days (the rollups cannot give distinct machines, and
per-day counts are taken as the largest single-event count so one machine's
install + index + usage is not counted three times), and recent activation and
retention cohorts are marked as still-converting instead of drawn as a cliff.

Both colour scales were run through the data-viz validator against the panel
surface, not picked by eye; the results are recorded in public/theme.js.

Verification, all against the committed fixture (12 machines over 10 days, every
expected number worked out by hand from the events, not recorded from a run):
  scripts/smoke-api.sh      98 assertions
  scripts/render-check.mjs  79 assertions — real Chromium over CDP, no new deps
  scripts/smoke-auth.sh     54 assertions (unchanged, still green)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry há 1 mês atrás
pai
commit
00f4621830

+ 105 - 8
telemetry-dashboard/README.md

@@ -26,9 +26,52 @@ behind the session check, and a request without a valid cookie gets a redirect (
 | `POST /login` | public | Rate-limited per IP; sets the session cookie on success. |
 | `POST /logout` | public | Clears the cookie. |
 | `GET /robots.txt` | public | `Disallow: /`. |
-| `GET /api/*` | required | JSON. `401` without a session. CG-12 adds the chart endpoints. |
+| `GET /api/*` | required | JSON. `401` without a session. See the API below. |
 | everything else | required | Static assets from `public/`. `302 /login` without a session. |
 
+## The API
+
+Every endpoint is `GET`, session-gated, and scoped by `?from=YYYY-MM-DD&to=YYYY-MM-DD`
+(inclusive, UTC days). Ranges wider than 366 days are clamped and say so in
+`range.clamped`. Responses come back Chart.js-shaped — `labels[] + datasets[]` — plus a
+`rows[]` in the data's natural shape, which is what each panel's "Show numbers" table
+renders. Bad input is a `400` with a message, never a guess. Chart data carries
+`Cache-Control: private, max-age=300`.
+
+| Endpoint | Answers |
+|---|---|
+| `/api/meta` | The days data actually exists for. The picker anchors its presets on `latest_day` so no chart ends on a day the nightly rollup has not written yet. |
+| `/api/summary` | Big numbers: production users, active machines, new machines, installs, uninstalls, indexing runs, tool calls. |
+| `/api/timeseries?metric=` | `installs_uninstalls`, `new_installs`, `production_users`, `indexing_activity`, `tool_calls`, `duration_buckets`. One dense point per day — a day with nothing is a zero, not a gap. |
+| `/api/breakdown?dim=` | `os`, `arch`, `codegraph_version`, `node_major`, `language`, `file_count_bucket`, `duration_bucket`, `target`, `scope`, `kind`, `name`, `client_name`, `name_error`. Optional `&event=`, `&metric=count\|machines`, `&limit=`. |
+| `/api/activation?window=7` | Install → first index funnel, plus the daily rate. |
+| `/api/retention` | Day 0–14 cohort curve for machines first seen in the range. |
+| `/api/health` | Liveness plus the latest event/rollup day. Uncached. |
+
+Everything reads the `daily_*` rollups and `machine_days`, which are kept forever, so a
+chart stays correct for days whose raw events have been purged. `/api/activation` is the
+one exception — "did this machine ever run an index" is not a daily aggregate — so it
+reads raw `events` and is bounded by the ingest worker's retention window. It reports
+`raw_events_from` for that reason.
+
+### Two numbers that are easy to misread
+
+Both are labelled honestly in the UI rather than rounded off into something friendlier:
+
+- **Machine-days, not users.** `daily_dim_counts.machines` is per day, so summing it over
+  a range counts a machine once per day it was active. A range-wide distinct count per
+  dimension value is not recoverable from the rollups at all, so the panels that use it
+  say "machine-days" and are share-of-total panels where the distinction does not move the
+  shape. Where a dimension rides several event types, the per-day figure is the largest
+  single-event count rather than their sum, so one machine's install + index + usage on
+  one day is not counted three times.
+- **Recent cohorts have not finished converting.** A machine that installed yesterday has
+  not had seven days to run an index, so the tail of the activation curve is a floor, not
+  a result. The API marks those days (`complete: false`, `incomplete_from`) and the panel
+  says so instead of drawing a cliff and calling it a drop in conversion. Retention does
+  the same thing with a per-day denominator: day *k* is measured only over the machines
+  that have actually had *k* days to come back.
+
 ## How the session works
 
 - The password is compared in constant time, over SHA-256 digests so the operands are always
@@ -72,21 +115,75 @@ Migrations belong to the writer, not to this worker: apply schema changes from
 ```bash
 cp .dev.vars.example .dev.vars   # placeholder secrets; also feeds `wrangler types`
 npm run check                    # vendor + wrangler types + tsc --noEmit + deploy --dry-run
+npm run seed                     # load scripts/fixture.sql into the LOCAL D1
 npm run dev                      # http://localhost:8787
 
-./scripts/smoke-auth.sh          # end-to-end auth suite against a throwaway `wrangler dev`
+npm run smoke:auth               # the auth gate            (54 assertions)
+npm run smoke:api                # the SQL and its numbers  (98 assertions)
+npm run smoke:render             # the panels, in a browser (79 assertions)
 ```
 
-`smoke-auth.sh` is the regression net for the gate: it asserts that unauthenticated requests
-reach nothing (pages, API *and* static assets), that the cookie is persistent and correctly
-flagged, that flipped/truncated/forged cookies are all rejected, that brute force is capped,
-and that rotating the password invalidates existing sessions. Run it after touching
-`src/auth.ts` or the route table in `src/index.ts`. It needs a local D1 to answer
-`/api/health`, which it seeds itself from the ingest worker's migration.
+Each suite starts its own throwaway `wrangler dev` on its own port and cleans up after
+itself, so they can be run in any order (`DASH_PORT` overrides the port).
+
+**`smoke-auth.sh`** is the regression net for the gate: unauthenticated requests reach
+nothing (pages, API *and* static assets), the cookie is persistent and correctly flagged,
+flipped/truncated/forged cookies are all rejected, brute force is capped, and rotating the
+password invalidates existing sessions. Run it after touching `src/auth.ts` or the route
+table in `src/index.ts`.
+
+**`smoke-api.sh`** checks every endpoint against `scripts/fixture.sql` — twelve machines
+over ten days, listed machine by machine in that file's header, small enough that every
+expected number was worked out by hand rather than recorded from a passing run. It also
+covers the boring half: bad dims, malformed dates, backwards ranges and over-wide ranges.
+
+**`render-check.mjs`** loads the real page in whatever Chromium is already on the machine
+(over the DevTools protocol — no new dependency; it *skips* if there is no browser) and
+reads the live Chart.js instance behind each canvas, comparing what every panel plotted
+against the same endpoint fetched from Node. That is what catches a panel wired to the
+wrong dimension, which neither of the other two suites can see. It also drives the range
+picker and asserts a clean console, so a CSP regression fails the build.
+`RENDER_SHOT=/tmp/dash.png npm run smoke:render` writes a full-page screenshot — the only
+way to check the things assertions cannot, like label collisions.
 
 ## Frontend
 
 Plain static files in `public/` — one HTML page, ES modules, no framework, no build step.
+
+| File | Holds |
+|---|---|
+| `index.html` | The shell: masthead, the one filter row, an empty grid. |
+| `panels.js` | The panel registry — data in, chart config out, no DOM. Adding a panel is one entry. |
+| `theme.js` | Palette, formatters, and the Chart.js defaults every panel inherits. |
+| `app.js` | The page: range picker, one fetch per panel, loading/empty/error states. |
+
+The split is what lets `render-check.mjs` import the *same* registry the browser just
+rendered from, so its expectations cannot drift from the panels under test.
+
+Panels fail alone: each fetches, draws and reports independently, so a failed query leaves
+the other eighteen on screen. There is no client-side cache — the only reuse is
+deduplicating identical URLs within a single render (four stat tiles share one
+`/api/summary`), and that map is discarded afterwards, so refresh really does re-ask.
+A refetch dims the previous render rather than tearing it down, so nothing jumps. Every
+chart has a "Show numbers" table twin, which is what keeps a value from being reachable
+only by hovering.
+
+### Colours
+
+Two scales, both run through the data-viz validator against this dashboard's actual chart
+surface (`#ffffff`, the panel fill) rather than picked by eye — the exact results are
+recorded at the top of `theme.js`:
+
+- **Categorical** `#a8342a #2a6f9e #17916a #c98500` — identity (which series). Slot 1 is
+  the brand oxblood stepped up into the legible lightness band. Clears every gate
+  including all-pairs colour-vision separation, with no contrast relief needed.
+- **Ordinal** `#d99a90 #c26a5c #a3423a #7a201a` — one hue, light to dark, for scales whose
+  order *is* their meaning (run length, codebase size), so the ordering is visible in the
+  colour instead of needing the legend.
+
+Nominal bars all take slot 1: colouring them by value would spend the identity channel
+re-encoding what bar length already shows. If you change a hex, re-run the validator — the
+red/green pair that "looks fine" is the one that collapses under deuteranopia.
 Workers Static Assets serves them verbatim, so third-party libraries are copied out of
 `node_modules` into `public/vendor/` by `npm run vendor` (wired into `dev` and `deploy`).
 That keeps the version pinned by the lockfile, avoids a third-party origin at runtime, and

+ 6 - 1
telemetry-dashboard/package.json

@@ -1,13 +1,18 @@
 {
   "name": "codegraph-telemetry-dashboard",
   "private": true,
+  "type": "module",
   "description": "Password-gated admin dashboard over the codegraph telemetry D1 database (stats.getcodegraph.com)",
   "scripts": {
     "vendor": "node scripts/vendor-assets.mjs",
     "dev": "npm run vendor && wrangler dev",
     "deploy": "npm run vendor && wrangler deploy",
     "types": "wrangler types",
-    "check": "npm run vendor && wrangler types && tsc --noEmit && wrangler deploy --dry-run"
+    "check": "npm run vendor && wrangler types && tsc --noEmit && wrangler deploy --dry-run",
+    "seed": "./scripts/seed-fixture.sh",
+    "smoke:auth": "./scripts/smoke-auth.sh",
+    "smoke:api": "./scripts/smoke-api.sh",
+    "smoke:render": "npm run vendor && node scripts/render-check.mjs"
   },
   "devDependencies": {
     "chart.js": "^4.4.0",

+ 377 - 17
telemetry-dashboard/public/app.js

@@ -1,8 +1,37 @@
 /**
- * Dashboard shell. CG-13 builds the Chart.js views on top of this; for now it
- * just proves the gated API and the vendored chart library are both reachable.
+ * The dashboard page: one filter row, a grid of panels, and a fetch per panel.
+ *
+ * Deliberate properties:
+ * - **One filter row, above everything it scopes.** Changing the range or
+ *   hitting refresh re-queries every panel against the same slice; no panel
+ *   carries its own time control.
+ * - **Panels fail alone.** Each one fetches, draws, and reports independently,
+ *   so a 503 on one query leaves the other eighteen on screen instead of
+ *   blanking the page.
+ * - **No client-side cache.** The only reuse is deduplicating identical URLs
+ *   within a single render (four stat tiles read one /api/summary); that map is
+ *   thrown away afterwards, so refresh really does re-ask. Anything longer-lived
+ *   is the API's `Cache-Control` doing its job in the browser's own cache.
+ * - **No skeleton flash.** A refetch dims the previous render instead of tearing
+ *   it down, so nothing jumps while new numbers land.
+ * - **Every chart has a table twin.** "Show numbers" reveals the same data as
+ *   text, which is what keeps a value from being reachable only by hovering.
  */
 
+import { PANELS } from './panels.js';
+import { applyChartDefaults, shortDay } from './theme.js';
+
+const RANGE_PRESETS = [
+  { days: 7, label: 'Last 7 days' },
+  { days: 14, label: 'Last 14 days' },
+  { days: 30, label: 'Last 30 days' },
+  { days: 90, label: 'Last 90 days' },
+];
+const DEFAULT_PRESET = 30;
+const DAY_MS = 86_400_000;
+
+const Chart = window.Chart;
+
 /** Every fetch goes through here so an expired session lands on /login instead
  *  of failing silently mid-render. */
 export async function api(path) {
@@ -11,25 +40,356 @@ export async function api(path) {
     window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
     throw new Error('session expired');
   }
-  if (!response.ok) throw new Error(`${path} responded ${response.status}`);
+  if (!response.ok) {
+    const detail = await response.json().catch(() => null);
+    throw new Error(detail?.error ?? `responded ${response.status}`);
+  }
   return response.json();
 }
 
-function set(id, text, bad = false) {
-  const el = document.getElementById(id);
-  if (!el) return;
-  el.textContent = text;
-  el.classList.toggle('bad', bad);
+// ---------------------------------------------------------------------------
+// Days
+// ---------------------------------------------------------------------------
+
+const utcDay = (atMs) => new Date(atMs).toISOString().slice(0, 10);
+const dayMs = (day) => Date.parse(`${day}T00:00:00Z`);
+const addDays = (day, delta) => utcDay(dayMs(day) + delta * DAY_MS);
+const isDay = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(dayMs(value));
+
+// ---------------------------------------------------------------------------
+// State
+// ---------------------------------------------------------------------------
+
+const state = {
+  /** Latest day the nightly rollup has written; every preset ends here. */
+  anchor: utcDay(Date.now()),
+  earliest: null,
+  preset: DEFAULT_PRESET,
+  custom: { from: null, to: null },
+  /** Panels whose table twin the reader has opened, kept across re-renders. */
+  openTables: new Set(),
+  renderToken: 0,
+};
+
+const charts = new Map();
+
+function currentRange() {
+  if (state.preset === 'custom' && state.custom.from && state.custom.to) {
+    return { from: state.custom.from, to: state.custom.to };
+  }
+  const to = state.anchor;
+  return { from: addDays(to, -(state.preset - 1)), to };
+}
+
+// ---------------------------------------------------------------------------
+// DOM helpers
+// ---------------------------------------------------------------------------
+
+function el(tag, className, text) {
+  const node = document.createElement(tag);
+  if (className) node.className = className;
+  if (text !== undefined) node.textContent = text;
+  return node;
+}
+
+const $ = (root, role) => root.querySelector(`[data-role="${role}"]`);
+
+// ---------------------------------------------------------------------------
+// Building the page
+// ---------------------------------------------------------------------------
+
+function buildFilters() {
+  const bar = document.getElementById('filters');
+  const presets = $(bar, 'presets');
+
+  for (const preset of RANGE_PRESETS) {
+    const button = el('button', 'range', preset.label);
+    button.type = 'button';
+    button.dataset.days = String(preset.days);
+    button.addEventListener('click', () => {
+      state.preset = preset.days;
+      syncFilters();
+      render();
+    });
+    presets.append(button);
+  }
+
+  const from = $(bar, 'custom-from');
+  const to = $(bar, 'custom-to');
+  const apply = $(bar, 'custom-apply');
+  apply.addEventListener('click', () => {
+    if (!isDay(from.value) || !isDay(to.value)) {
+      setRangeSummary('Enter both dates as YYYY-MM-DD.');
+      return;
+    }
+    if (from.value > to.value) {
+      setRangeSummary('The start date must come before the end date.');
+      return;
+    }
+    state.preset = 'custom';
+    state.custom = { from: from.value, to: to.value };
+    syncFilters();
+    render();
+  });
+
+  $(bar, 'refresh').addEventListener('click', () => {
+    refreshMeta().finally(render);
+  });
+}
+
+function syncFilters() {
+  const bar = document.getElementById('filters');
+  for (const button of bar.querySelectorAll('button.range')) {
+    const selected = String(state.preset) === button.dataset.days;
+    button.classList.toggle('is-selected', selected);
+    button.setAttribute('aria-pressed', String(selected));
+  }
+  const { from, to } = currentRange();
+  $(bar, 'custom-from').value = from;
+  $(bar, 'custom-to').value = to;
+}
+
+function setRangeSummary(text) {
+  document.getElementById('range-summary').textContent = text;
+}
+
+function buildPanels() {
+  const grid = document.getElementById('grid');
+  for (const panel of PANELS) {
+    const section = el('section', `panel span-${panel.span}`);
+    section.id = `panel-${panel.id}`;
+    section.dataset.panel = panel.id;
+    section.dataset.state = 'loading';
+
+    const head = el('div', 'panel-head');
+    head.append(el('h2', null, panel.title));
+    const figure = el('p', 'panel-figure');
+    figure.dataset.role = 'figure';
+    head.append(figure);
+    section.append(head);
+
+    if (panel.note) section.append(el('p', 'panel-note', panel.note));
+
+    const body = el('div', 'panel-body');
+    body.dataset.role = 'body';
+    if (panel.kind === 'chart') {
+      const wrap = el('div', 'chart-wrap');
+      const canvas = document.createElement('canvas');
+      canvas.dataset.role = 'canvas';
+      // Chart.js renders to canvas, so the accessible copy is the table twin
+      // below — say so rather than leaving a bare graphic.
+      canvas.setAttribute('role', 'img');
+      canvas.setAttribute('aria-label', `${panel.title}. The same data is in the table below.`);
+      wrap.append(canvas);
+      body.append(wrap);
+    } else if (panel.kind === 'stat') {
+      const stat = el('div', 'stat');
+      stat.dataset.role = 'stat';
+      stat.append(el('p', 'stat-value'), el('p', 'stat-caption'));
+      body.append(stat);
+    } else if (panel.kind === 'funnel') {
+      const funnel = el('div', 'funnel');
+      funnel.dataset.role = 'funnel';
+      body.append(funnel);
+    }
+
+    const status = el('p', 'panel-state');
+    status.dataset.role = 'state';
+    body.append(status);
+    section.append(body);
+
+    const toggle = el('button', 'link', 'Show numbers');
+    toggle.type = 'button';
+    toggle.dataset.role = 'toggle';
+    toggle.setAttribute('aria-expanded', 'false');
+    const table = el('div', 'table-wrap');
+    table.dataset.role = 'table';
+    table.hidden = true;
+    toggle.addEventListener('click', () => {
+      const open = table.hidden;
+      table.hidden = !open;
+      toggle.textContent = open ? 'Hide numbers' : 'Show numbers';
+      toggle.setAttribute('aria-expanded', String(open));
+      if (open) state.openTables.add(panel.id);
+      else state.openTables.delete(panel.id);
+    });
+    section.append(toggle, table);
+
+    grid.append(section);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Drawing one panel
+// ---------------------------------------------------------------------------
+
+function setState(section, name, message) {
+  section.dataset.state = name;
+  $(section, 'state').textContent = message ?? '';
+}
+
+function drawTable(section, spec) {
+  const host = $(section, 'table');
+  host.replaceChildren();
+  if (!spec) return;
+
+  const table = el('table');
+  const thead = el('thead');
+  const headRow = el('tr');
+  for (const column of spec.columns) {
+    const th = el('th', null, column);
+    th.scope = 'col';
+    headRow.append(th);
+  }
+  thead.append(headRow);
+
+  const tbody = el('tbody');
+  for (const row of spec.rows) {
+    const tr = el('tr');
+    row.forEach((cell, i) => {
+      const node = el(i === 0 ? 'th' : 'td', null, String(cell));
+      if (i === 0) node.scope = 'row';
+      tr.append(node);
+    });
+    tbody.append(tr);
+  }
+  table.append(thead, tbody);
+  host.append(table);
+}
+
+function drawStat(section, stat) {
+  const host = $(section, 'stat');
+  host.querySelector('.stat-value').textContent = stat.value;
+  host.querySelector('.stat-caption').textContent = stat.caption ?? '';
+}
+
+/**
+ * The two-stage conversion funnel, drawn as proportional bars rather than a
+ * chart: two bars and a percentage is the whole story, and a two-slice pie or a
+ * two-bar chart would be more chrome than data.
+ */
+function drawFunnel(section, funnel) {
+  const host = $(section, 'funnel');
+  host.replaceChildren();
+
+  for (const stage of funnel.stages) {
+    const row = el('div', 'funnel-stage');
+    const head = el('div', 'funnel-label');
+    head.append(el('span', null, stage.label), el('span', 'funnel-value', stage.value.toLocaleString('en-US')));
+    const track = el('div', 'funnel-track');
+    const fill = el('div', 'funnel-fill');
+    // Width is the datum, so it is set from JS rather than a style attribute —
+    // the CSP here allows no inline styles at all.
+    fill.style.width = `${Math.max(0, Math.min(1, stage.share)) * 100}%`;
+    track.append(fill);
+    row.append(head, track);
+    host.append(row);
+  }
+
+  const rate = funnel.rate === null ? '—' : `${(funnel.rate * 100).toFixed(1)}%`;
+  host.append(
+    el('p', 'funnel-summary', `${rate} converted · ${funnel.dropped.toLocaleString('en-US')} dropped off`),
+  );
+}
+
+function drawChart(section, panel, config) {
+  const canvas = $(section, 'canvas');
+  const existing = charts.get(panel.id);
+  if (existing) existing.destroy();
+  charts.set(panel.id, new Chart(canvas, config));
+}
+
+async function drawPanel(panel, request, token) {
+  const section = document.getElementById(`panel-${panel.id}`);
+  section.dataset.stale = 'true';
+
+  try {
+    const data = await request;
+    // A slower panel from a superseded render must never overwrite the current one.
+    if (token !== state.renderToken) return;
+
+    if (panel.empty?.(data)) {
+      setState(section, 'empty', 'Nothing in this range.');
+      drawTable(section, panel.table?.(data));
+      return;
+    }
+
+    if (panel.kind === 'stat') drawStat(section, panel.stat(data));
+    else if (panel.kind === 'funnel') drawFunnel(section, panel.funnel(data));
+    else drawChart(section, panel, panel.chart(data));
+
+    $(section, 'figure').textContent = panel.figure ? panel.figure(data) : '';
+    drawTable(section, panel.table?.(data));
+    setState(section, 'ready');
+  } catch (err) {
+    if (token !== state.renderToken) return;
+    // One panel's failure is one panel's problem: the message lands in the
+    // panel, the rest of the page keeps its data.
+    setState(section, 'error', `Could not load this panel — ${err.message ?? err}`);
+    const chart = charts.get(panel.id);
+    if (chart) {
+      chart.destroy();
+      charts.delete(panel.id);
+    }
+  } finally {
+    if (token === state.renderToken) section.dataset.stale = 'false';
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Rendering everything
+// ---------------------------------------------------------------------------
+
+async function refreshMeta() {
+  try {
+    const meta = await api('/api/meta');
+    if (meta.latest_day) state.anchor = meta.latest_day;
+    state.earliest = meta.earliest_day ?? null;
+    syncFilters();
+  } catch {
+    // A meta failure is not fatal: the picker falls back to today's date and
+    // every panel still answers. The banner is what says so.
+    document.getElementById('data-through').textContent = 'Could not read the data range.';
+  }
+}
+
+async function render() {
+  const token = ++state.renderToken;
+  const { from, to } = currentRange();
+  const query = `from=${from}&to=${to}`;
+
+  setRangeSummary(`${shortDay(from)} – ${shortDay(to)}, ${to.slice(0, 4)}`);
+  document.getElementById('data-through').textContent = `Data through ${shortDay(state.anchor)}`;
+
+  // Deduplicate identical URLs within THIS render only — the four stat tiles
+  // share one /api/summary. Discarded when the render ends, so refresh refetches.
+  const inFlight = new Map();
+  const request = (path) => {
+    if (!inFlight.has(path)) inFlight.set(path, api(path));
+    return inFlight.get(path);
+  };
+
+  await Promise.allSettled(PANELS.map((panel) => drawPanel(panel, request(panel.source(query)), token)));
+
+  if (token === state.renderToken) {
+    document.getElementById('refreshed-at').textContent =
+      `Last refreshed ${new Date().toLocaleTimeString('en-US')}`;
+    document.body.dataset.ready = 'true';
+  }
 }
 
-const chart = window.Chart;
-set('chart-status', chart ? `Chart.js ${chart.version}` : 'Not loaded', !chart);
+// ---------------------------------------------------------------------------
+// Start
+// ---------------------------------------------------------------------------
 
-try {
-  const health = await api('/api/health');
-  set('db-status', health.ok ? 'Connected' : 'Unavailable', !health.ok);
-  set('latest-event', health.database?.latest_event_day ?? 'No events yet');
-  set('latest-rollup', health.database?.latest_rollup_day ?? 'No rollups yet');
-} catch (err) {
-  set('db-status', String(err.message ?? err), true);
+if (!Chart) {
+  document.getElementById('data-through').textContent =
+    'The chart library did not load — run `npm run vendor` and reload.';
+} else {
+  applyChartDefaults(Chart);
+  buildFilters();
+  buildPanels();
+  syncFilters();
+  await refreshMeta();
+  await render();
 }

+ 28 - 18
telemetry-dashboard/public/index.html

@@ -17,24 +17,34 @@
       </form>
     </header>
 
-    <main>
-      <!-- CG-13 replaces this section with the Chart.js views. Until then it is a
-           live proof that the session gate, the D1 binding and the asset
-           pipeline all work end to end. -->
-      <section class="panel">
-        <h2>Connection</h2>
-        <dl class="facts">
-          <dt>Database</dt>
-          <dd id="db-status">Checking…</dd>
-          <dt>Latest event</dt>
-          <dd id="latest-event">—</dd>
-          <dt>Latest rollup</dt>
-          <dd id="latest-rollup">—</dd>
-          <dt>Chart library</dt>
-          <dd id="chart-status">Checking…</dd>
-        </dl>
-      </section>
-    </main>
+    <!-- One filter row for the whole page: every panel below is drawn against the
+         range chosen here, and no panel carries a time control of its own. The
+         panels themselves are built from the registry in public/panels.js. -->
+    <section class="filters" id="filters" aria-label="Time range">
+      <div class="filter-group" data-role="presets"></div>
+
+      <div class="filter-group custom-range">
+        <label for="custom-from">From</label>
+        <input type="date" id="custom-from" data-role="custom-from" />
+        <label for="custom-to">To</label>
+        <input type="date" id="custom-to" data-role="custom-to" />
+        <button type="button" class="secondary" data-role="custom-apply">Apply</button>
+      </div>
+
+      <div class="filter-group filter-end">
+        <button type="button" class="secondary" data-role="refresh">Refresh</button>
+      </div>
+
+      <p class="filter-status">
+        <span id="range-summary">Loading…</span>
+        <span class="dot" aria-hidden="true">·</span>
+        <span id="data-through"></span>
+        <span class="dot" aria-hidden="true">·</span>
+        <span id="refreshed-at"></span>
+      </p>
+    </section>
+
+    <main class="grid" id="grid"></main>
 
     <script src="/vendor/chart.umd.js"></script>
     <script type="module" src="/app.js"></script>

+ 534 - 0
telemetry-dashboard/public/panels.js

@@ -0,0 +1,534 @@
+/**
+ * The panel registry — what the dashboard shows, in the order it shows it.
+ *
+ * Every panel is data in, chart config out, with no DOM anywhere in this file:
+ * app.js owns the page, this owns the mapping from an API response to a chart.
+ * Keeping them apart is what lets scripts/render-check.mjs drive the real panel
+ * definitions in a real browser and compare what each one plotted against what
+ * the API returned.
+ *
+ * A panel is:
+ *   id       stable key, also the DOM id and the anchor in a bug report
+ *   title    sentence case, at a readable size — never a tracked-out caps label
+ *   note     the honest footnote: what the number actually counts
+ *   span     grid columns out of 12
+ *   source   (query) => API path; panels sharing a path share one fetch
+ *   kind     'stat' | 'funnel' | 'chart'
+ *   figure   optional headline shown under the title (pie totals)
+ *   empty    (data) => is there nothing to draw
+ *   table    (data) => the WCAG-clean twin every chart owes the reader
+ */
+
+import {
+  CATEGORICAL,
+  INDEX_HOVER,
+  NEUTRAL,
+  SURFACE,
+  categoryScale,
+  compact,
+  number,
+  paletteFor,
+  percent,
+  shortDay,
+  valueScale,
+} from './theme.js';
+
+// ---------------------------------------------------------------------------
+// Sources
+// ---------------------------------------------------------------------------
+
+const summary = (q) => `/api/summary?${q}`;
+const activation = (q) => `/api/activation?${q}`;
+const retention = (q) => `/api/retention?${q}`;
+const series = (metric) => (q) => `/api/timeseries?metric=${metric}&${q}`;
+const breakdown =
+  (dim, extra = '') =>
+  (q) =>
+    `/api/breakdown?dim=${dim}${extra}&${q}`;
+
+// ---------------------------------------------------------------------------
+// Chart builders
+// ---------------------------------------------------------------------------
+
+const allZero = (data) => data.datasets.every((ds) => ds.data.every((v) => !v));
+const noRows = (data) => data.labels.length === 0 || data.datasets[0].data.every((v) => !v);
+
+/** Alpha-suffixed hex for the ~10% area wash under a single-series line. */
+const wash = (hex) => `${hex}1a`;
+
+/**
+ * A line per series over days. One axis, always — two measures of different
+ * scale get two panels rather than a second y-axis, which would invent a
+ * correlation the data does not have.
+ */
+function lineChart(data, { unit = 'count' } = {}) {
+  const dense = data.labels.length > 21;
+  const isPercent = unit === 'percent';
+  // A wash under a single line reads well — but not across gaps, where the fill
+  // would colour in days the series has no value for. Days with no cohort at
+  // all are exactly that case, so a gapped series goes unfilled.
+  const gapped = data.datasets.some((ds) => ds.data.some((v) => v === null));
+  const single = data.datasets.length === 1 && !gapped;
+
+  return {
+    type: 'line',
+    data: {
+      labels: data.labels.map(shortDay),
+      datasets: data.datasets.map((ds, i) => {
+        const colour = CATEGORICAL[i] ?? NEUTRAL;
+        return {
+          label: ds.label,
+          data: ds.data,
+          borderColor: colour,
+          backgroundColor: single ? wash(colour) : colour,
+          fill: single,
+          // Dots on a 90-day line are noise; the index-mode tooltip is how you
+          // read a value, and the table view is how you read all of them.
+          pointRadius: dense ? 0 : 3,
+          pointHoverRadius: 5,
+          pointBackgroundColor: colour,
+          // 2px surface ring, so a marker stays legible where lines cross.
+          pointBorderColor: SURFACE,
+          pointBorderWidth: 2,
+          spanGaps: false,
+        };
+      }),
+    },
+    options: {
+      interaction: INDEX_HOVER,
+      plugins: {
+        // A single series needs no legend box — the panel title names it.
+        legend: { display: data.datasets.length > 1 },
+        tooltip: {
+          callbacks: {
+            label: (ctx) =>
+              `${ctx.dataset.label}: ${
+                ctx.parsed.y === null ? 'no data' : isPercent ? `${ctx.parsed.y}%` : number(ctx.parsed.y)
+              }`,
+          },
+        },
+      },
+      scales: {
+        x: categoryScale(),
+        y: valueScale(
+          isPercent
+            ? { max: 100, ticks: { color: undefined, padding: 8, callback: (v) => `${v}%` } }
+            : {},
+        ),
+      },
+    },
+  };
+}
+
+/**
+ * Bands stacked to the day's total, for an ordered split of one measure.
+ *
+ * Four separate lines is the wrong form here: same-hue ordinal steps crossing
+ * each other read as scribble, and the question ("how is run length shifting?")
+ * is part-to-whole, not four independent trends. Stacked, the band heights are
+ * the mix and the outline is the total. The 2px surface-coloured border is the
+ * gap between touching fills — white doing the separating, not a stroke.
+ */
+function stackedAreaChart(data) {
+  const colours = paletteFor(
+    data.datasets.map((ds) => ds.label),
+    'ordinal',
+  );
+  const config = lineChart(data);
+  config.data.datasets.forEach((ds, i) => {
+    ds.backgroundColor = colours[i];
+    ds.borderColor = SURFACE;
+    ds.borderWidth = 2;
+    ds.pointRadius = 0;
+    ds.pointHoverRadius = 4;
+    ds.pointBackgroundColor = colours[i];
+    ds.pointBorderColor = SURFACE;
+    ds.fill = true;
+  });
+  config.options.scales.y.stacked = true;
+  // The swatch has to be the band's colour; the line is surface-coloured here.
+  config.options.plugins.legend = {
+    display: true,
+    labels: { generateLabels: () => data.datasets.map((ds, i) => ({
+      text: ds.label,
+      fillStyle: colours[i],
+      strokeStyle: colours[i],
+      pointStyle: 'circle',
+      datasetIndex: i,
+    })) },
+  };
+  return config;
+}
+
+/**
+ * Horizontal bars. `scale: 'ordinal'` is for categories whose order is their
+ * meaning (run length, codebase size) and takes the one-hue ramp; nominal
+ * categories all take slot 1, because colouring them by value would spend the
+ * identity channel re-encoding what bar length already says.
+ */
+function barChart(data, { scale = 'nominal' } = {}) {
+  const colours =
+    scale === 'ordinal'
+      ? paletteFor(data.labels, 'ordinal')
+      : data.labels.map((label) => (label === 'Other' ? NEUTRAL : CATEGORICAL[0]));
+
+  return {
+    type: 'bar',
+    data: {
+      labels: data.labels,
+      datasets: [
+        {
+          label: data.datasets[0].label,
+          data: data.datasets[0].data,
+          backgroundColor: colours,
+          maxBarThickness: 24,
+          // Rounded at the data end, square at the baseline (Chart.js skips the
+          // 'start' edge by default, which is the baseline on a horizontal bar).
+          borderRadius: 4,
+        },
+      ],
+    },
+    options: {
+      indexAxis: 'y',
+      plugins: { legend: { display: false } },
+      scales: {
+        x: valueScale(),
+        y: categoryScale({ ticks: { color: undefined, padding: 6, autoSkip: false } }),
+      },
+    },
+  };
+}
+
+/** Part-to-whole at a glance. Capped at a handful of slices by the API's `limit`. */
+function pieChart(data, { scale = 'categorical' } = {}) {
+  const total = data.datasets[0].data.reduce((n, v) => n + v, 0);
+  return {
+    type: 'pie',
+    data: {
+      labels: data.labels,
+      datasets: [
+        {
+          label: data.datasets[0].label,
+          data: data.datasets[0].data,
+          backgroundColor: paletteFor(data.labels, scale === 'ordinal' ? 'ordinal' : 'categorical'),
+        },
+      ],
+    },
+    options: {
+      plugins: {
+        legend: { display: true },
+        tooltip: {
+          callbacks: {
+            label: (ctx) =>
+              `${ctx.label}: ${number(ctx.parsed)} (${total > 0 ? percent(ctx.parsed / total, 1) : '—'})`,
+          },
+        },
+      },
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Table twins
+// ---------------------------------------------------------------------------
+
+/** Days down the side, one column per series. */
+const seriesTable = (data) => ({
+  columns: ['Day', ...data.datasets.map((ds) => ds.label)],
+  rows: data.labels.map((day, i) => [
+    day,
+    ...data.datasets.map((ds) => (ds.data[i] === null ? '—' : number(ds.data[i]))),
+  ]),
+});
+
+/** Both numbers, always — the panel plots one of them, the table shows both. */
+const breakdownTable = (data) => ({
+  columns: [data.title, 'Events', 'Machine-days'],
+  rows: data.rows.map((r) => [r.value, number(r.count), number(r.machines)]),
+});
+
+// ---------------------------------------------------------------------------
+// The panels
+// ---------------------------------------------------------------------------
+
+export const PANELS = [
+  {
+    id: 'production-users',
+    title: 'Production users',
+    note: 'Distinct machines active in the range, excluding CI runners.',
+    span: 3,
+    kind: 'stat',
+    source: summary,
+    stat: (d) => ({ value: compact(d.production_users), caption: `${number(d.active_machines)} including CI` }),
+    table: (d) => ({
+      columns: ['Measure', 'Machines'],
+      rows: [
+        ['Production users', number(d.production_users)],
+        ['All active machines', number(d.active_machines)],
+        ['First seen in range', number(d.new_machines)],
+      ],
+    }),
+  },
+  {
+    id: 'installs',
+    title: 'Installs',
+    note: 'Install events, including upgrades and reinstalls.',
+    span: 3,
+    kind: 'stat',
+    source: summary,
+    stat: (d) => ({ value: compact(d.installs), caption: `${number(d.new_machines)} from machines never seen before` }),
+    table: (d) => ({
+      columns: ['Measure', 'Events'],
+      rows: [
+        ['Installs', number(d.installs)],
+        ['New machines', number(d.new_machines)],
+      ],
+    }),
+  },
+  {
+    id: 'uninstalls',
+    title: 'Uninstalls',
+    note: 'Uninstall events in the range.',
+    span: 3,
+    kind: 'stat',
+    source: summary,
+    stat: (d) => ({
+      value: compact(d.uninstalls),
+      caption: d.installs > 0 ? `${percent(d.uninstalls / d.installs)} of installs` : 'No installs in range',
+    }),
+    table: (d) => ({
+      columns: ['Measure', 'Events'],
+      rows: [
+        ['Uninstalls', number(d.uninstalls)],
+        ['Installs', number(d.installs)],
+      ],
+    }),
+  },
+  {
+    id: 'indexing-runs',
+    title: 'Indexing runs',
+    note: 'Index events in the range, across every machine.',
+    span: 3,
+    kind: 'stat',
+    source: summary,
+    stat: (d) => ({ value: compact(d.index_runs), caption: `${compact(d.tool_calls)} tool and command calls` }),
+    table: (d) => ({
+      columns: ['Measure', 'Events'],
+      rows: [
+        ['Indexing runs', number(d.index_runs)],
+        ['Tool and command calls', number(d.tool_calls)],
+      ],
+    }),
+  },
+
+  {
+    id: 'activation-funnel',
+    title: 'Install to first use',
+    note: 'Machines first seen in the range that ran an index within 7 days.',
+    span: 4,
+    kind: 'funnel',
+    source: activation,
+    empty: (d) => d.installs === 0,
+    funnel: (d) => ({
+      stages: [
+        { label: 'Installed', value: d.installs, share: 1 },
+        {
+          label: `Indexed within ${d.window_days} days`,
+          value: d.activated,
+          share: d.installs > 0 ? d.activated / d.installs : 0,
+        },
+      ],
+      rate: d.rate,
+      dropped: d.dropped,
+    }),
+    table: (d) => ({
+      columns: ['Stage', 'Machines', 'Share'],
+      rows: [
+        ['Installed', number(d.installs), '100%'],
+        [`Indexed within ${d.window_days} days`, number(d.activated), percent(d.rate)],
+        ['Dropped off', number(d.dropped), percent(d.installs > 0 ? d.dropped / d.installs : null)],
+      ],
+    }),
+  },
+  {
+    id: 'activation-rate',
+    title: 'Conversion rate over time',
+    note: 'By the day a machine was first seen. Recent days are still converting, so their rate only rises.',
+    span: 8,
+    kind: 'chart',
+    source: activation,
+    empty: (d) => d.installs === 0,
+    chart: (d) => lineChart(d, { unit: 'percent' }),
+    table: (d) => ({
+      columns: ['Day', 'Installs', 'Indexed', 'Rate', 'Window elapsed'],
+      rows: d.rows.map((r) => [
+        r.day,
+        number(r.installs),
+        number(r.activated),
+        percent(r.rate),
+        r.complete ? 'Yes' : 'Not yet',
+      ]),
+    }),
+  },
+
+  {
+    id: 'os',
+    title: 'Users by operating system',
+    note: 'Share of machine-days: a machine active on several days counts once per day.',
+    span: 4,
+    kind: 'chart',
+    // Three hues plus a neutral "Other" — the point past which categorical
+    // colours stop being reliably distinguishable under colour-vision deficiency.
+    source: breakdown('os', '&limit=3'),
+    empty: noRows,
+    figure: (d) => `${compact(d.total)} machine-days`,
+    chart: (d) => pieChart(d),
+    table: breakdownTable,
+  },
+  {
+    id: 'run-length',
+    title: 'Session run length',
+    note: 'Indexing runs by how long they took.',
+    span: 4,
+    kind: 'chart',
+    source: breakdown('duration_bucket'),
+    empty: noRows,
+    figure: (d) => `${compact(d.total)} runs`,
+    chart: (d) => pieChart(d, { scale: 'ordinal' }),
+    table: breakdownTable,
+  },
+  {
+    id: 'codebase-size',
+    title: 'Codebase size',
+    note: 'Files per indexed project.',
+    span: 4,
+    kind: 'chart',
+    source: breakdown('file_count_bucket'),
+    empty: noRows,
+    chart: (d) => barChart(d, { scale: 'ordinal' }),
+    table: breakdownTable,
+  },
+
+  {
+    id: 'installs-uninstalls',
+    title: 'Installs and uninstalls over time',
+    note: 'Install and uninstall events per day.',
+    span: 6,
+    kind: 'chart',
+    source: series('installs_uninstalls'),
+    empty: allZero,
+    chart: (d) => lineChart(d),
+    table: seriesTable,
+  },
+  {
+    id: 'new-installs',
+    title: 'New installs over time',
+    note: 'Machines seen for the first time, by day.',
+    span: 6,
+    kind: 'chart',
+    source: series('new_installs'),
+    empty: allZero,
+    chart: (d) => lineChart(d),
+    table: seriesTable,
+  },
+  {
+    id: 'indexing-activity',
+    title: 'Daily indexing activity',
+    note: 'Indexing runs and the machines that ran them.',
+    span: 6,
+    kind: 'chart',
+    source: series('indexing_activity'),
+    empty: allZero,
+    chart: (d) => lineChart(d),
+    table: seriesTable,
+  },
+  {
+    id: 'daily-production-users',
+    title: 'Daily production users',
+    note: 'Distinct machines active each day, excluding CI runners.',
+    span: 6,
+    kind: 'chart',
+    source: series('production_users'),
+    empty: allZero,
+    chart: (d) => lineChart(d),
+    table: seriesTable,
+  },
+  {
+    id: 'run-length-over-time',
+    title: 'Run length over time',
+    note: 'Indexing runs per day, split by how long they took.',
+    span: 6,
+    kind: 'chart',
+    source: series('duration_buckets'),
+    empty: allZero,
+    // Ordered buckets, so the bands take the one-hue ramp rather than four
+    // unrelated hues: the reader sees "longer" in the colour.
+    chart: stackedAreaChart,
+    table: seriesTable,
+  },
+  {
+    id: 'retention',
+    title: 'Daily retention cohorts',
+    note: 'Machines first seen in the range, and the share still active k days later.',
+    span: 6,
+    kind: 'chart',
+    source: retention,
+    empty: (d) => d.cohort === 0,
+    figure: (d) => `${compact(d.cohort)} machines in cohort`,
+    chart: (d) => lineChart(d, { unit: 'percent' }),
+    table: (d) => ({
+      columns: ['Day', 'Machines old enough', 'Still active', 'Rate'],
+      rows: d.rows.map((r) => [
+        `Day ${r.day}`,
+        number(r.eligible),
+        number(r.retained),
+        percent(r.rate),
+      ]),
+    }),
+  },
+
+  {
+    id: 'languages',
+    title: 'Most-indexed programming languages',
+    note: 'One count per indexing run that found the language; a mixed repo counts under each.',
+    span: 6,
+    kind: 'chart',
+    source: breakdown('language'),
+    empty: noRows,
+    chart: (d) => barChart(d),
+    table: breakdownTable,
+  },
+  {
+    id: 'indexing-speed',
+    title: 'Indexing speed',
+    note: 'Indexing runs by duration bucket.',
+    span: 6,
+    kind: 'chart',
+    source: breakdown('duration_bucket'),
+    empty: noRows,
+    chart: (d) => barChart(d, { scale: 'ordinal' }),
+    table: breakdownTable,
+  },
+  {
+    id: 'versions',
+    title: 'Users by app version',
+    note: 'Machine-days per version, newest first.',
+    span: 6,
+    kind: 'chart',
+    source: breakdown('codegraph_version'),
+    empty: noRows,
+    chart: (d) => barChart(d),
+    table: breakdownTable,
+  },
+  {
+    id: 'targets',
+    title: 'AI agent targets',
+    note: 'Agents wired up at install time. One install can configure several.',
+    span: 6,
+    kind: 'chart',
+    source: breakdown('target'),
+    empty: noRows,
+    chart: (d) => barChart(d),
+    table: breakdownTable,
+  },
+];

+ 268 - 23
telemetry-dashboard/public/styles.css

@@ -1,12 +1,18 @@
 /* Flat and editorial: square corners, hairline rules, sentence-case headings,
-   one oxblood accent. Matches getcodegraph.com. */
+   one oxblood accent. Matches getcodegraph.com.
+
+   No tiny all-caps tracked-out labels anywhere — panel titles are real headings
+   at a readable size, and the fine print under them is sentence case. */
 
 :root {
   --paper: #f7f6f2;
+  --surface: #ffffff;
   --ink: #16150f;
-  --muted: #56534a;
+  --secondary: #56534a;
+  --muted: #807d74;
   --oxblood: #7a201a;
   --rule: #d8d5cb;
+  --hairline: #e7e5de;
 }
 
 * {
@@ -39,49 +45,74 @@ h1 {
 }
 
 h2 {
-  margin: 0 0 12px;
+  margin: 0;
   font-size: 17px;
   font-weight: 600;
 }
 
 .subtitle {
   margin: 0;
-  color: var(--muted);
+  color: var(--secondary);
 }
 
-main {
-  max-width: 1080px;
-  margin: 24px 0 0;
+/* --- filter row --------------------------------------------------------- */
+
+.filters {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px 20px;
+  padding: 16px 0;
+  border-bottom: 1px solid var(--rule);
 }
 
-.panel {
-  padding: 16px;
-  background: #fff;
-  border: 1px solid var(--rule);
+.filter-group {
+  display: flex;
+  align-items: center;
+  gap: 8px;
 }
 
-.facts {
-  display: grid;
-  grid-template-columns: max-content 1fr;
-  gap: 8px 24px;
-  margin: 0;
+.filter-end {
+  margin-left: auto;
 }
 
-.facts dt {
-  color: var(--muted);
+.custom-range label {
+  color: var(--secondary);
 }
 
-.facts dd {
+.custom-range input {
+  padding: 7px 10px;
+  font: inherit;
+  font-size: 15px;
+  color: var(--ink);
+  background: var(--surface);
+  border: 1px solid var(--rule);
+  border-radius: 0;
+}
+
+.custom-range input:focus-visible,
+button:focus-visible {
+  outline: 2px solid var(--oxblood);
+  outline-offset: 1px;
+}
+
+.filter-status {
+  flex-basis: 100%;
   margin: 0;
+  color: var(--muted);
+  font-size: 14px;
 }
 
-.facts dd.bad {
-  color: var(--oxblood);
+.filter-status .dot {
+  padding: 0 4px;
 }
 
+/* --- buttons ------------------------------------------------------------ */
+
 button {
   padding: 8px 14px;
   font: inherit;
+  font-size: 15px;
   color: var(--paper);
   background: var(--oxblood);
   border: 1px solid var(--oxblood);
@@ -89,12 +120,226 @@ button {
   cursor: pointer;
 }
 
-button.secondary {
+button.secondary,
+button.range {
   color: var(--ink);
   background: transparent;
   border-color: var(--rule);
 }
 
-button.secondary:hover {
+button.secondary:hover,
+button.range:hover {
   border-color: var(--ink);
 }
+
+button.range.is-selected {
+  color: var(--paper);
+  background: var(--oxblood);
+  border-color: var(--oxblood);
+}
+
+button.link {
+  align-self: flex-start;
+  margin-top: 12px;
+  padding: 0;
+  color: var(--oxblood);
+  background: none;
+  border: none;
+  font-size: 14px;
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+
+/* --- grid --------------------------------------------------------------- */
+
+.grid {
+  display: grid;
+  grid-template-columns: repeat(12, 1fr);
+  gap: 16px;
+  margin-top: 24px;
+}
+
+.span-3 { grid-column: span 3; }
+.span-4 { grid-column: span 4; }
+.span-6 { grid-column: span 6; }
+.span-8 { grid-column: span 8; }
+.span-12 { grid-column: span 12; }
+
+/* A laptop is the target; below that the columns just widen rather than
+   pretending to be a phone layout. */
+@media (max-width: 1180px) {
+  .span-3 { grid-column: span 6; }
+  .span-4,
+  .span-8 { grid-column: span 6; }
+}
+
+@media (max-width: 760px) {
+  .span-3,
+  .span-4,
+  .span-6,
+  .span-8 { grid-column: span 12; }
+}
+
+/* --- panels ------------------------------------------------------------- */
+
+.panel {
+  display: flex;
+  flex-direction: column;
+  padding: 16px;
+  background: var(--surface);
+  border: 1px solid var(--rule);
+}
+
+.panel-head {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.panel-figure {
+  margin: 0;
+  color: var(--secondary);
+  font-size: 14px;
+  white-space: nowrap;
+}
+
+.panel-note {
+  margin: 6px 0 0;
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.panel-body {
+  flex: 1;
+  margin-top: 12px;
+  /* Refetch dims the previous render instead of tearing it down — no skeleton
+     flash, no layout jump. */
+  transition: opacity 120ms ease-out;
+}
+
+.panel[data-stale='true'] .panel-body {
+  opacity: 0.55;
+}
+
+.panel-state {
+  margin: 0;
+  color: var(--muted);
+  font-size: 14px;
+}
+
+.panel[data-state='ready'] .panel-state {
+  display: none;
+}
+
+.panel[data-state='error'] .panel-state {
+  color: var(--oxblood);
+}
+
+/* Until a panel has data there is nothing to show but its state line. */
+.panel:not([data-state='ready']) .chart-wrap,
+.panel:not([data-state='ready']) .stat,
+.panel:not([data-state='ready']) .funnel {
+  display: none;
+}
+
+/* Height covers the plot AND the axis band, so a panel never grows its own
+   little scrollbar. */
+.chart-wrap {
+  position: relative;
+  height: 232px;
+}
+
+/* --- stat tiles --------------------------------------------------------- */
+
+.stat-value {
+  margin: 4px 0 0;
+  font-size: 40px;
+  font-weight: 600;
+  line-height: 1.1;
+  /* Proportional figures on purpose: tabular-nums makes a number like 121 look
+     loose at display sizes. Tabular is for the table below. */
+}
+
+.stat-caption {
+  margin: 6px 0 0;
+  color: var(--muted);
+  font-size: 14px;
+}
+
+/* --- funnel ------------------------------------------------------------- */
+
+.funnel-stage + .funnel-stage {
+  margin-top: 16px;
+}
+
+.funnel-label {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  color: var(--secondary);
+  font-size: 14px;
+}
+
+.funnel-value {
+  color: var(--ink);
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.funnel-track {
+  height: 10px;
+  margin-top: 6px;
+  background: var(--hairline);
+}
+
+.funnel-fill {
+  height: 100%;
+  background: var(--oxblood);
+}
+
+.funnel-summary {
+  margin: 16px 0 0;
+  color: var(--secondary);
+  font-size: 14px;
+}
+
+/* --- table twins -------------------------------------------------------- */
+
+.table-wrap {
+  margin-top: 12px;
+  max-height: 260px;
+  overflow-y: auto;
+}
+
+.table-wrap table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 14px;
+  /* Columns of numbers that align vertically — the one place tabular figures
+     are the right call. */
+  font-variant-numeric: tabular-nums;
+}
+
+.table-wrap th,
+.table-wrap td {
+  padding: 5px 8px 5px 0;
+  text-align: left;
+  border-bottom: 1px solid var(--hairline);
+}
+
+.table-wrap thead th {
+  position: sticky;
+  top: 0;
+  background: var(--surface);
+  color: var(--secondary);
+  font-weight: 600;
+}
+
+.table-wrap tbody th {
+  font-weight: 400;
+}
+
+.table-wrap td {
+  color: var(--secondary);
+}

+ 195 - 0
telemetry-dashboard/public/theme.js

@@ -0,0 +1,195 @@
+/**
+ * Chart theme — the colours and the Chart.js defaults every panel inherits.
+ *
+ * The palette is not eyeballed. Both scales below were run through the data-viz
+ * validator against this dashboard's actual chart surface (#ffffff, the panel
+ * fill — not the page's paper), and both clear every hard gate:
+ *
+ *   categorical  #a8342a,#2a6f9e,#17916a,#c98500   (light, surface #ffffff, --pairs all)
+ *     lightness band PASS · chroma floor PASS · CVD separation PASS (worst pair
+ *     ΔE 8.7 protan, all 6 pairs) · normal-vision floor PASS (worst 15.1) ·
+ *     contrast PASS (all ≥ 3:1, so no panel depends on the relief rule)
+ *
+ *   ordinal      #d99a90,#c26a5c,#a3423a,#7a201a   (light, surface #ffffff, --ordinal)
+ *     monotone lightness PASS · adjacent ΔL PASS · light-end contrast 2.34:1
+ *     PASS · single hue PASS (spread 3°)
+ *
+ * If you change a hex, re-run the validator rather than trusting your eye —
+ * the red/green pair that "looks fine" is the one that collapses under
+ * deuteranopia. Slot order is the CVD-safety mechanism: assign in sequence,
+ * never cycle, and fold a ninth series into "Other".
+ */
+
+/** Panel fill — the surface every contrast number above was measured against. */
+export const SURFACE = '#ffffff';
+export const INK = '#16150f';
+export const SECONDARY = '#56534a';
+export const MUTED = '#807d74';
+export const GRID = '#e7e5de';
+export const AXIS = '#c9c6bc';
+
+/**
+ * Categorical — identity. Slot 1 is the brand oxblood stepped up into the
+ * lightness band (#7a201a itself is too dark to sit in a categorical scale).
+ */
+export const CATEGORICAL = ['#a8342a', '#2a6f9e', '#17916a', '#c98500'];
+
+/**
+ * Neutral, deliberately outside the categorical scale: "Other" is a leftover,
+ * not a series, and should not read as one.
+ */
+export const NEUTRAL = '#8d8a80';
+
+/**
+ * Ordinal — order IS the meaning (run length, codebase size). One hue, light to
+ * dark, so the reader sees the ordering in the colour instead of decoding a legend.
+ */
+export const ORDINAL = ['#d99a90', '#c26a5c', '#a3423a', '#7a201a'];
+
+/** Identity by position, never by rank — a filter must not repaint the survivors. */
+export function categorical(index) {
+  return CATEGORICAL[index] ?? NEUTRAL;
+}
+
+/**
+ * Colours for an ordered set of n marks. Four buckets map onto the ramp exactly;
+ * a shorter set is spread across it so the light→dark reading survives. Anything
+ * past the ramp (an unexpected bucket from an old client) goes neutral rather
+ * than inventing a step that would misstate the order.
+ */
+export function ordinal(n) {
+  if (n <= 0) return [];
+  if (n === 1) return [ORDINAL[2]];
+  const out = [];
+  for (let i = 0; i < n; i++) {
+    out.push(i < ORDINAL.length ? ORDINAL[Math.round((i * (ORDINAL.length - 1)) / (n - 1))] : NEUTRAL);
+  }
+  return out;
+}
+
+/** "Other" keeps the neutral wherever the API folded a tail into it. */
+export function paletteFor(labels, scale) {
+  const hues = scale === 'ordinal' ? ordinal(labels.length) : labels.map((_, i) => categorical(i));
+  return labels.map((label, i) => (label === 'Other' ? NEUTRAL : hues[i]));
+}
+
+// ---------------------------------------------------------------------------
+// Formatting
+// ---------------------------------------------------------------------------
+
+const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
+const PLAIN = new Intl.NumberFormat('en-US');
+
+/** Stat-tile values: 1,284 stays exact; 12,900 becomes 12.9K. */
+export function compact(n) {
+  if (n === null || n === undefined || Number.isNaN(n)) return '—';
+  return Math.abs(n) >= 10_000 ? COMPACT.format(n) : PLAIN.format(n);
+}
+
+export function number(n) {
+  if (n === null || n === undefined || Number.isNaN(n)) return '—';
+  return PLAIN.format(n);
+}
+
+export function percent(fraction, digits = 1) {
+  if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return '—';
+  return `${(fraction * 100).toFixed(digits)}%`;
+}
+
+/** "2026-07-04" → "Jul 4". Axis ticks only; tables keep the full date. */
+export function shortDay(day) {
+  const parsed = Date.parse(`${day}T00:00:00Z`);
+  if (!Number.isFinite(parsed)) return day;
+  return new Date(parsed).toLocaleDateString('en-US', {
+    month: 'short',
+    day: 'numeric',
+    timeZone: 'UTC',
+  });
+}
+
+// ---------------------------------------------------------------------------
+// Chart.js defaults
+// ---------------------------------------------------------------------------
+
+/**
+ * Applied once, before any chart is built. Everything here is the recessive
+ * half of the design: hairline grid, muted axis text, no animation loud enough
+ * to notice. Text never wears a series colour — identity comes from the mark
+ * beside it, which is why the legend uses point-style swatches.
+ */
+export function applyChartDefaults(Chart) {
+  const { defaults } = Chart;
+  defaults.font.family =
+    "'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
+  defaults.font.size = 12;
+  defaults.color = MUTED;
+  defaults.borderColor = GRID;
+  defaults.maintainAspectRatio = false;
+  defaults.animation.duration = 180;
+
+  defaults.plugins.legend.position = 'bottom';
+  defaults.plugins.legend.align = 'start';
+  defaults.plugins.legend.labels.usePointStyle = true;
+  defaults.plugins.legend.labels.pointStyle = 'circle';
+  defaults.plugins.legend.labels.boxWidth = 8;
+  defaults.plugins.legend.labels.boxHeight = 8;
+  defaults.plugins.legend.labels.padding = 14;
+  defaults.plugins.legend.labels.color = SECONDARY;
+
+  defaults.plugins.tooltip.backgroundColor = INK;
+  defaults.plugins.tooltip.padding = 10;
+  defaults.plugins.tooltip.cornerRadius = 0;
+  defaults.plugins.tooltip.displayColors = true;
+  defaults.plugins.tooltip.usePointStyle = true;
+  defaults.plugins.tooltip.boxWidth = 8;
+  defaults.plugins.tooltip.boxHeight = 8;
+
+  defaults.elements.line.borderWidth = 2;
+  defaults.elements.line.borderJoinStyle = 'round';
+  defaults.elements.line.borderCapStyle = 'round';
+  defaults.elements.line.tension = 0;
+  defaults.elements.point.hoverBorderWidth = 2;
+  defaults.elements.bar.borderRadius = 4;
+  defaults.elements.arc.borderColor = SURFACE;
+  // The 2px surface gap between touching fills — white doing the separating,
+  // rather than a stroke drawn around each mark.
+  defaults.elements.arc.borderWidth = 2;
+}
+
+/**
+ * `ticks` is merged rather than replaced: spreading an override on top would
+ * silently drop the tick limit and hand back a y-axis labelled every 10%.
+ */
+const scale = (base, extra) => ({ ...base, ...extra, ticks: { ...base.ticks, ...extra.ticks } });
+
+/** A value axis: hairline grid, clean ticks, always anchored at zero. */
+export function valueScale(extra = {}) {
+  return scale(
+    {
+      beginAtZero: true,
+      border: { color: AXIS },
+      grid: { color: GRID, drawTicks: false },
+      ticks: { color: MUTED, padding: 8, maxTicksLimit: 6, precision: 0 },
+    },
+    extra,
+  );
+}
+
+/** A category or time axis: no grid at all, so the marks carry the chart. */
+export function categoryScale(extra = {}) {
+  return scale(
+    {
+      border: { color: AXIS },
+      grid: { display: false },
+      ticks: { color: MUTED, padding: 6, autoSkipPadding: 12, maxRotation: 0 },
+    },
+    extra,
+  );
+}
+
+/**
+ * Crosshair-style reading on anything plotted against days: hovering anywhere in
+ * a column reports every series at that day, so a 2px line never has to be hit
+ * dead-centre.
+ */
+export const INDEX_HOVER = { mode: 'index', intersect: false, axis: 'x' };

+ 208 - 0
telemetry-dashboard/scripts/fixture.sql

@@ -0,0 +1,208 @@
+-- Seed data for the dashboard's local checks: 12 machines over 10 days
+-- (2026-07-01 … 2026-07-10), small enough that every number on every panel can
+-- be worked out by hand from the events below and checked against the API.
+--
+--   npm run seed        (writes the LOCAL .wrangler D1 — never the remote one)
+--
+-- Only the raw `events` rows are hand-authored. `machine_days`,
+-- `machine_first_seen` and the three `daily_*` rollups are DERIVED from them at
+-- the bottom of this file by the same aggregations the writers use in
+-- telemetry-worker/ (the ingest path and the nightly cron respectively), so the
+-- fixture can never drift into a state production could not produce.
+--
+-- The machines, and what each one does:
+--
+--   id   first  os      arch   ver    ci  installs  indexes on          uninstalls
+--   m01  07-01  darwin  arm64  1.4.0  0   local     07-01, 07-02, 07-04
+--   m02  07-01  darwin  arm64  1.4.0  0   global    07-01
+--   m03  07-01  linux   x64    1.4.0  0   local     07-03
+--   m04  07-01  win32   x64    1.4.0  0   local     never               07-06
+--   m05  07-02  darwin  arm64  1.4.0  0   local     07-02
+--   m06  07-02  linux   x64    1.4.1  0   local     never               07-07
+--   m07  07-03  darwin  x64    1.4.1  0   local     07-03
+--   m08  07-05  linux   arm64  1.5.0  0   global    07-06
+--   m09  07-05  win32   x64    1.5.0  0   local     07-05, 07-07
+--   m10  07-08  darwin  arm64  1.5.0  0   local     07-08
+--   m11  07-09  linux   x64    1.5.0  0   local     07-10
+--   m12  07-09  linux   x64    1.5.0  1   global    07-09              (CI runner)
+--
+-- m04 and m06 never index: they are the two machines the activation funnel is
+-- supposed to lose (12 installs → 10 activated → 83.3%). m12 is the one CI
+-- machine, so "production users" is 11 where "active machines" is 12.
+
+DELETE FROM daily_dim_counts;
+DELETE FROM daily_event_counts;
+DELETE FROM daily_machines;
+DELETE FROM machine_days;
+DELETE FROM machine_first_seen;
+DELETE FROM events;
+
+-- ---------------------------------------------------------------------------
+-- install — 12, one per machine on its first day
+-- ---------------------------------------------------------------------------
+INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
+VALUES
+ ('2026-07-01T09:00:00Z','2026-07-01T09:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","cursor"]}'),
+ ('2026-07-01T09:05:00Z','2026-07-01T09:05:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude"]}'),
+ ('2026-07-01T10:00:00Z','2026-07-01T10:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["codex"]}'),
+ ('2026-07-01T11:00:00Z','2026-07-01T11:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","opencode"]}'),
+ ('2026-07-02T09:00:00Z','2026-07-02T09:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'),
+ ('2026-07-02T14:00:00Z','2026-07-02T14:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'),
+ ('2026-07-03T08:00:00Z','2026-07-03T08:00:00Z','2026-07-03','install','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"scope":"local","kind":"upgrade","targets":["claude"]}'),
+ ('2026-07-05T08:00:00Z','2026-07-05T08:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude","codex"]}'),
+ ('2026-07-05T09:00:00Z','2026-07-05T09:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'),
+ ('2026-07-08T08:00:00Z','2026-07-08T08:00:00Z','2026-07-08','install','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'),
+ ('2026-07-09T08:00:00Z','2026-07-09T08:00:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'),
+ ('2026-07-09T08:30:00Z','2026-07-09T08:30:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"scope":"global","kind":"fresh","targets":["claude"]}');
+
+-- ---------------------------------------------------------------------------
+-- index — 13 runs
+--   languages        typescript 7 · javascript 2 · python 2 · go 2 · rust 2 · csharp 2 · java 1  (18 rows)
+--   file_count_bucket  <100 2 · 100-1k 5 · 1k-10k 4 · 10k+ 2
+--   duration_bucket    <10s 5 · 10-60s 4 · 1-5m 2 · 5m+ 2
+-- ---------------------------------------------------------------------------
+INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
+VALUES
+ ('2026-07-01T09:10:00Z','2026-07-01T09:10:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'),
+ ('2026-07-01T09:20:00Z','2026-07-01T09:20:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'),
+ ('2026-07-02T10:00:00Z','2026-07-02T10:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}'),
+ ('2026-07-02T11:00:00Z','2026-07-02T11:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"languages":["python"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'),
+ ('2026-07-03T09:00:00Z','2026-07-03T09:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"languages":["go"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'),
+ ('2026-07-03T10:00:00Z','2026-07-03T10:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"languages":["typescript","rust"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'),
+ ('2026-07-04T10:00:00Z','2026-07-04T10:00:00Z','2026-07-04','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'),
+ ('2026-07-05T09:30:00Z','2026-07-05T09:30:00Z','2026-07-05','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'),
+ ('2026-07-06T09:00:00Z','2026-07-06T09:00:00Z','2026-07-06','index','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"languages":["rust","go"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'),
+ ('2026-07-07T09:00:00Z','2026-07-07T09:00:00Z','2026-07-07','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'),
+ ('2026-07-08T08:10:00Z','2026-07-08T08:10:00Z','2026-07-08','index','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'),
+ ('2026-07-09T09:00:00Z','2026-07-09T09:00:00Z','2026-07-09','index','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"languages":["java"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'),
+ ('2026-07-10T09:00:00Z','2026-07-10T09:00:00Z','2026-07-10','index','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"languages":["python","typescript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}');
+
+-- ---------------------------------------------------------------------------
+-- uninstall — 2
+-- ---------------------------------------------------------------------------
+INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
+VALUES
+ ('2026-07-06T12:00:00Z','2026-07-06T12:00:00Z','2026-07-06','uninstall','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"targets":["claude","opencode"]}'),
+ ('2026-07-07T12:00:00Z','2026-07-07T12:00:00Z','2026-07-07','uninstall','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"targets":["cursor"]}');
+
+-- ---------------------------------------------------------------------------
+-- usage_rollup — 5 rows, 85 calls (the `count` prop is summed, never the rows)
+--   codegraph_explore 82 · index 3   |   Claude Code 70 · Cursor 12
+-- ---------------------------------------------------------------------------
+INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
+VALUES
+ ('2026-07-03T02:00:00Z','2026-07-02T12:00:00Z','2026-07-02','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":40,"error_count":1,"client_name":"Claude Code"}'),
+ ('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":25,"client_name":"Claude Code"}'),
+ ('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"kind":"cli_command","name":"index","count":3}'),
+ ('2026-07-07T02:00:00Z','2026-07-06T12:00:00Z','2026-07-06','usage_rollup','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":12,"client_name":"Cursor"}'),
+ ('2026-07-11T02:00:00Z','2026-07-10T12:00:00Z','2026-07-10','usage_rollup','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":5,"client_name":"Claude Code"}');
+
+-- ---------------------------------------------------------------------------
+-- Derived: what the ingest worker writes on every batch
+-- ---------------------------------------------------------------------------
+-- prod is 0 only when EVERY event a machine sent that day carried ci = 1, which
+-- is what makes m12 the only non-production machine-day.
+INSERT INTO machine_days (machine_id, day, prod)
+SELECT machine_id, day, max(CASE WHEN ci = 1 THEN 0 ELSE 1 END) FROM events GROUP BY machine_id, day;
+
+INSERT INTO machine_first_seen (machine_id, first_day)
+SELECT machine_id, min(day) FROM events GROUP BY machine_id;
+
+-- ---------------------------------------------------------------------------
+-- Derived: what the nightly cron writes
+-- ---------------------------------------------------------------------------
+-- These mirror ROLLUP_STATEMENTS in telemetry-worker/src/rollup.ts, with the
+-- single-day filter dropped so one pass seeds the whole fixture range.
+
+INSERT INTO daily_machines (day, machines, prod_machines)
+SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days GROUP BY day;
+
+INSERT INTO daily_event_counts (day, event, count, machines)
+SELECT day, event,
+       CASE WHEN event = 'usage_rollup'
+            THEN sum(coalesce(json_extract(props, '$.count'), 0))
+            ELSE count(*) END,
+       count(DISTINCT machine_id)
+  FROM events GROUP BY day, event;
+
+-- Envelope dimensions — carried by every event.
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'os', CAST(os AS TEXT),
+       CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
+       count(DISTINCT machine_id)
+  FROM events WHERE os IS NOT NULL AND os <> '' GROUP BY day, event, os;
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'arch', CAST(arch AS TEXT),
+       CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
+       count(DISTINCT machine_id)
+  FROM events WHERE arch IS NOT NULL AND arch <> '' GROUP BY day, event, arch;
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'codegraph_version', CAST(codegraph_version AS TEXT),
+       CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
+       count(DISTINCT machine_id)
+  FROM events WHERE codegraph_version IS NOT NULL AND codegraph_version <> '' GROUP BY day, event, codegraph_version;
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'node_major', CAST(node_major AS TEXT),
+       CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
+       count(DISTINCT machine_id)
+  FROM events WHERE node_major IS NOT NULL GROUP BY day, event, node_major;
+
+-- Event-specific scalar props.
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'file_count_bucket', CAST(json_extract(props, '$.file_count_bucket') AS TEXT), count(*), count(DISTINCT machine_id)
+  FROM events WHERE event = 'index' AND json_extract(props, '$.file_count_bucket') IS NOT NULL
+ GROUP BY day, event, json_extract(props, '$.file_count_bucket');
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'duration_bucket', CAST(json_extract(props, '$.duration_bucket') AS TEXT), count(*), count(DISTINCT machine_id)
+  FROM events WHERE event = 'index' AND json_extract(props, '$.duration_bucket') IS NOT NULL
+ GROUP BY day, event, json_extract(props, '$.duration_bucket');
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'scope', CAST(json_extract(props, '$.scope') AS TEXT), count(*), count(DISTINCT machine_id)
+  FROM events WHERE event = 'install' AND json_extract(props, '$.scope') IS NOT NULL
+ GROUP BY day, event, json_extract(props, '$.scope');
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'kind', CAST(json_extract(props, '$.kind') AS TEXT),
+       CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
+       count(DISTINCT machine_id)
+  FROM events WHERE event IN ('install', 'usage_rollup') AND json_extract(props, '$.kind') IS NOT NULL
+ GROUP BY day, event, json_extract(props, '$.kind');
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'name', CAST(json_extract(props, '$.name') AS TEXT),
+       sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id)
+  FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL
+ GROUP BY day, event, json_extract(props, '$.name');
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'client_name', CAST(json_extract(props, '$.client_name') AS TEXT),
+       sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id)
+  FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.client_name') IS NOT NULL
+ GROUP BY day, event, json_extract(props, '$.client_name');
+
+-- Array props — one row per element, so a TypeScript+Go repo counts under both.
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT e.day, e.event, 'language', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id)
+  FROM events e, json_each(e.props, '$.languages') j
+ WHERE e.event = 'index' AND j.value <> ''
+ GROUP BY e.day, e.event, j.value;
+
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT e.day, e.event, 'target', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id)
+  FROM events e, json_each(e.props, '$.targets') j
+ WHERE e.event IN ('install', 'uninstall') AND j.value <> ''
+ GROUP BY e.day, e.event, j.value;
+
+-- Errors per tool: count is errors, machines is the machines that saw one.
+INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
+SELECT day, event, 'name_error', CAST(json_extract(props, '$.name') AS TEXT),
+       sum(json_extract(props, '$.error_count')), count(DISTINCT machine_id)
+  FROM events
+ WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL
+   AND coalesce(json_extract(props, '$.error_count'), 0) > 0
+ GROUP BY day, event, json_extract(props, '$.name');

+ 465 - 0
telemetry-dashboard/scripts/render-check.mjs

@@ -0,0 +1,465 @@
+#!/usr/bin/env node
+/**
+ * Renders the dashboard in a real browser against the fixture and checks that
+ * every panel drew, and drew the numbers the API returned.
+ *
+ * smoke-api.sh proves the SQL; this proves the other half — that each panel is
+ * wired to the right endpoint and plots it without mangling it. It reads the
+ * Chart.js instance off each canvas and compares its dataset arrays against the
+ * same endpoint fetched straight from Node, so a panel pointed at the wrong dim
+ * fails here even though both halves are individually fine.
+ *
+ *   node scripts/render-check.mjs        (or: npm run smoke:render)
+ *
+ * Zero new dependencies: it drives whatever Chromium is already on the machine
+ * over the DevTools protocol (Node 22 has WebSocket built in). With no browser
+ * installed it SKIPS rather than fails — the shell smoke suites stay the
+ * portable floor, and this is the deeper check where a browser exists.
+ */
+
+import { spawn } from 'node:child_process';
+import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const root = dirname(dirname(fileURLToPath(import.meta.url)));
+const PORT = Number(process.env.DASH_PORT ?? 8790);
+const BASE = `http://127.0.0.1:${PORT}`;
+
+/** The fixture's own window — see scripts/fixture.sql. */
+const FROM = '2026-07-01';
+const TO = '2026-07-10';
+
+let pass = 0;
+let fail = 0;
+
+const ok = (what) => {
+  console.log(`  ok    ${what}`);
+  pass++;
+};
+const bad = (what, detail) => {
+  console.log(`  FAIL  ${what}${detail ? ` (${detail})` : ''}`);
+  fail++;
+};
+const check = (what, condition, detail) => (condition ? ok(what) : bad(what, detail));
+const same = (what, expected, actual) =>
+  check(
+    what,
+    JSON.stringify(expected) === JSON.stringify(actual),
+    `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
+  );
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// ---------------------------------------------------------------------------
+// Finding a browser
+// ---------------------------------------------------------------------------
+
+/** Expands one `*` in a path segment, newest match first. */
+function glob(pattern) {
+  const [head, ...rest] = pattern.split('*');
+  const base = dirname(head);
+  const prefix = head.slice(base.length + 1);
+  if (!existsSync(base)) return [];
+  return readdirSync(base)
+    .filter((name) => name.startsWith(prefix))
+    .sort()
+    .reverse()
+    .map((name) => join(base, name) + rest.join('*'));
+}
+
+function findBrowser() {
+  const home = process.env.HOME ?? '';
+  const candidates = [
+    process.env.CHROME_BIN,
+    ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell`),
+    ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-x64/chrome-headless-shell`),
+    ...glob(`${home}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux/chrome-headless-shell`),
+    ...glob(`${home}/.cache/ms-playwright/chromium-*/chrome-linux/chrome`),
+    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+    '/Applications/Chromium.app/Contents/MacOS/Chromium',
+    '/usr/bin/chromium',
+    '/usr/bin/chromium-browser',
+    '/usr/bin/google-chrome',
+  ];
+  return candidates.find((path) => path && existsSync(path)) ?? null;
+}
+
+// ---------------------------------------------------------------------------
+// A minimal DevTools-protocol client
+// ---------------------------------------------------------------------------
+
+class CDP {
+  constructor(socket) {
+    this.socket = socket;
+    this.nextId = 1;
+    this.pending = new Map();
+    this.events = [];
+    socket.addEventListener('message', (event) => {
+      const message = JSON.parse(event.data);
+      if (message.id !== undefined) {
+        const waiter = this.pending.get(message.id);
+        if (!waiter) return;
+        this.pending.delete(message.id);
+        if (message.error) waiter.reject(new Error(message.error.message));
+        else waiter.resolve(message.result);
+      } else {
+        this.events.push(message);
+      }
+    });
+  }
+
+  static async connect(url) {
+    const socket = new WebSocket(url);
+    await new Promise((resolve, reject) => {
+      socket.addEventListener('open', resolve, { once: true });
+      socket.addEventListener('error', () => reject(new Error(`cannot reach ${url}`)), { once: true });
+    });
+    return new CDP(socket);
+  }
+
+  send(method, params = {}, sessionId) {
+    const id = this.nextId++;
+    return new Promise((resolve, reject) => {
+      this.pending.set(id, { resolve, reject });
+      this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
+    });
+  }
+
+  /** Runs an expression in the page and returns its value, awaiting promises. */
+  async evaluate(sessionId, expression) {
+    const result = await this.send(
+      'Runtime.evaluate',
+      { expression, returnByValue: true, awaitPromise: true },
+      sessionId,
+    );
+    if (result.exceptionDetails) {
+      throw new Error(result.exceptionDetails.exception?.description ?? 'page threw');
+    }
+    return result.result.value;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// The page probe
+// ---------------------------------------------------------------------------
+
+/**
+ * Runs inside the page. Reads what each panel actually rendered — including the
+ * live Chart.js instance behind each canvas — rather than trusting that a
+ * fetch resolved.
+ */
+const PROBE = `(() => {
+  const panels = [...document.querySelectorAll('[data-panel]')].map((section) => {
+    const canvas = section.querySelector('canvas');
+    const chart = canvas && window.Chart ? window.Chart.getChart(canvas) : null;
+    return {
+      id: section.dataset.panel,
+      state: section.dataset.state,
+      stale: section.dataset.stale,
+      title: section.querySelector('h2').textContent,
+      figure: section.querySelector('[data-role="figure"]').textContent,
+      note: section.querySelector('.panel-note')?.textContent ?? '',
+      message: section.querySelector('[data-role="state"]').textContent,
+      stat: section.querySelector('.stat-value')?.textContent ?? null,
+      funnelValues: [...section.querySelectorAll('.funnel-value')].map((n) => n.textContent),
+      funnelWidths: [...section.querySelectorAll('.funnel-fill')].map((n) => n.style.width),
+      chart: chart && {
+        type: chart.config.type,
+        labels: chart.data.labels,
+        datasets: chart.data.datasets.map((d) => ({ label: d.label, data: d.data })),
+        legend: chart.options.plugins?.legend?.display !== false,
+      },
+      tableRows: section.querySelectorAll('[data-role="table"] tbody tr').length,
+      tableCols: section.querySelectorAll('[data-role="table"] thead th').length,
+      tableHidden: section.querySelector('[data-role="table"]').hidden,
+    };
+  });
+  return {
+    ready: document.body.dataset.ready === 'true',
+    range: document.getElementById('range-summary').textContent,
+    dataThrough: document.getElementById('data-through').textContent,
+    refreshed: document.getElementById('refreshed-at').textContent,
+    selectedPreset: document.querySelector('button.range.is-selected')?.textContent ?? null,
+    panels,
+  };
+})()`;
+
+// ---------------------------------------------------------------------------
+// Run
+// ---------------------------------------------------------------------------
+
+const children = [];
+let profileDir = null;
+
+function cleanup() {
+  for (const child of children) {
+    try {
+      child.kill('SIGTERM');
+    } catch {
+      /* already gone */
+    }
+  }
+  if (profileDir) rmSync(profileDir, { recursive: true, force: true });
+}
+process.on('exit', cleanup);
+process.on('SIGINT', () => process.exit(130));
+
+function run(command, args, options = {}) {
+  const child = spawn(command, args, { cwd: root, stdio: 'ignore', ...options });
+  children.push(child);
+  return child;
+}
+
+async function waitFor(what, probe, attempts = 90) {
+  for (let i = 0; i < attempts; i++) {
+    try {
+      if (await probe()) return true;
+    } catch {
+      /* not up yet */
+    }
+    await sleep(1000);
+  }
+  throw new Error(`timed out waiting for ${what}`);
+}
+
+async function main() {
+  const browserPath = findBrowser();
+  if (!browserPath) {
+    console.log('render-check: no Chromium found — skipping.');
+    console.log('  Set CHROME_BIN, or install Chrome; the shell smoke suites cover the rest.');
+    return 0;
+  }
+  console.log(`Browser: ${browserPath}`);
+
+  console.log('Seeding the local D1 fixture…');
+  const seed = run('./scripts/seed-fixture.sh', [], { stdio: 'inherit' });
+  const seeded = await new Promise((resolve) => seed.on('exit', resolve));
+  if (seeded !== 0) throw new Error('seeding failed');
+
+  console.log(`Starting wrangler dev on :${PORT}…`);
+  run('npx', ['wrangler', 'dev', '--port', String(PORT), '--ip', '127.0.0.1']);
+  await waitFor('wrangler dev', async () => (await fetch(`${BASE}/robots.txt`)).ok);
+
+  const password = readFileSync(join(root, '.dev.vars'), 'utf8').match(/^ADMIN_PASSWORD="(.*)"$/m)?.[1];
+  if (!password) throw new Error('no ADMIN_PASSWORD in .dev.vars');
+  const login = await fetch(`${BASE}/login`, {
+    method: 'POST',
+    body: new URLSearchParams({ password }),
+    redirect: 'manual',
+  });
+  const cookie = login.headers.getSetCookie().find((c) => c.startsWith('cg_admin_session='));
+  if (!cookie) throw new Error('login did not set a session cookie');
+  const [name, value] = cookie.split(';')[0].split('=');
+
+  profileDir = mkdtempSync(join(tmpdir(), 'cg-dash-profile-'));
+  // chrome-headless-shell is headless by construction and rejects the flag;
+  // a full Chrome needs it.
+  const headlessFlag = browserPath.includes('headless') ? [] : ['--headless=new'];
+  run(browserPath, [
+    ...headlessFlag,
+    '--disable-gpu',
+    '--no-first-run',
+    '--no-default-browser-check',
+    '--remote-debugging-port=0',
+    `--user-data-dir=${profileDir}`,
+    'about:blank',
+  ]);
+
+  let devtoolsPort = null;
+  await waitFor('the browser', () => {
+    const portFile = join(profileDir, 'DevToolsActivePort');
+    if (!existsSync(portFile)) return false;
+    devtoolsPort = Number(readFileSync(portFile, 'utf8').split('\n')[0]);
+    return Number.isFinite(devtoolsPort) && devtoolsPort > 0;
+  }, 30);
+
+  const version = await (await fetch(`http://127.0.0.1:${devtoolsPort}/json/version`)).json();
+  const cdp = await CDP.connect(version.webSocketDebuggerUrl);
+  const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
+  const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
+
+  await cdp.send('Page.enable', {}, sessionId);
+  await cdp.send('Runtime.enable', {}, sessionId);
+  await cdp.send('Log.enable', {}, sessionId);
+  await cdp.send('Network.enable', {}, sessionId);
+  await cdp.send('Network.setCookie', { url: BASE, name, value, path: '/', httpOnly: true }, sessionId);
+
+  await cdp.send('Page.navigate', { url: `${BASE}/` }, sessionId);
+  await waitFor('the dashboard to finish rendering', async () => {
+    const view = await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"');
+    return view === true;
+  }, 60);
+
+  let view = await cdp.evaluate(sessionId, PROBE);
+
+  // -- what loaded ---------------------------------------------------------
+  console.log('\nThe page renders');
+  // The very same registry the page just rendered from, imported here so the
+  // expectations cannot drift from the panels under test.
+  const { PANELS } = await import(pathToFileURL(join(root, 'public', 'panels.js')).href);
+  check(`all ${PANELS.length} panels are on the page`, view.panels.length === PANELS.length, `got ${view.panels.length}`);
+  const broken = view.panels.filter((p) => p.state !== 'ready');
+  check(
+    'every panel reached its ready state',
+    broken.length === 0,
+    broken.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '),
+  );
+  check('the default range is the 30-day preset', view.selectedPreset === 'Last 30 days', view.selectedPreset);
+  check('the range is stated in the filter row', /Jun|Jul/.test(view.range), view.range);
+  check('the data horizon is stated', view.dataThrough.includes('Jul 10'), view.dataThrough);
+  check('the refresh time is stated', view.refreshed.startsWith('Last refreshed'), view.refreshed);
+
+  // A CSP violation surfaces here as a `security` log entry, which is the point
+  // of the check: the page must work under `script-src 'self'` with no inline
+  // styles at all. The favicon 404 is expected — there isn't one — and is the
+  // only network noise allowed through.
+  const errors = cdp.events.filter(
+    (e) =>
+      (e.method === 'Log.entryAdded' &&
+        e.params.entry.level === 'error' &&
+        !/favicon/.test(e.params.entry.url ?? '')) ||
+      e.method === 'Runtime.exceptionThrown',
+  );
+  check(
+    'no console errors — the strict CSP allows everything the page needs',
+    errors.length === 0,
+    errors.map((e) => e.params.entry?.text ?? e.params.exceptionDetails?.text).join(' | '),
+  );
+
+  // -- the range picker really re-queries ----------------------------------
+  console.log('\nChanging the range re-queries every panel');
+  await cdp.evaluate(
+    sessionId,
+    `document.body.dataset.ready = "";
+     [...document.querySelectorAll('button.range')].find((b) => b.textContent === 'Last 7 days').click();`,
+  );
+  await waitFor('the 7-day render', async () =>
+    (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
+  );
+  view = await cdp.evaluate(sessionId, PROBE);
+  const weekly = view.panels.find((p) => p.id === 'daily-production-users');
+  check('a daily line now holds 7 points', weekly.chart?.labels.length === 7, `${weekly.chart?.labels.length}`);
+  check('the 7-day preset is marked selected', view.selectedPreset === 'Last 7 days', view.selectedPreset);
+  check('every panel re-rendered cleanly', view.panels.every((p) => p.state === 'ready'));
+
+  console.log('\nA custom range works the same way');
+  await cdp.evaluate(
+    sessionId,
+    `document.body.dataset.ready = "";
+     document.querySelector('[data-role="custom-from"]').value = "${FROM}";
+     document.querySelector('[data-role="custom-to"]').value = "${TO}";
+     document.querySelector('[data-role="custom-apply"]').click();`,
+  );
+  await waitFor('the custom-range render', async () =>
+    (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
+  );
+  view = await cdp.evaluate(sessionId, PROBE);
+  check('the fixture window is 10 days', view.panels.find((p) => p.id === 'daily-production-users').chart?.labels.length === 10);
+  check('no preset stays highlighted', view.selectedPreset === null, view.selectedPreset);
+
+  // -- every panel plots what the API returned ------------------------------
+  console.log('\nEvery panel plots the API’s own numbers');
+  const query = `from=${FROM}&to=${TO}`;
+  const fetched = new Map();
+  const apiGet = async (path) => {
+    if (!fetched.has(path)) {
+      fetched.set(
+        path,
+        fetch(`${BASE}${path}`, { headers: { cookie: `${name}=${value}` } }).then((r) => r.json()),
+      );
+    }
+    return fetched.get(path);
+  };
+
+  for (const panel of PANELS) {
+    const rendered = view.panels.find((p) => p.id === panel.id);
+    const data = await apiGet(panel.source(query));
+
+    if (panel.kind === 'chart') {
+      const plotted = rendered.chart?.datasets.map((d) => d.data);
+      same(`${panel.id}: plots the endpoint's series`, data.datasets.map((d) => d.data), plotted);
+      // A legend is owed wherever colour carries identity: any multi-series
+      // chart, and every pie (whose slices are identities inside one dataset).
+      // A single line needs none — the panel title already names it.
+      const owed = rendered.chart.type === 'pie' || data.datasets.length > 1;
+      check(
+        `${panel.id}: a legend exactly where colour carries identity`,
+        rendered.chart.legend === owed,
+        `legend ${rendered.chart.legend}, expected ${owed}`,
+      );
+    } else if (panel.kind === 'stat') {
+      same(`${panel.id}: shows the endpoint's number`, panel.stat(data).value, rendered.stat);
+    } else if (panel.kind === 'funnel') {
+      same(
+        `${panel.id}: shows both funnel stages`,
+        panel.funnel(data).stages.map((s) => s.value.toLocaleString('en-US')),
+        rendered.funnelValues,
+      );
+    }
+
+    const table = panel.table(data);
+    check(
+      `${panel.id}: the table twin carries every row`,
+      rendered.tableRows === table.rows.length && rendered.tableCols === table.columns.length,
+      `${rendered.tableRows}×${rendered.tableCols} vs ${table.rows.length}×${table.columns.length}`,
+    );
+  }
+
+  // -- a few numbers checked against the fixture by hand --------------------
+  console.log('\nSpot checks against the fixture, worked out by hand');
+  const byId = Object.fromEntries(view.panels.map((p) => [p.id, p]));
+  same('production users is 11 (m12 is the CI machine)', '11', byId['production-users'].stat);
+  same('installs is 12', '12', byId['installs'].stat);
+  same('uninstalls is 2', '2', byId['uninstalls'].stat);
+  same('indexing runs is 13', '13', byId['indexing-runs'].stat);
+  same('the funnel loses m04 and m06', ['12', '10'], byId['activation-funnel'].funnelValues);
+  const widths = byId['activation-funnel'].funnelWidths;
+  check(
+    '…and draws the drop as a shorter bar',
+    widths[0] === '100%' && widths[1].startsWith('83.3'),
+    widths.join(' / '),
+  );
+  same('the OS pie is machine-days', ['linux', 'darwin', 'win32'], byId.os.chart.labels);
+  same('…and its slices are 9 / 8 / 4', [[9, 8, 4]], byId.os.chart.datasets.map((d) => d.data));
+  check('…with the honest metric named under the title', byId.os.figure === '21 machine-days', byId.os.figure);
+  same('run length keeps its bucket order', ['<10s', '10-60s', '1-5m', '5m+'], byId['run-length'].chart.labels);
+  same('languages lead with typescript', 'typescript', byId.languages.chart.labels[0]);
+  check('retention starts at 100%', byId.retention.chart.datasets[0].data[0] === 100);
+
+  // Colour, spacing and label collisions are not things an assertion catches.
+  // RENDER_SHOT=/tmp/dash.png npm run smoke:render → look at it.
+  if (process.env.RENDER_SHOT) {
+    await cdp.send(
+      'Emulation.setDeviceMetricsOverride',
+      { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false },
+      sessionId,
+    );
+    await sleep(500);
+    const shot = await cdp.send(
+      'Page.captureScreenshot',
+      { format: 'png', captureBeyondViewport: true },
+      sessionId,
+    );
+    writeFileSync(process.env.RENDER_SHOT, Buffer.from(shot.data, 'base64'));
+    console.log(`\nScreenshot written to ${process.env.RENDER_SHOT}`);
+  }
+
+  console.log('\nPanel copy follows the house rules');
+  const capsy = view.panels.filter((p) => /^[A-Z0-9 ]{4,}$/.test(p.title));
+  check('no shouty panel titles', capsy.length === 0, capsy.map((p) => p.title).join(', '));
+  check('every panel says what it is counting', view.panels.every((p) => p.note.length > 20));
+  check('tables start closed', view.panels.every((p) => p.tableHidden));
+
+  return fail;
+}
+
+try {
+  const failures = await main();
+  console.log(`\n${pass} passed, ${fail} failed`);
+  process.exit(failures === 0 ? 0 : 1);
+} catch (err) {
+  console.error(`\nrender-check: ${err.message}`);
+  process.exit(1);
+}

+ 31 - 0
telemetry-dashboard/scripts/seed-fixture.sh

@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Loads scripts/fixture.sql into the LOCAL .wrangler D1 (never the remote one:
+# --local is on every command here, and nothing in this repo writes production).
+#
+# The schema comes from the writer, telemetry-worker/migrations/, because that
+# is where it belongs — D1 is read-only from this worker.
+#
+#   ./scripts/seed-fixture.sh      (or: npm run seed)
+set -uo pipefail
+
+cd "$(dirname "$0")/.."
+
+DB=codegraph-telemetry
+MIGRATION=../telemetry-worker/migrations/0001_init.sql
+
+if [[ ! -f "$MIGRATION" ]]; then
+  echo "seed: cannot find $MIGRATION — run this from a full checkout" >&2
+  exit 1
+fi
+
+# The migration is plain CREATE TABLE, so a second run fails on "table already
+# exists". That is the expected steady state here, hence the swallowed output —
+# the fixture load below is the step whose failure actually matters.
+npx wrangler d1 execute "$DB" --local --file="$MIGRATION" >/dev/null 2>&1
+
+if ! npx wrangler d1 execute "$DB" --local --file=scripts/fixture.sql >/dev/null; then
+  echo "seed: loading scripts/fixture.sql failed" >&2
+  exit 1
+fi
+
+echo "seed: fixture loaded into the local $DB (12 machines, 2026-07-01 … 2026-07-10)"

+ 264 - 0
telemetry-dashboard/scripts/smoke-api.sh

@@ -0,0 +1,264 @@
+#!/usr/bin/env bash
+# End-to-end check of the chart API against the committed fixture.
+#
+# Every expected number below is worked out by hand from scripts/fixture.sql —
+# the header comment there lists all twelve machines and what each one does — so
+# a failure here means the SQL changed its mind, not that a golden file drifted.
+#
+#   ./scripts/smoke-api.sh          (or: npm run smoke:api)
+set -uo pipefail
+
+cd "$(dirname "$0")/.."
+
+# Deliberately NOT $PORT — see smoke-auth.sh.
+DASH_PORT="${DASH_PORT:-8789}"
+BASE="http://127.0.0.1:${DASH_PORT}"
+PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)"
+JAR="$(mktemp -t cg-api-jar)"
+LOG="$(mktemp -t cg-api-log)"
+PASS=0
+FAIL=0
+
+# The fixture's own window. Every assertion is scoped to it, so a later fixture
+# row outside these days cannot silently change an expected number.
+FROM=2026-07-01
+TO=2026-07-10
+RANGE="from=$FROM&to=$TO"
+
+cleanup() {
+  [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null
+  rm -f "$JAR" "$LOG"
+}
+trap cleanup EXIT
+
+status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
+get() { curl -s -b "$JAR" "$BASE$1"; }
+
+# Resolves a dotted path through the JSON. Numeric segments index arrays, so
+# `datasets.0.data` works. Node rather than jq: this is a Node project, jq is not.
+jget() {
+  node -e '
+    let v = JSON.parse(process.argv[1]);
+    for (const key of process.argv[2].split(".")) v = v?.[key];
+    console.log(v === undefined ? "<missing>" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
+  ' "$1" "$2"
+}
+
+check() { # check <description> <expected> <actual>
+  if [[ "$2" == "$3" ]]; then
+    printf '  ok    %s\n' "$1"
+    PASS=$((PASS + 1))
+  else
+    printf '  FAIL  %s (expected %s, got %s)\n' "$1" "$2" "$3"
+    FAIL=$((FAIL + 1))
+  fi
+}
+
+field() { # field <description> <path> <expected> <json>
+  check "$1" "$3" "$(jget "$4" "$2")"
+}
+
+echo "Seeding the local D1 fixture…"
+./scripts/seed-fixture.sh || exit 1
+
+echo "Starting wrangler dev on :${DASH_PORT}…"
+npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
+DEV_PID=$!
+READY=""
+for _ in $(seq 1 90); do
+  if [[ "$(curl -s "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi
+  sleep 1
+done
+if [[ -z "$READY" ]]; then
+  echo "wrangler dev never came up on :${DASH_PORT} — log follows"
+  cat "$LOG"
+  exit 1
+fi
+
+echo
+echo "The gate still holds on every new endpoint"
+for path in summary meta timeseries breakdown activation retention; do
+  check "GET /api/$path without a cookie → 401" 401 "$(status "$BASE/api/$path")"
+done
+
+curl -s -o /dev/null -c "$JAR" -X POST -d "password=$PASSWORD" "$BASE/login"
+check "signed in" 200 "$(status -b "$JAR" "$BASE/api/session")"
+
+echo
+echo "Caching"
+check "chart data is privately cacheable" "private, max-age=300" \
+  "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/summary" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')"
+check "health stays uncached" "no-store" \
+  "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/health" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')"
+
+echo
+echo "/api/meta — what the range picker anchors on"
+META="$(get "/api/meta")"
+field "latest day"        latest_day       2026-07-10 "$META"
+field "earliest day"      earliest_day     2026-07-01 "$META"
+field "raw events start"  earliest_raw_day 2026-07-01 "$META"
+field "retention window"  retention_days   14         "$META"
+
+echo
+echo "/api/summary — the big numbers (12 machines, one of them CI)"
+SUMMARY="$(get "/api/summary?$RANGE")"
+field "production users (m12 is CI)" production_users 11 "$SUMMARY"
+field "active machines"              active_machines  12 "$SUMMARY"
+field "new machines"                 new_machines     12 "$SUMMARY"
+field "installs"                     installs         12 "$SUMMARY"
+field "uninstalls"                   uninstalls        2 "$SUMMARY"
+field "indexing runs"                index_runs       13 "$SUMMARY"
+field "tool calls (SUM of count)"    tool_calls       85 "$SUMMARY"
+field "range echoed back"            range.days       10 "$SUMMARY"
+
+echo
+echo "/api/timeseries — one dense point per day, zeros where nothing happened"
+TS="$(get "/api/timeseries?metric=installs_uninstalls&$RANGE")"
+field "10 labels"      labels.0        2026-07-01                 "$TS"
+field "installs"       datasets.0.data '[4,2,1,0,2,0,0,1,2,0]'    "$TS"
+field "uninstalls"     datasets.1.data '[0,0,0,0,0,1,1,0,0,0]'    "$TS"
+field "legend labels"  datasets.1.label Uninstalls                "$TS"
+
+TS="$(get "/api/timeseries?metric=new_installs&$RANGE")"
+field "new installs by first-seen day" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS"
+
+TS="$(get "/api/timeseries?metric=production_users&$RANGE")"
+field "daily production users" datasets.0.data '[4,3,4,1,2,3,2,1,1,1]' "$TS"
+
+TS="$(get "/api/timeseries?metric=indexing_activity&$RANGE")"
+field "indexing runs"     datasets.0.data '[2,2,2,1,1,1,1,1,1,1]' "$TS"
+field "machines indexing" datasets.1.data '[2,2,2,1,1,1,1,1,1,1]' "$TS"
+
+TS="$(get "/api/timeseries?metric=tool_calls&$RANGE")"
+field "calls per day"     datasets.0.data '[0,40,28,0,0,12,0,0,0,5]' "$TS"
+field "machines per day"  datasets.1.data '[0,1,2,0,0,1,0,0,0,1]'    "$TS"
+
+TS="$(get "/api/timeseries?metric=duration_buckets&$RANGE")"
+field "bucket order is the scale" datasets.0.label '<10s'                  "$TS"
+field "…and ends at the longest"  datasets.3.label '5m+'                   "$TS"
+field "<10s over time"            datasets.0.data  '[2,0,1,1,0,0,0,1,0,0]' "$TS"
+field "10-60s over time"          datasets.1.data  '[0,2,0,0,0,0,1,0,0,1]' "$TS"
+field "1-5m over time"            datasets.2.data  '[0,0,0,0,1,1,0,0,0,0]' "$TS"
+field "5m+ over time"             datasets.3.data  '[0,0,1,0,0,0,0,0,1,0]' "$TS"
+
+echo
+echo "/api/breakdown — bars and pies"
+# machine-days, taking the largest per-event count per day so one machine's
+# install + index + usage_rollup on one day is not counted three times.
+BD="$(get "/api/breakdown?dim=os&$RANGE")"
+field "os labels"        labels          '["linux","darwin","win32"]' "$BD"
+field "os machine-days"  datasets.0.data '[9,8,4]'                    "$BD"
+field "os metric named"  datasets.0.label 'Machine-days'              "$BD"
+field "os total"         total           21                           "$BD"
+
+BD="$(get "/api/breakdown?dim=os&metric=count&$RANGE")"
+field "os by events sums every event" total 112 "$BD"
+
+BD="$(get "/api/breakdown?dim=language&$RANGE")"
+field "languages, most-indexed first" labels '["typescript","csharp","go","javascript","python","rust","java"]' "$BD"
+field "language counts"               datasets.0.data '[7,2,2,2,2,2,1]' "$BD"
+field "language rows total"           total 18 "$BD"
+
+BD="$(get "/api/breakdown?dim=file_count_bucket&$RANGE")"
+field "codebase size keeps bucket order" labels '["<100","100-1k","1k-10k","10k+"]' "$BD"
+field "codebase size counts"             datasets.0.data '[2,5,4,2]' "$BD"
+
+BD="$(get "/api/breakdown?dim=duration_bucket&$RANGE")"
+field "run length keeps bucket order" labels '["<10s","10-60s","1-5m","5m+"]' "$BD"
+field "run length counts"             datasets.0.data '[5,4,2,2]' "$BD"
+field "run length total = index runs" total 13 "$BD"
+
+BD="$(get "/api/breakdown?dim=target&$RANGE")"
+field "agent targets are the installs" event install "$BD"
+field "agent target labels" labels '["claude","cursor","codex","opencode"]' "$BD"
+field "agent target counts" datasets.0.data '[9,3,2,1]' "$BD"
+
+BD="$(get "/api/breakdown?dim=codegraph_version&$RANGE")"
+field "versions sort newest first" labels '["1.5.0","1.4.1","1.4.0"]' "$BD"
+field "version machine-days"       datasets.0.data '[8,3,10]' "$BD"
+
+BD="$(get "/api/breakdown?dim=name&$RANGE")"
+field "tool names by call volume" labels '["codegraph_explore","index"]' "$BD"
+field "tool call counts"          datasets.0.data '[82,3]' "$BD"
+
+BD="$(get "/api/breakdown?dim=client_name&$RANGE")"
+field "agents by call volume" labels '["Claude Code","Cursor"]' "$BD"
+field "agent call counts"     datasets.0.data '[70,12]' "$BD"
+
+BD="$(get "/api/breakdown?dim=kind&$RANGE")"
+field "install kinds" labels '["fresh","upgrade"]' "$BD"
+field "install kind counts" datasets.0.data '[11,1]' "$BD"
+
+BD="$(get "/api/breakdown?dim=scope&$RANGE")"
+field "install scopes" datasets.0.data '[9,3]' "$BD"
+
+BD="$(get "/api/breakdown?dim=name_error&$RANGE")"
+field "errors by tool" datasets.0.data '[1]' "$BD"
+
+BD="$(get "/api/breakdown?dim=language&limit=2&$RANGE")"
+field "the tail folds into Other, never truncates" labels '["typescript","csharp","Other"]' "$BD"
+field "Other keeps the total honest" total 18 "$BD"
+field "truncation is declared"       truncated true "$BD"
+
+echo
+echo "/api/activation — install → first index within 7 days"
+ACT="$(get "/api/activation?$RANGE")"
+field "cohort is every machine first seen" installs  12 "$ACT"
+field "m04 and m06 never indexed"          activated 10 "$ACT"
+field "…so two dropped"                    dropped    2 "$ACT"
+field "window"                             window_days 7 "$ACT"
+field "daily rate, null where no cohort"   datasets.0.data '[75,50,100,null,100,null,null,100,100,null]' "$ACT"
+field "recent cohorts flagged incomplete"  incomplete_from 2026-07-04 "$ACT"
+field "…and the completed ones are not"    rows.2.complete true "$ACT"
+field "…while the last week is"            rows.8.complete false "$ACT"
+
+# Narrowing the window drops m03 alone: it installed on 07-01 and did not index
+# until 07-03. Everyone else who ever indexed did it on day 0 or day 1.
+ACT="$(get "/api/activation?window=1&$RANGE")"
+field "a 1-day window converts fewer" activated 9 "$ACT"
+
+echo
+echo "/api/retention — day 0–14, denominator per day"
+RET="$(get "/api/retention?$RANGE")"
+field "cohort size"     cohort 12 "$RET"
+field "15 points"       labels.14 'Day 14' "$RET"
+# Day 2 divides by 10, not 12: m11/m12 arrived on 07-09 and cannot have a day-2
+# data point yet. Day 10+ is null — nobody in the cohort is old enough at all.
+field "retention curve" datasets.0.data \
+  '[100,41.7,30,11.1,0,22.2,0,0,0,0,null,null,null,null,null]' "$RET"
+field "day 2 eligible excludes the newest cohorts" rows.2.eligible 10 "$RET"
+field "day 10 has nobody old enough"               rows.10.eligible 0 "$RET"
+
+echo
+echo "Bad input is rejected, never guessed at"
+check "unknown dim → 400"        400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=machine_id")"
+check "missing dim → 400"        400 "$(status -b "$JAR" "$BASE/api/breakdown")"
+check "unknown metric → 400"     400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&metric=secrets")"
+check "unknown series → 400"     400 "$(status -b "$JAR" "$BASE/api/timeseries?metric=everything")"
+check "limit out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&limit=0")"
+check "impossible date → 400"    400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-02-31&to=2026-07-10")"
+check "malformed date → 400"     400 "$(status -b "$JAR" "$BASE/api/summary?from=yesterday")"
+check "backwards range → 400"    400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-07-10&to=2026-07-01")"
+check "window out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/activation?window=99")"
+check "unknown endpoint → 404"   404 "$(status -b "$JAR" "$BASE/api/everything")"
+check "event name is a closed shape → 400" 400 \
+  "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&event=install%27%20OR%201=1")"
+
+CLAMPED="$(get "/api/breakdown?dim=os&from=2019-01-01&to=$TO")"
+field "a decade-wide range clamps to a year" range.days 366  "$CLAMPED"
+field "…and says so"                         range.clamped true "$CLAMPED"
+field "…kept against the recent end"         range.from 2025-07-10 "$CLAMPED"
+
+echo
+echo "An empty range renders as empty, not as an error"
+EMPTY="$(get "/api/summary?from=2025-01-01&to=2025-01-07")"
+field "no machines"     production_users 0 "$EMPTY"
+field "no installs"     installs 0 "$EMPTY"
+EMPTY="$(get "/api/breakdown?dim=os&from=2025-01-01&to=2025-01-07")"
+field "no bars"         labels '[]' "$EMPTY"
+EMPTY="$(get "/api/timeseries?metric=production_users&from=2025-01-01&to=2025-01-03")"
+field "still a dense axis" datasets.0.data '[0,0,0]' "$EMPTY"
+
+echo
+printf '%d passed, %d failed\n' "$PASS" "$FAIL"
+[[ "$FAIL" -eq 0 ]]

+ 823 - 0
telemetry-dashboard/src/api.ts

@@ -0,0 +1,823 @@
+/**
+ * The dashboard's read API: one JSON endpoint per chart shape, all of them
+ * scoped by the same `?from=&to=` range the picker drives.
+ *
+ * Rules this file keeps:
+ * - **Rollups first.** Every panel is answered from `daily_*` / `machine_days`,
+ *   which are kept forever. Only the activation funnel touches raw `events`,
+ *   because "did this machine ever run an index" is not a daily aggregate — and
+ *   that is also the only endpoint with a horizon (the retention window).
+ * - **Parameterized, always.** No value from the query string is ever
+ *   concatenated into SQL. Dimensions and metrics are looked up in the tables
+ *   below and rejected with a 400 if they are not there, so even the column
+ *   *names* a caller can reach are a closed set.
+ * - **Chart-shaped.** Responses come back as `labels[] + datasets[]` so the
+ *   frontend does no arithmetic; every response also carries `rows` in its
+ *   natural shape, which is what the per-panel table view renders.
+ * - **No Response objects.** Handlers return plain data and let src/index.ts
+ *   apply the security headers, so there is exactly one place where headers on
+ *   an authenticated response are decided.
+ */
+
+const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
+const DAY_MS = 86_400_000;
+
+/** A year of daily points is already more than the charts can draw legibly. */
+const MAX_RANGE_DAYS = 366;
+const DEFAULT_RANGE_DAYS = 30;
+
+/** Retention curve length, matching the PostHog dashboard this replaces. */
+const RETENTION_DAYS = 14;
+/** Days a machine gets to run its first index before it counts as churned. */
+const DEFAULT_ACTIVATION_WINDOW = 7;
+const MAX_ACTIVATION_WINDOW = 30;
+
+/** Bars past this fold into "Other" — see the note on `Other` in breakdown(). */
+const DEFAULT_BREAKDOWN_LIMIT = 12;
+const MAX_BREAKDOWN_LIMIT = 50;
+
+/** Panels read at most every few minutes; the underlying data moves once a night. */
+const CACHE_CONTROL = 'private, max-age=300';
+
+export interface ApiResult {
+  body: unknown;
+  status?: number;
+  /** Omitted ⇒ src/index.ts keeps its `no-store` default. */
+  cacheControl?: string;
+}
+
+const fail = (error: string, status = 400): ApiResult => ({ body: { error }, status });
+
+/**
+ * `noUncheckedIndexedAccess` types every slot of a `batch()` result as possibly
+ * undefined. These two keep that out of the query code, which reads better for
+ * being about rows rather than about array bounds.
+ */
+const rowsOf = <T>(result: D1Result | undefined): T[] => (result?.results ?? []) as unknown as T[];
+const firstOf = <T>(result: D1Result | undefined): T | undefined => rowsOf<T>(result)[0];
+
+// ---------------------------------------------------------------------------
+// Days
+// ---------------------------------------------------------------------------
+
+const utcDay = (atMs: number): string => new Date(atMs).toISOString().slice(0, 10);
+
+/** Rejects the wrong shape and impossible dates alike (`2026-02-31` round-trips as March). */
+function isValidDay(day: string): boolean {
+  if (!DAY_RE.test(day)) return false;
+  const t = Date.parse(`${day}T00:00:00Z`);
+  return Number.isFinite(t) && utcDay(t) === day;
+}
+
+const dayMs = (day: string): number => Date.parse(`${day}T00:00:00Z`);
+const addDays = (day: string, delta: number): string => utcDay(dayMs(day) + delta * DAY_MS);
+const daysApart = (from: string, to: string): number => Math.round((dayMs(to) - dayMs(from)) / DAY_MS);
+
+export interface Range {
+  from: string;
+  to: string;
+  /** Inclusive length. */
+  days: number;
+  /** The request asked for more than MAX_RANGE_DAYS and `from` was moved up. */
+  clamped: boolean;
+}
+
+/**
+ * The range every endpoint shares. Absent params default to the last 30 days
+ * ending today so a bare `curl /api/summary` still answers something sensible;
+ * the dashboard itself always sends both, anchored on /api/meta's latest day so
+ * no chart ends on a day the nightly rollup has not written yet.
+ */
+function parseRange(url: URL): Range | ApiResult {
+  const rawTo = url.searchParams.get('to');
+  const rawFrom = url.searchParams.get('from');
+
+  if (rawTo !== null && !isValidDay(rawTo)) return fail('to must be YYYY-MM-DD');
+  if (rawFrom !== null && !isValidDay(rawFrom)) return fail('from must be YYYY-MM-DD');
+
+  const to = rawTo ?? utcDay(Date.now());
+  const from = rawFrom ?? addDays(to, -(DEFAULT_RANGE_DAYS - 1));
+  if (from > to) return fail('from must not be after to');
+
+  const requested = daysApart(from, to) + 1;
+  const clamped = requested > MAX_RANGE_DAYS;
+  return {
+    from: clamped ? addDays(to, -(MAX_RANGE_DAYS - 1)) : from,
+    to,
+    days: clamped ? MAX_RANGE_DAYS : requested,
+    clamped,
+  };
+}
+
+const isApiResult = (v: Range | ApiResult): v is ApiResult => 'body' in v;
+
+/** Every day in the range, so a chart's x-axis has no holes where nothing happened. */
+function dayList(range: Range): string[] {
+  const days: string[] = [];
+  for (let i = 0; i < range.days; i++) days.push(addDays(range.from, i));
+  return days;
+}
+
+/** Turns day-keyed rows into a dense series aligned to `labels`. */
+function densify(labels: string[], byDay: Map<string, number>): number[] {
+  return labels.map((day) => byDay.get(day) ?? 0);
+}
+
+// ---------------------------------------------------------------------------
+// Dimensions
+// ---------------------------------------------------------------------------
+
+type Order = 'value_desc' | 'bucket' | 'version_desc';
+
+interface DimSpec {
+  /** Axis/legend label for the values of this dimension. */
+  label: string;
+  /** Which number the chart plots when the caller does not say. */
+  metric: 'machines' | 'count';
+  /**
+   * Restrict to one event type. Set wherever the same dim is emitted by more
+   * than one event and the panel means a specific one — `target` rides both
+   * install and uninstall, and "AI agent targets" means the installs.
+   */
+  event?: string;
+  order: Order;
+  /** Fixed display order for bucket dims, whose meaning IS their order. */
+  buckets?: readonly string[];
+}
+
+const FILE_COUNT_BUCKETS = ['<100', '100-1k', '1k-10k', '10k+'] as const;
+const DURATION_BUCKETS = ['<10s', '10-60s', '1-5m', '5m+'] as const;
+
+/**
+ * The closed set of breakdowns. A dim not in here is a 400, which is what keeps
+ * `?dim=` from being a way to ask the database questions of the caller's own design.
+ * Values mirror the cron's dimension list (telemetry-worker/src/rollup.ts).
+ */
+const DIMS: Record<string, DimSpec> = {
+  os: { label: 'Operating system', metric: 'machines', order: 'value_desc' },
+  arch: { label: 'Architecture', metric: 'machines', order: 'value_desc' },
+  codegraph_version: { label: 'Version', metric: 'machines', order: 'version_desc' },
+  node_major: { label: 'Node major', metric: 'machines', order: 'version_desc' },
+  language: { label: 'Language', metric: 'count', order: 'value_desc' },
+  file_count_bucket: {
+    label: 'Files in project',
+    metric: 'count',
+    order: 'bucket',
+    buckets: FILE_COUNT_BUCKETS,
+  },
+  duration_bucket: {
+    label: 'Indexing run length',
+    metric: 'count',
+    order: 'bucket',
+    buckets: DURATION_BUCKETS,
+  },
+  target: { label: 'Agent target', metric: 'count', event: 'install', order: 'value_desc' },
+  scope: { label: 'Install scope', metric: 'count', event: 'install', order: 'value_desc' },
+  kind: { label: 'Install kind', metric: 'count', event: 'install', order: 'value_desc' },
+  name: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
+  client_name: { label: 'Agent', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
+  name_error: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
+};
+
+/** Newest first, numerically per segment, so 1.10.0 sorts above 1.9.0. */
+function compareVersionsDesc(a: string, b: string): number {
+  const pa = a.split(/[.-]/);
+  const pb = b.split(/[.-]/);
+  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
+    const na = Number(pa[i]);
+    const nb = Number(pb[i]);
+    if (Number.isFinite(na) && Number.isFinite(nb)) {
+      if (na !== nb) return nb - na;
+    } else {
+      const sa = pa[i] ?? '';
+      const sb = pb[i] ?? '';
+      if (sa !== sb) return sb.localeCompare(sa);
+    }
+  }
+  return 0;
+}
+
+// ---------------------------------------------------------------------------
+// /api/meta
+// ---------------------------------------------------------------------------
+
+interface MetaRow {
+  latest_rollup_day: string | null;
+  earliest_rollup_day: string | null;
+  latest_active_day: string | null;
+  earliest_active_day: string | null;
+  earliest_raw_day: string | null;
+  latest_raw_day: string | null;
+}
+
+/**
+ * What the picker anchors on. The dashboard asks for this first and ends every
+ * default range on `latest_day`, because the nightly cron has not rolled up
+ * today yet — anchoring on the wall clock would put a phantom zero on the right
+ * edge of every line chart.
+ */
+async function meta(env: Env): Promise<ApiResult> {
+  const row = await env.DB.prepare(
+    `SELECT (SELECT max(day) FROM daily_event_counts) AS latest_rollup_day,
+            (SELECT min(day) FROM daily_event_counts) AS earliest_rollup_day,
+            (SELECT max(day) FROM machine_days)       AS latest_active_day,
+            (SELECT min(day) FROM machine_days)       AS earliest_active_day,
+            (SELECT min(day) FROM events)             AS earliest_raw_day,
+            (SELECT max(day) FROM events)             AS latest_raw_day`,
+  ).first<MetaRow>();
+
+  const latest = row?.latest_rollup_day ?? row?.latest_active_day ?? null;
+  const earliest = row?.earliest_rollup_day ?? row?.earliest_active_day ?? null;
+  return {
+    body: {
+      latest_day: latest,
+      earliest_day: earliest,
+      latest_rollup_day: row?.latest_rollup_day ?? null,
+      latest_active_day: row?.latest_active_day ?? null,
+      /** Below this day the activation funnel is blind — raw events are purged. */
+      earliest_raw_day: row?.earliest_raw_day ?? null,
+      latest_raw_day: row?.latest_raw_day ?? null,
+      max_range_days: MAX_RANGE_DAYS,
+      retention_days: RETENTION_DAYS,
+      generated_at: new Date().toISOString(),
+    },
+    cacheControl: CACHE_CONTROL,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// /api/summary — the big numbers
+// ---------------------------------------------------------------------------
+
+/**
+ * One D1 batch, one round trip. Distinct-machine numbers come from
+ * `machine_days` rather than by summing `daily_machines`: a machine active on
+ * five days is one user, and summing the daily counts would call it five.
+ */
+async function summary(env: Env, range: Range): Promise<ApiResult> {
+  const { from, to } = range;
+  const batch = await env.DB.batch([
+    env.DB.prepare(
+      `SELECT count(DISTINCT machine_id) AS n FROM machine_days
+        WHERE day BETWEEN ? AND ? AND prod = 1`,
+    ).bind(from, to),
+    env.DB.prepare(
+      `SELECT count(DISTINCT machine_id) AS n FROM machine_days WHERE day BETWEEN ? AND ?`,
+    ).bind(from, to),
+    env.DB.prepare(
+      `SELECT count(*) AS n FROM machine_first_seen WHERE first_day BETWEEN ? AND ?`,
+    ).bind(from, to),
+    env.DB.prepare(
+      `SELECT event, sum(count) AS events, sum(machines) AS machines
+         FROM daily_event_counts WHERE day BETWEEN ? AND ? GROUP BY event`,
+    ).bind(from, to),
+  ]);
+
+  const byEvent = new Map<string, number>();
+  for (const row of rowsOf<{ event: string; events: number }>(batch[3])) {
+    byEvent.set(row.event, row.events ?? 0);
+  }
+  const eventCount = (name: string): number => byEvent.get(name) ?? 0;
+
+  return {
+    body: {
+      range,
+      production_users: firstOf<{ n: number }>(batch[0])?.n ?? 0,
+      active_machines: firstOf<{ n: number }>(batch[1])?.n ?? 0,
+      new_machines: firstOf<{ n: number }>(batch[2])?.n ?? 0,
+      installs: eventCount('install'),
+      uninstalls: eventCount('uninstall'),
+      index_runs: eventCount('index'),
+      tool_calls: eventCount('usage_rollup'),
+    },
+    cacheControl: CACHE_CONTROL,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// /api/timeseries — the line charts
+// ---------------------------------------------------------------------------
+
+interface DayValueRow {
+  day: string;
+  a: number | null;
+  b: number | null;
+}
+
+interface SeriesSpec {
+  title: string;
+  /** Series labels, in the order their data lands in `datasets`. */
+  labels: [string] | [string, string];
+  sql: string;
+  binds: (range: Range) => (string | number)[];
+}
+
+/**
+ * Every metric here reads a rollup table, so a line stays correct for days whose
+ * raw events are long gone. Each query returns (day, a[, b]) and is densified
+ * against the full day list, because a day with no rows means zero, not a gap.
+ */
+const SERIES: Record<string, SeriesSpec> = {
+  installs_uninstalls: {
+    title: 'Installs and uninstalls',
+    labels: ['Installs', 'Uninstalls'],
+    sql: `SELECT day,
+                 sum(CASE WHEN event = 'install'   THEN count ELSE 0 END) AS a,
+                 sum(CASE WHEN event = 'uninstall' THEN count ELSE 0 END) AS b
+            FROM daily_event_counts
+           WHERE day BETWEEN ? AND ? AND event IN ('install', 'uninstall')
+           GROUP BY day`,
+    binds: (r) => [r.from, r.to],
+  },
+  new_installs: {
+    title: 'New installs',
+    labels: ['New machines'],
+    sql: `SELECT first_day AS day, count(*) AS a, NULL AS b
+            FROM machine_first_seen
+           WHERE first_day BETWEEN ? AND ?
+           GROUP BY first_day`,
+    binds: (r) => [r.from, r.to],
+  },
+  production_users: {
+    title: 'Daily production users',
+    labels: ['Production users'],
+    sql: `SELECT day, prod_machines AS a, NULL AS b
+            FROM daily_machines WHERE day BETWEEN ? AND ?`,
+    binds: (r) => [r.from, r.to],
+  },
+  indexing_activity: {
+    title: 'Daily indexing activity',
+    labels: ['Indexing runs', 'Machines indexing'],
+    sql: `SELECT day, count AS a, machines AS b
+            FROM daily_event_counts
+           WHERE day BETWEEN ? AND ? AND event = 'index'`,
+    binds: (r) => [r.from, r.to],
+  },
+  tool_calls: {
+    title: 'Daily tool and command calls',
+    labels: ['Calls', 'Machines'],
+    sql: `SELECT day, count AS a, machines AS b
+            FROM daily_event_counts
+           WHERE day BETWEEN ? AND ? AND event = 'usage_rollup'`,
+    binds: (r) => [r.from, r.to],
+  },
+};
+
+async function timeseries(env: Env, url: URL, range: Range): Promise<ApiResult> {
+  const metric = url.searchParams.get('metric') ?? 'installs_uninstalls';
+
+  // The one metric whose series are data-driven rather than fixed: one line per
+  // duration bucket, in bucket order (an ordered scale, so the order is meaning).
+  if (metric === 'duration_buckets') return durationBucketSeries(env, range);
+
+  const spec = SERIES[metric];
+  if (!spec) {
+    return fail(`unknown metric — one of: ${[...Object.keys(SERIES), 'duration_buckets'].join(', ')}`);
+  }
+
+  const { results } = await env.DB.prepare(spec.sql)
+    .bind(...spec.binds(range))
+    .all<DayValueRow>();
+
+  const labels = dayList(range);
+  const a = new Map<string, number>();
+  const b = new Map<string, number>();
+  for (const row of results) {
+    a.set(row.day, row.a ?? 0);
+    b.set(row.day, row.b ?? 0);
+  }
+
+  const datasets = [{ label: spec.labels[0], data: densify(labels, a) }];
+  if (spec.labels.length === 2) datasets.push({ label: spec.labels[1], data: densify(labels, b) });
+
+  return {
+    body: {
+      range,
+      metric,
+      title: spec.title,
+      labels,
+      datasets,
+      rows: labels.map((day, i) => ({
+        day,
+        ...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
+      })),
+    },
+    cacheControl: CACHE_CONTROL,
+  };
+}
+
+/** "Session run length over time": one series per duration bucket, bucket-ordered. */
+async function durationBucketSeries(env: Env, range: Range): Promise<ApiResult> {
+  const { results } = await env.DB.prepare(
+    `SELECT day, value, sum(count) AS n
+       FROM daily_dim_counts
+      WHERE dim = 'duration_bucket' AND event = 'index' AND day BETWEEN ? AND ?
+      GROUP BY day, value`,
+  )
+    .bind(range.from, range.to)
+    .all<{ day: string; value: string; n: number }>();
+
+  const labels = dayList(range);
+  const perBucket = new Map<string, Map<string, number>>();
+  for (const row of results) {
+    let series = perBucket.get(row.value);
+    if (!series) perBucket.set(row.value, (series = new Map()));
+    series.set(row.day, row.n ?? 0);
+  }
+
+  // Fixed buckets first and always present (a bucket with no runs is a real zero,
+  // and dropping it would silently renumber the ordinal colour ramp); anything
+  // unexpected from an older client is appended rather than hidden.
+  const extra = [...perBucket.keys()].filter((v) => !DURATION_BUCKETS.includes(v as never)).sort();
+  const order = [...DURATION_BUCKETS, ...extra];
+
+  const datasets = order.map((bucket) => ({
+    label: bucket,
+    data: densify(labels, perBucket.get(bucket) ?? new Map()),
+  }));
+
+  return {
+    body: {
+      range,
+      metric: 'duration_buckets',
+      title: 'Indexing run length over time',
+      labels,
+      datasets,
+      rows: labels.map((day, i) => ({
+        day,
+        ...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
+      })),
+    },
+    cacheControl: CACHE_CONTROL,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// /api/breakdown — the bars and pies
+// ---------------------------------------------------------------------------
+
+interface BreakdownRow {
+  value: string;
+  count: number;
+  machines: number;
+}
+
+/**
+ * Sums one dimension over the range.
+ *
+ * On the `machines` metric: `daily_dim_counts.machines` is per day, so summing
+ * it over a range gives **machine-days**, not distinct machines — a machine
+ * seen on ten days counts ten times. A range-wide distinct count per dimension
+ * value is not recoverable from the rollups at all (it would need the raw
+ * events, which are purged), so rather than quietly presenting one as the
+ * other, the number is honestly named machine-days everywhere it appears, and
+ * the panels that use it are share-of-total panels where the distinction does
+ * not move the shape.
+ *
+ * The inner `max(machines)` is the other half of that honesty: the same machine
+ * emits install *and* index *and* usage_rollup on one day, each carrying `os`,
+ * so summing `machines` across event types would triple-count it. Taking the
+ * largest single-event count for the day is the closest lower bound the rollups
+ * can give. When a dim belongs to exactly one event (or `?event=` pins it) the
+ * `max` is over a single row and the question does not arise.
+ */
+async function breakdown(env: Env, url: URL, range: Range): Promise<ApiResult> {
+  const dim = url.searchParams.get('dim') ?? '';
+  const spec = DIMS[dim];
+  if (!spec) return fail(`unknown dim — one of: ${Object.keys(DIMS).join(', ')}`);
+
+  const requestedMetric = url.searchParams.get('metric');
+  if (requestedMetric !== null && requestedMetric !== 'count' && requestedMetric !== 'machines') {
+    return fail('metric must be count or machines');
+  }
+  const metric = requestedMetric ?? spec.metric;
+
+  const rawLimit = url.searchParams.get('limit');
+  const limit = rawLimit === null ? DEFAULT_BREAKDOWN_LIMIT : Number(rawLimit);
+  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_BREAKDOWN_LIMIT) {
+    return fail(`limit must be an integer between 1 and ${MAX_BREAKDOWN_LIMIT}`);
+  }
+
+  const event = url.searchParams.get('event') ?? spec.event ?? null;
+  if (event !== null && !/^[a-z_]{1,32}$/.test(event)) return fail('event must be a bare event name');
+
+  const binds: (string | number)[] = [dim, range.from, range.to];
+  if (event !== null) binds.push(event);
+
+  const { results } = await env.DB.prepare(
+    `SELECT value, sum(day_count) AS count, sum(day_machines) AS machines
+       FROM (SELECT day, value, sum(count) AS day_count, max(machines) AS day_machines
+               FROM daily_dim_counts
+              WHERE dim = ? AND day BETWEEN ? AND ?${event !== null ? ' AND event = ?' : ''}
+              GROUP BY day, value)
+      GROUP BY value`,
+  )
+    .bind(...binds)
+    .all<BreakdownRow>();
+
+  const rows = results.map((r) => ({
+    value: r.value,
+    count: r.count ?? 0,
+    machines: r.machines ?? 0,
+  }));
+
+  const pick = (r: BreakdownRow): number => (metric === 'machines' ? r.machines : r.count);
+
+  let ordered: BreakdownRow[];
+  let truncated = false;
+  if (spec.order === 'bucket' && spec.buckets) {
+    // An ordered scale: the buckets keep their own order and all of them show,
+    // including empty ones, so the ordinal colour ramp always means the same thing.
+    const found = new Map(rows.map((r) => [r.value, r]));
+    const extra = rows.filter((r) => !spec.buckets?.includes(r.value)).sort((x, y) => pick(y) - pick(x));
+    ordered = [
+      ...spec.buckets.map((b) => found.get(b) ?? { value: b, count: 0, machines: 0 }),
+      ...extra,
+    ];
+  } else {
+    const sorted = [...rows].sort(
+      spec.order === 'version_desc'
+        ? (x, y) => compareVersionsDesc(x.value, y.value)
+        : (x, y) => pick(y) - pick(x) || x.value.localeCompare(y.value),
+    );
+    if (sorted.length > limit) {
+      // Fold rather than truncate: a chopped bar chart quietly changes what the
+      // total means, and "Other" keeps the panel's total honest.
+      const head = sorted.slice(0, limit);
+      const tail = sorted.slice(limit);
+      ordered = [
+        ...head,
+        {
+          value: 'Other',
+          count: tail.reduce((n, r) => n + r.count, 0),
+          machines: tail.reduce((n, r) => n + r.machines, 0),
+        },
+      ];
+      truncated = true;
+    } else {
+      ordered = sorted;
+    }
+  }
+
+  const data = ordered.map(pick);
+  return {
+    body: {
+      range,
+      dim,
+      event,
+      metric,
+      title: spec.label,
+      labels: ordered.map((r) => r.value),
+      datasets: [{ label: metric === 'machines' ? 'Machine-days' : 'Events', data }],
+      rows: ordered,
+      total: data.reduce((n, v) => n + v, 0),
+      /** True when the tail was folded into an "Other" bar. */
+      truncated,
+    },
+    cacheControl: CACHE_CONTROL,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// /api/activation — install → first index
+// ---------------------------------------------------------------------------
+
+interface ActivationRow {
+  day: string;
+  installs: number;
+  activated: number;
+}
+
+/**
+ * Of the machines whose FIRST day falls in the range, how many ran an index
+ * within `window` days of it.
+ *
+ * The cohort key is `machine_first_seen`, not install events: a machine that
+ * reinstalls does not re-enter the funnel, which is what makes this a
+ * conversion rate rather than an install-event ratio.
+ *
+ * The LEFT JOIN rides events_machine_day (machine_id, day) and `count(DISTINCT)`
+ * absorbs the fan-out from a machine that indexed many times. This is the one
+ * endpoint that reads raw `events`, so it is bounded by the retention window —
+ * `raw_events_from` tells the caller where the data actually starts, and the UI
+ * says so rather than drawing a cliff and calling it a drop in conversion.
+ */
+async function activation(env: Env, url: URL, range: Range): Promise<ApiResult> {
+  const rawWindow = url.searchParams.get('window');
+  const window = rawWindow === null ? DEFAULT_ACTIVATION_WINDOW : Number(rawWindow);
+  if (!Number.isInteger(window) || window < 1 || window > MAX_ACTIVATION_WINDOW) {
+    return fail(`window must be an integer between 1 and ${MAX_ACTIVATION_WINDOW}`);
+  }
+
+  const batch = await env.DB.batch([
+    env.DB.prepare(
+      `SELECT f.first_day AS day,
+              count(DISTINCT f.machine_id) AS installs,
+              count(DISTINCT CASE WHEN e.machine_id IS NOT NULL THEN f.machine_id END) AS activated
+         FROM machine_first_seen f
+         LEFT JOIN events e
+                ON e.machine_id = f.machine_id
+               AND e.event = 'index'
+               AND e.day >= f.first_day
+               AND e.day <= date(f.first_day, ?)
+        WHERE f.first_day BETWEEN ? AND ?
+        GROUP BY f.first_day`,
+      // A bound modifier string, built from an integer this function validated —
+      // date() takes the modifier as data, so nothing is concatenated into SQL.
+    ).bind(`+${window} days`, range.from, range.to),
+    env.DB.prepare(`SELECT min(day) AS raw_from, max(day) AS raw_to FROM events`),
+  ]);
+
+  const rows = rowsOf<ActivationRow>(batch[0]);
+  const byDay = new Map(rows.map((r) => [r.day, r]));
+  const labels = dayList(range);
+
+  const installs = rows.reduce((n, r) => n + (r.installs ?? 0), 0);
+  const activated = rows.reduce((n, r) => n + (r.activated ?? 0), 0);
+
+  // Cohorts younger than the window have not finished converting yet, so their
+  // rate is a floor, not a result. Marked rather than dropped: hiding the last
+  // week of a conversion chart is its own kind of lie.
+  const boundsRow = firstOf<{ raw_from: string | null; raw_to: string | null }>(batch[1]);
+  const latestRaw = boundsRow?.raw_to ?? utcDay(Date.now());
+  const incompleteFrom = addDays(latestRaw, -(window - 1));
+
+  const detail = labels.map((day) => {
+    const row = byDay.get(day);
+    const dayInstalls = row?.installs ?? 0;
+    const dayActivated = row?.activated ?? 0;
+    return {
+      day,
+      installs: dayInstalls,
+      activated: dayActivated,
+      rate: dayInstalls > 0 ? dayActivated / dayInstalls : null,
+      complete: day < incompleteFrom,
+    };
+  });
+
+  return {
+    body: {
+      range,
+      window_days: window,
+      installs,
+      activated,
+      dropped: installs - activated,
+      rate: installs > 0 ? activated / installs : null,
+      /** Cohorts from this day on have not had the full window to convert. */
+      incomplete_from: incompleteFrom,
+      /** Raw events start here; a range reaching further back under-counts. */
+      raw_events_from: boundsRow?.raw_from ?? null,
+      labels,
+      datasets: [
+        {
+          label: 'Activation rate',
+          data: detail.map((d) => (d.rate === null ? null : Math.round(d.rate * 1000) / 10)),
+        },
+      ],
+      rows: detail,
+    },
+    cacheControl: CACHE_CONTROL,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// /api/retention — day 0–14 cohort curve
+// ---------------------------------------------------------------------------
+
+/**
+ * For machines first seen in the range, the share still active k days later.
+ *
+ * Read entirely off `machine_days` + `machine_first_seen`, neither of which the
+ * retention purge touches, so this answers for any range in history.
+ *
+ * The denominator is per-k, not the whole cohort: a machine first seen
+ * yesterday cannot have a day-7 data point, and dividing by it anyway would
+ * bend every recent cohort's curve toward zero. So day k is measured only over
+ * the machines that have actually had k days to come back — `eligible[k]`. The
+ * numerator needs no matching filter, since a machine with fewer than k days
+ * elapsed contributes zero to day k by construction.
+ */
+async function retention(env: Env, range: Range): Promise<ApiResult> {
+  const batch = await env.DB.batch([
+    env.DB.prepare(
+      `SELECT CAST(julianday(d.day) - julianday(f.first_day) AS INTEGER) AS k,
+              count(DISTINCT d.machine_id) AS machines
+         FROM machine_first_seen f
+         JOIN machine_days d ON d.machine_id = f.machine_id
+        WHERE f.first_day BETWEEN ? AND ?
+          AND d.day >= f.first_day
+          AND d.day <= date(f.first_day, ?)
+        GROUP BY k`,
+    ).bind(range.from, range.to, `+${RETENTION_DAYS} days`),
+    env.DB.prepare(
+      `SELECT first_day AS day, count(*) AS machines
+         FROM machine_first_seen WHERE first_day BETWEEN ? AND ? GROUP BY first_day`,
+    ).bind(range.from, range.to),
+    env.DB.prepare(`SELECT max(day) AS day FROM machine_days`),
+  ]);
+
+  const retained = new Map(
+    rowsOf<{ k: number; machines: number }>(batch[0]).map((r) => [r.k, r.machines ?? 0]),
+  );
+  const cohortDays = rowsOf<{ day: string; machines: number }>(batch[1]);
+  const cohortSize = cohortDays.reduce((n, r) => n + (r.machines ?? 0), 0);
+  const latestDay = firstOf<{ day: string | null }>(batch[2])?.day ?? utcDay(Date.now());
+
+  const rows = [];
+  for (let k = 0; k <= RETENTION_DAYS; k++) {
+    // Machines whose first day is early enough that day k has already happened.
+    const cutoff = addDays(latestDay, -k);
+    const eligible = cohortDays.reduce((n, r) => (r.day <= cutoff ? n + (r.machines ?? 0) : n), 0);
+    const back = retained.get(k) ?? 0;
+    rows.push({
+      day: k,
+      eligible,
+      retained: back,
+      rate: eligible > 0 ? back / eligible : null,
+    });
+  }
+
+  return {
+    body: {
+      range,
+      cohort: cohortSize,
+      window_days: RETENTION_DAYS,
+      labels: rows.map((r) => `Day ${r.day}`),
+      datasets: [
+        {
+          label: 'Retained',
+          data: rows.map((r) => (r.rate === null ? null : Math.round(r.rate * 1000) / 10)),
+        },
+      ],
+      rows,
+    },
+    cacheControl: CACHE_CONTROL,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// /api/health — liveness, and the only endpoint that is not range-scoped
+// ---------------------------------------------------------------------------
+
+async function health(env: Env): Promise<ApiResult> {
+  try {
+    const batch = await env.DB.batch<{ day: string | null }>([
+      env.DB.prepare('SELECT max(day) AS day FROM events'),
+      env.DB.prepare('SELECT max(day) AS day FROM daily_machines'),
+    ]);
+    return {
+      body: {
+        ok: true,
+        database: {
+          latest_event_day: batch[0]?.results[0]?.day ?? null,
+          latest_rollup_day: batch[1]?.results[0]?.day ?? null,
+        },
+      },
+    };
+  } catch (err) {
+    console.error(JSON.stringify({ msg: 'health query failed', err: String(err) }));
+    return { body: { ok: false, error: 'database unavailable' }, status: 503 };
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Router
+// ---------------------------------------------------------------------------
+
+/**
+ * Called only for an authenticated GET — src/index.ts owns the session gate and
+ * turns what comes back into a Response.
+ */
+export async function handleApi(env: Env, url: URL): Promise<ApiResult> {
+  if (url.pathname === '/api/session') return { body: { authenticated: true } };
+  if (url.pathname === '/api/health') return health(env);
+  if (url.pathname === '/api/meta') return meta(env);
+
+  const ranged = new Set(['/api/summary', '/api/timeseries', '/api/breakdown', '/api/activation', '/api/retention']);
+  if (!ranged.has(url.pathname)) return fail('not found', 404);
+
+  const range = parseRange(url);
+  if (isApiResult(range)) return range;
+
+  try {
+    switch (url.pathname) {
+      case '/api/summary':
+        return await summary(env, range);
+      case '/api/timeseries':
+        return await timeseries(env, url, range);
+      case '/api/breakdown':
+        return await breakdown(env, url, range);
+      case '/api/activation':
+        return await activation(env, url, range);
+      case '/api/retention':
+        return await retention(env, range);
+      default:
+        return fail('not found', 404);
+    }
+  } catch (err) {
+    // The query failed, not the caller. Log the cause, tell the page something
+    // it can put in the panel, and let the other panels carry on.
+    console.error(JSON.stringify({ msg: 'api query failed', path: url.pathname, err: String(err) }));
+    return { body: { error: 'query failed' }, status: 503 };
+  }
+}

+ 14 - 28
telemetry-dashboard/src/index.ts

@@ -12,6 +12,7 @@
  * D1 is read-only here. Writes belong to the ingest worker's cron.
  */
 
+import { handleApi } from './api';
 import {
   checkPassword,
   clearedSessionCookie,
@@ -200,34 +201,19 @@ async function handleLoginSubmit(env: Env, request: Request): Promise<Response>
 }
 
 /**
- * Scaffold API. CG-12 hangs the real chart endpoints off `/api/*`; everything
- * added there is gated by the same session check as this handler.
+ * The chart endpoints live in src/api.ts and return data, not responses, so this
+ * file stays the single place that decides headers on an authenticated reply.
+ * Everything under `/api/` is behind the same session check as the pages.
  */
-async function handleApi(env: Env, url: URL): Promise<Response> {
-  if (url.pathname === '/api/session') {
-    return json({ authenticated: true });
-  }
-
-  if (url.pathname === '/api/health') {
-    try {
-      const batch = await env.DB.batch<{ day: string | null }>([
-        env.DB.prepare('SELECT max(day) AS day FROM events'),
-        env.DB.prepare('SELECT max(day) AS day FROM daily_machines'),
-      ]);
-      return json({
-        ok: true,
-        database: {
-          latest_event_day: batch[0]?.results[0]?.day ?? null,
-          latest_rollup_day: batch[1]?.results[0]?.day ?? null,
-        },
-      });
-    } catch (err) {
-      console.error(JSON.stringify({ msg: 'health query failed', err: String(err) }));
-      return json({ ok: false, error: 'database unavailable' }, { status: 503 });
-    }
-  }
-
-  return json({ error: 'not found' }, { status: 404 });
+async function apiResponse(env: Env, url: URL): Promise<Response> {
+  const result = await handleApi(env, url);
+  return json(result.body, {
+    status: result.status,
+    // Chart data is daily-granular, so a few minutes in the browser's private
+    // cache saves D1 a round of identical queries on every panel re-render.
+    // Anything without an explicit lifetime keeps the no-store default.
+    headers: result.cacheControl ? { 'cache-control': result.cacheControl } : undefined,
+  });
 }
 
 /** Gated static assets: the dashboard shell, its JS, its CSS, the chart library. */
@@ -274,7 +260,7 @@ export default {
         if (!isRead) {
           return json({ error: 'method not allowed' }, { status: 405, headers: { allow: 'GET' } });
         }
-        return await handleApi(env, url);
+        return await apiResponse(env, url);
       }
 
       if (!isRead) {