diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf025bf..3ffde74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,8 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- The README screenshots are of the software as it now is. The single hero shot predated the Overview rework — it showed the deleted user-search cloud, Sessions at the top, KPI labels hardcoded to `(30d)` and no range switcher. It is replaced by the current Overview plus a Screens gallery of Sessions, Costs, Users and Tools, all taken from the demo seed, so no real user name or real spend is published. The README's logo `` pointed at `assets/logo.svg`, a path that does not exist in the repo and rendered as a broken image on GitHub; it now points at `docs/assets/logo.svg`. The current-release tag reads `:0.3`, not `:0.2`
- The Overview is one window instead of five. A single range switcher in the header — its own `cotel_overview_range` cookie, so it does not move the Users or Tools page — scopes every figure on the page. Previously the KPIs showed 30 days, the Sessions and Models blocks showed all time, and only the KPI labels said which, as a literal `(30d)` baked into the string; a reader comparing the Sessions KPI against the Models table below it was comparing 30 days against all time. Labels now take their suffix from the selected range, and `All` renders none
-- Overview section order is Users, History, Costs, Tools, Models, Sessions. A new Users block leads with the top 5 principals by spend in the selected range, and Sessions moves to the bottom as the one block that cannot honour a long range. The Costs block drops its inner by-model table — the Models block below it is the same data at full width
-- The Overview's History block stops borrowing the sessions list's `covered_since` and reads the coverage the history response reports for its own series. The note it printed on `Year` and `All` is gone on every range the union now covers, and remains only where the chart is genuinely short — the `Day` range, which charts hourly. `RANGE_DAYS` goes with it: the page no longer derives `from`/`to` from the range key at all, so its copy of the window lengths cannot drift from the server's
+- The Overview's History and Costs blocks are one block, `Activity & Cost`, charting spans and spend on one field: spans as a filled area against a left axis in `--color-chart-1`, cost as a line against a right axis in `--color-chart-4`, with the legend naming the axis each series reads against and the block header linking to both full pages. It is fed by one `/history` call rather than two requests — `buckets` already carried `cost_usd` beside `spans`, from the same row of the same `spans ∪ daily_usage` union, so the two series are bucketed, windowed and user-filtered identically by construction instead of by a client-side join that has to keep three things in sync. Both series now also start and stop together on every range, including `Day`, where cost is charted hourly for the first time — `/costs` is daily-only, so the old pair showed 24 hourly points above a single daily one. Two y-scales on one plot is a shape that can invent a correlation the data does not hold; the two series wear different marks and the legend states the sides in words to keep the plot readable, and [ADR-0015](docs/decisions/0015-overview-activity-and-cost-one-block.md) records what that mitigation does and does not buy. The colour pair was validated, not eyeballed: CVD separation ΔE 32.3 protan / 29.3 tritan in light, 29.9 / 24.6 in dark, against a target of ≥ 8
+- Overview section order is Users, Activity & Cost, Tools, Models, Sessions. A new Users block leads with the top 5 principals by spend in the selected range, and Sessions moves to the bottom as the one block that cannot honour a long range. The Overview charts cost without a by-model table beside it — the Models block below is the same data at full width
+- The Overview's activity chart stops borrowing the sessions list's `covered_since` and reads the coverage the history response reports for its own series. The note it printed on `Year` and `All` is gone on every range the union now covers, and remains only where the chart is genuinely short — the `Day` range, which charts hourly. `RANGE_DAYS` goes with it: the page no longer derives `from`/`to` from the range key at all, so its copy of the window lengths cannot drift from the server's
- The Overview's user-search typeahead is gone, and the `UserSearch` component with it. Scoping is reached from a user's page ("View activity"); `?user_id=` now shows a chip in the header naming the user and clearing the scope on click, instead of a page that was silently filtered with nothing on it to say so
- A deploy now fails when the container does not come up. The Deploy workflow ended at `docker compose up -d`, which returns once the container has *started*, not once it works — so the last thing it observed of a deploy was `Up Less than a second (health: starting)` and it went green on that, reporting a container whose `storage.Open` had died identically to one serving traffic. It now runs `scripts/wait-for-healthy.sh`, which blocks on the container's own `HEALTHCHECK` and fails the job on `unhealthy`, on an exit, on a crash loop (in under a second, rather than waiting out the timeout — only restarts seen *during* the wait indict a deploy, since `up -d` leaves an already-current container in place and one that crashed once and recovered carries a restart count for the rest of its life, including while it legitimately replays a WAL), on a service that defines no healthcheck at all, or on a 120 s timeout — dumping `docker compose ps`, the last health-probe output and the container logs so the reason is in the run log instead of on the runner. `workflow_dispatch` takes a `health_timeout` input for the one deploy that legitimately needs longer: a start following a hard kill replays the WAL. The CI smoke job runs the same script in place of its `curl`-until-ready loop, so a break in the gate surfaces on a PR rather than on a deploy. Measured against a 109 MB copy of production, healthy at 6 s from cold with a 3.9 MB WAL to replay (the open itself 2.8 s, including the v10 migration) and 6 s on a redeploy of the warm database — the 6 s is the probe cadence, not the database
- The image `HEALTHCHECK` gains `--start-interval=5s`. `--interval=30s` also governed the probes during `start-period`, so a container that was ready in two seconds still reported `starting` for thirty, and the deploy gate above would have waited out all of it
diff --git a/README.md b/README.md
index 7a6dff7..1d2c312 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on
## What you get
-- **Overview dashboard** — one range switcher in the header (All / Year / Month / Week / Day, default 30 days) that every figure on the page obeys: the KPI cards for sessions, users, total cost and token counts, and the Users / History / Costs / Tools / Models / Sessions blocks below them. The Users block ranks your top 5 principals by spend in the selected range. Arriving with `?user_id=` scopes the whole page to one user, with a chip in the header to clear it
+- **Overview dashboard** — one range switcher in the header (All / Year / Month / Week / Day, default 30 days) that every figure on the page obeys: the KPI cards for sessions, users, total cost and token counts, and the Users / Activity & Cost / Tools / Models / Sessions blocks below them. The Activity & Cost block charts spans and spend together on one field — spans in blue against the left axis, cost in amber against the right — so a spend spike lands under the activity that caused it. The Users block ranks your top 5 principals by spend in the selected range. Arriving with `?user_id=` scopes the whole page to one user, with a chip in the header to clear it
- **Sessions** — live table of every Claude Code session with user, model, duration, cost, and status (OK / ERROR); search by user and click any user to filter the table to their sessions
- **History** — time-series and daily-activity heatmaps for sessions and token spend over time. The day, week and month series keep charting after retention has rolled raw spans into daily totals; the hourly series and both heatmaps need a per-span timestamp, so they cover raw days only and say from when
- **Costs** — cumulative spend chart + breakdown table by model
diff --git a/docs/assets/dashboard-overview.png b/docs/assets/dashboard-overview.png
index 7db9585..1784dd6 100644
--- a/docs/assets/dashboard-overview.png
+++ b/docs/assets/dashboard-overview.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:52a0c1f40f661054a49cb31d6dec096f11ddf5d0c16b85351df71d053b5cea92
-size 234617
+oid sha256:b4ec510e3663289e64e72a709b18fb23978c0fb853f58b765bed9bfc4342d0a2
+size 275332
diff --git a/docs/decisions/0015-overview-activity-and-cost-one-block.md b/docs/decisions/0015-overview-activity-and-cost-one-block.md
new file mode 100644
index 0000000..8ae8277
--- /dev/null
+++ b/docs/decisions/0015-overview-activity-and-cost-one-block.md
@@ -0,0 +1,105 @@
+# ADR 0015 — Overview: spans and cost share one block, and one plot
+
+**Date:** 2026-08-21
+**Status:** Accepted
+**Deciders:** Daedalus (CTO)
+
+---
+
+## Context
+
+The Overview stacked two adjacent chart blocks, History and Costs, each holding a
+single-series chart over the same window:
+
+| Block | Endpoint | Series | Height |
+|---|---|---|---|
+| History | `GET /history` (`granularity=day`, or `hour` on the `day` range) | `spans` per bucket | 160 px |
+| Costs | `GET /costs` | `cost_usd` per day | 140 px |
+
+Two requests, two cards, two headers, two axes — for two series that answer the
+same question at a glance: *how much did we do, and what did it cost?* Reading
+the pair meant comparing the shape of one chart against the shape of another
+card below it, from memory, with no shared x-position to sight along.
+
+Worse, the two blocks did not always agree on their x-axis. `/history` buckets
+hourly on the `day` range, `/costs` is daily-only, so on `Day` the top chart
+showed 24 hourly points and the one below it showed a single daily point. And
+the two answered different windows until ADR-0014 gave `/history` a `range` key —
+before that, History stopped a month back while Costs beside it charted a year.
+
+The board asked for one block with both series on one field in different colours.
+
+## Options considered
+
+**1. Keep two blocks, tighten them.** Cheapest, but does not answer the request
+and leaves the `Day` mismatch in place.
+
+**2. Client-side join of `/history` and `/costs`.** Merge the two payloads on the
+date key in the page. Rejected: it re-derives an alignment the server already
+guarantees, and it can only be correct when both endpoints happen to have
+resolved the same window, the same user filter and the same bucket width. Three
+things to keep in sync at the call site is three things to get wrong. *(Lens:
+trust the boundary.)*
+
+**3. One series from `/history` alone.** `historyBucket` already carries
+`cost_usd` beside `spans` — the same row, from the same `spans ∪ daily_usage`
+union at the same raw-floor split (ADR-0014). Both series are bucketed, windowed
+and user-filtered identically **by construction**, and the Overview drops from
+two requests to one. Chosen.
+
+Given one dataset, the plot itself is the second decision: the two measures are
+counts and dollars, three orders of magnitude apart. Plotted on one scale the
+cost line is a flat trace on the axis floor.
+
+- **3a. Two y-axes on one plot.** What the board asked for, and the shape the
+ request only has as a single field.
+- **3b. Index both to a common base (=100 at t0).** One honest axis, but an
+ Overview panel exists to be read in absolute terms — "417 spans, $2.14" — and
+ "137 vs. base" is not that.
+- **3c. Two stacked plots sharing an x-axis inside one block.** Honest scales and
+ sightable alignment, but two plots again.
+
+## Decision
+
+The Overview carries **one block, `Activity & Cost`**, fed by a single
+`/history` call, plotting `spans` as a filled area against a left axis and
+`cost_usd` as a line against a right axis (option 3 + 3a). The block header links
+to both full pages, `History →` and `Costs →`, so neither is orphaned.
+
+Colours are `--color-chart-1` (blue) for spans and `--color-chart-4` (amber) for
+cost — the existing tokens, unchanged. The pair was validated rather than eyeballed:
+CVD separation ΔE 32.3 protan / 29.3 tritan in light, 29.9 / 24.6 in dark, against
+a target of ≥ 8.
+
+A two-scale plot is a known way to mislead: the alignment of the scales is
+arbitrary, so where the two lines cross means nothing, and a chart can invent a
+correlation the data does not hold. We take it deliberately, and pay for it:
+
+- The two series wear **different marks** — a filled area and a bare line — so
+ the eye does not read them as two comparable lines with a meaningful crossing.
+- The legend names the axis each series reads against, in words: `Spans (left
+ axis)`, `Cost (right axis, USD)`. Identity is not carried by colour alone.
+- The right axis is tick-formatted in dollars, the left in bare counts, so the
+ two scales are self-labelling.
+- Axis and legend text stay in ink tokens, not series colours.
+
+The mitigation is honest about what it is. It makes the plot *readable*; it does
+not make the scale alignment meaningful. If the board later wants the stronger
+form, 3c is a contained change behind the same block.
+
+## Consequences
+
+- The Overview issues one fewer request per range change. `useCosts` is no longer
+ called from the page; the Costs page is its only caller.
+- Both series now stop and start together on every range, including `Day`, where
+ cost is charted hourly for the first time.
+- The `covered_since` note already shown for spans now covers cost too — the same
+ buckets carry both, so there is one coverage statement instead of one stated
+ and one silent.
+- `StatSection` takes an optional `links` array beside `viewAllHref`; the single
+ link stays the default for every other block.
+- The README's Overview hero and its Overview bullet change shape; both are
+ re-taken and rewritten with this change, not after it.
+- The two-scale plot is a standing exception to the usual one-axis rule, recorded
+ here so the next person to open the file finds the reasoning rather than
+ re-deriving it — or "fixing" it into a flat line on one axis.
diff --git a/docs/decisions/index.md b/docs/decisions/index.md
index f215b33..e1f048d 100644
--- a/docs/decisions/index.md
+++ b/docs/decisions/index.md
@@ -22,3 +22,4 @@ New ADRs go in this directory as `NNNN-short-title.md`, numbered sequentially.
| [ADR-0012](./0012-tools-list-ranged-stats-and-server-side-sort) | Tools list — time-ranged stats, server-side sort and pagination | Accepted |
| [ADR-0013](./0013-spans-has-no-derived-columns) | `spans` carries no derived columns: drop `duration_ms` | Accepted |
| [ADR-0014](./0014-overview-single-range-selector) | Overview — one range selector every panel obeys | Accepted |
+| [ADR-0015](./0015-overview-activity-and-cost-one-block) | Overview — spans and cost share one block, and one plot | Accepted |
diff --git a/docs/operations/screenshots.md b/docs/operations/screenshots.md
index a52e0ab..1305f4b 100644
--- a/docs/operations/screenshots.md
+++ b/docs/operations/screenshots.md
@@ -47,10 +47,11 @@ BASE=http://localhost:18080 node scripts/shoot-screenshots.mjs
1440×1400 viewport at 2× DPR, dark scheme, each page cropped at the bottom edge of
a named element. The files land straight in `docs/assets/`.
-`playwright-core` has to be resolvable from the repo — `npx playwright-core@latest
---help` once is enough to populate the npx cache, then symlink or set `NODE_PATH` to
-it. Chromium comes from `CHROMIUM` (default `/usr/bin/chromium`); this box has no
-Playwright-managed browser.
+`playwright-core` has to be resolvable from the script — `npx playwright-core@latest
+--help` once is enough to populate the npx cache, then symlink it into a
+`node_modules/` beside the script. It has to be a symlink: the script is an ES
+module and ESM resolution ignores `NODE_PATH`. Chromium comes from `CHROMIUM`
+(default `/usr/bin/chromium`); this box has no Playwright-managed browser.
## 5. Tear down
diff --git a/frontend/src/components/StatSection.module.css b/frontend/src/components/StatSection.module.css
index 85b0ec6..bc90dc8 100644
--- a/frontend/src/components/StatSection.module.css
+++ b/frontend/src/components/StatSection.module.css
@@ -39,6 +39,12 @@
font-weight: 600;
}
+.links {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
.viewAll {
font-size: var(--text-sm);
color: var(--color-accent);
diff --git a/frontend/src/components/StatSection.tsx b/frontend/src/components/StatSection.tsx
index dfcc8d7..7fa6a68 100644
--- a/frontend/src/components/StatSection.tsx
+++ b/frontend/src/components/StatSection.tsx
@@ -3,15 +3,28 @@ import { Link } from 'react-router-dom'
import { ChevronDown, ChevronUp } from 'lucide-react'
import styles from './StatSection.module.css'
+interface SectionLink {
+ label: string
+ href: string
+}
+
interface StatSectionProps {
title: string
children: ReactNode
viewAllHref?: string
+ links?: SectionLink[]
defaultExpanded?: boolean
}
-export function StatSection({ title, children, viewAllHref, defaultExpanded = true }: StatSectionProps) {
+export function StatSection({
+ title,
+ children,
+ viewAllHref,
+ links,
+ defaultExpanded = true,
+}: StatSectionProps) {
const [expanded, setExpanded] = useState(defaultExpanded)
+ const headerLinks = links ?? (viewAllHref ? [{ label: 'View all', href: viewAllHref }] : [])
return (