Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- The Overview leads with a **Span activity** grid — a block of cells counting spans, GitHub-contribution-graph style, sitting directly under the KPI row. A line chart answers *how much and when*; it does not answer *what does a week here look like*, which is the question a telemetry front door gets asked most. The selected range picks both the grid and how much time one cell is: 53 × 7 day cells over a year (and over `All`), 31 × 6 four-hour cells over a month, 24 × 7 hourly cells over a week, 24 × 6 ten-minute cells over a day — each tiling its window exactly, and each about 115 px tall, so switching range does not move the page under the reader. Cells outside the queried window — the leading edge of the lattice, and the rest of today — are drawn as an outline with no fill: an empty cell means "we looked and there was nothing", an outline means "we did not look", and conflating the two is how a heatmap invents a quiet weekend. The grid is placed in UTC, which the footer and every tooltip say. Intensity is cut at the quartiles of the cells in view, not scaled against the busiest one: against a 722-span peak a 200-span day and a 700-span day are both "busy", so a max-relative ramp — linear or log — renders a working week as one flat block of full-intensity cells, which is the difference the grid exists to show. A step therefore means a rank, so the footer names the busiest cell in view and every tooltip gives the cell's own count. The scale lives in `frontend/src/lib/heat.ts` and is shared with the History page's calendar and hour-of-day heatmaps, which had a private copy of it and pick up the quartile cut with this change ([ADR-0016](docs/decisions/0016-overview-activity-grid.md))
- `GET /api/v1/history` accepts two more bucket widths, `granularity=10m` and `granularity=4h`, so the activity grid asks for exactly the width it draws — one bucket, one cell — instead of re-bucketing an hourly series in the page, which could not have produced a ten-minute cell at all. Both are additive and no existing caller changes; an unrecognised width still falls back to `day` rather than 400ing. Like `hour` they are answered from `spans` alone and report the shortfall in `covered_since`, because `daily_usage` buckets whole UTC days and cannot produce a sub-day bucket. `bucket` is now documented as a UTC wall-clock label floored to the width, on any host, so a client can reconstruct it for an instant without asking what the server thinks midnight is
- `scripts/seed-demo.py` fills a throwaway instance with a synthetic team — seven users, three models, 90 days of sessions and tool calls. It goes in over the OTLP endpoint rather than writing to DuckDB, so a seeded instance exercises the same ingest, cost-derivation and roll-up path a real one does, and it sends only attributes Claude Code actually sends: no `command` on `Bash` spans, so the Tools page shows the same "no command detail" state a real install sees. The RNG seed is fixed, so a re-run against a fresh volume reproduces the same numbers. `scripts/shoot-screenshots.mjs` turns that instance into the README images, each cropped at the bottom edge of a named element rather than at a pixel count ([docs/operations/screenshots.md](docs/operations/screenshots.md))
- `GET /api/v1/overview`, `/sessions`, `/costs` and `/models` accept `range`, the same five-key rolling window `/users` and `/tools` already took, and each echoes back the key it used. Defaults preserve today's behaviour instead of converging on one value: `/overview` and `/costs` default to `month` (the 30-day window they already applied), `/sessions` and `/models` to `all`, because they had no time filter and a `month` default would silently truncate every existing caller. Long ranges resolve against the `spans` ∪ `daily_usage` union at the raw-floor split, so `year` and `all` keep answering after retention has deleted the raw spans rather than repeating the `month` figure. On `/costs`, explicit `from`/`to` still beat the range key and the response then echoes `"range": null` ([ADR-0014](docs/decisions/0014-overview-single-range-selector.md))
- `GET /api/v1/sessions` returns `covered_since`. A session row needs a start time, model and status, none of which the roll-up keeps, so the list is raw-only; a range reaching past the raw floor is clamped and the field names the instant the list actually starts from (`null` when the range is fully covered). The Overview's Sessions block states that window in one line. The session *count* KPI is unaffected — `daily_usage` carries `session_id`, so counting distinct sessions across the union is exact
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 / 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
- **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 Span activity / Users / Activity & Cost / Tools / Models / Sessions blocks below them. The Span activity grid is a GitHub-style block of cells counting spans, and the range picks how much time a cell is — a day over a year, four hours over a month, an hour over a week, ten minutes over a day. 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
Expand Down
157 changes: 157 additions & 0 deletions docs/decisions/0016-overview-activity-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# ADR 0016 — Overview: an activity grid, one cell per bucket

**Date:** 2026-08-21
**Status:** Accepted
**Deciders:** Daedalus (CTO)

---

## Context

The Overview charts activity as a line over time. A line answers *how much, and
when* — it does not answer *what does a week here look like*. Rhythm is the
question a telemetry front door gets asked most: which hours are dead, whether
the weekend is quiet, whether the nightly bot really runs nightly. A line at 200
px tall over 30 buckets flattens all of it.

The board asked for a GitHub-contributions-style block of cells on the Overview,
counting spans, with a grid per range:

| Range | Grid asked for | Cell |
|---|---|---|
| Year | 53 × 7 | day |
| Month | 31 × 6 | 4 hours |
| Week | 27 × 7 | hour |
| Day | 24 × 6 | 10 minutes |

`All` keeps the year grid. Exact cell counts and the intensity ramp were left to
engineering ("количество и яркость сам подбери").

Three of those four are already coherent: **columns are the coarse unit, rows the
subdivision of it.** 53 weeks × 7 weekdays is GitHub's own layout; 31 days × 6
four-hour slots and 24 hours × 6 ten-minute slots are the same idea one and two
zoom levels in. Every one of them tiles its window exactly — 371 days, 31 days,
24 hours.

Two things had to be decided before any of it could be drawn: what the week grid
actually is, and where the data comes from.

## Options considered

### The week grid

27 columns of hour cells over 7 rows is 189 hours — 7.875 days. It tiles neither
a week nor a day, so no column is a fixed hour and no row is a fixed day; the
grid would drift by three hours per row and mean nothing at either axis.

**1. 7 × 24 — one column per day, 24 hour-rows.** The only layout that keeps
"columns coarse, rows fine" for the week too. It is also 24 rows tall: a 430 px
column of cells in a block whose other three ranges are ~115 px, so the page
jumps by a third of a screen when the range changes.

**2. 24 × 7 — one column per hour of the day, one row per day (chosen).** 168
cells, exactly the 7-day window. Same wide, short shape as the other three
grids. It is the transpose of option 1, so on this grid alone time runs *across*
a row rather than down a column — and it is the layout the History page's
hour-of-day heatmap already uses, so it is not a new idiom in the product, just
a second appearance of one. It is also what the board's `27 × 7` was reaching
for: seven rows, a day each.

### Where the cells come from

**A. Fold client-side from the hourly series.** `/history` already serves
`hour`. The month grid could fetch 744 hourly buckets and sum each four; the day
grid cannot be built at all, because nothing below an hour exists. Rejected on
the second count alone.

**B. A dedicated `GET /activity?range=…` returning the grid.** The server would
own the grid shape. Rejected: a new public contract, forever, for a bucket width
— and it would duplicate the window, user-filter and roll-up-union logic
`/history` already carries. *(Lens: schema and public interfaces are forever.)*

**C. Two more bucket widths on `/history` (chosen).** `10m` and `4h` join
`hour`, `day`, `week`, `month`. `/history` *is* this product's "spans over time,
bucketed" contract; ADR-0014 closed with "the next one is a parameter, not a
design", and this is that. The grid then asks for exactly the width it draws:
one bucket, one cell, no client-side re-bucketing to get wrong.

## Decision

**A `Span activity` block leads the Overview**, directly under the KPI row and
above the resource sections. It is the page's at-a-glance pulse; the sections
below it are the itemised answers.

**One cell is one `/history` bucket.** The range picks both the grid and the
granularity it fetches, and nothing else in the page knows the mapping:

| Range | Grid | Cell | `granularity` | Window tiled |
|---|---|---|---|---|
| `year`, `all` | 53 × 7, column-major | day | `day` | 371 days |
| `month` | 31 × 6, column-major | 4 h | `4h` | 31 days |
| `week` | 24 × 7, row-major | 1 h | `hour` | 7 days |
| `day` | 24 × 6, column-major | 10 min | `10m` | 24 hours |

On `year` and `all` that is the identical request the History block already
makes, so SWR serves both blocks from one fetch. The other three ranges cost one
extra `/history` call, over a window of at most 31 days of raw spans.

**Sub-day widths stay raw-only, like `hour`.** `daily_usage` buckets whole UTC
days, so `10m` and `4h` are answered from `spans` alone and report the shortfall
in `covered_since` — the rule ADR-0014 set, extended to two more widths rather
than re-argued. Only the day-celled year grid crosses the union, and it is the
one grid that needs to.

**The grid is placed in UTC.** `CAST(start_time AS TIMESTAMP)` renders the
stored `TIMESTAMPTZ` in UTC whatever the server's timezone is — the same
property the roll-up depends on for a day to be a day — so every cell start
falls on a bucket boundary by construction and the client never has to reconcile
two notions of midnight. The footer says `UTC` and every tooltip repeats it.

**A cell outside the queried window is drawn absent, not empty.** The lattice
is a fixed 53 × 7 and the window is a rolling 365 days, so the leading edge and
the rest of today are cells nothing was ever asked about. They are drawn as an
outline with no fill, distinct from the empty-but-covered colour, and carry no
tooltip. An empty cell means "we looked and there was nothing"; an outline means
"we did not look". Conflating the two is how a heatmap invents a quiet weekend.

**The ramp is cut at the quartiles of the cells in view, in five steps, shared
with the History page.** Scaling against the maximum is the obvious choice and
it was the first one built; it renders as one flat block of full-intensity
cells. Against a 722-span peak a 200-span day and a 700-span day are both
"busy" — on a linear ramp and on a log ramp alike, since the log only
compresses the top harder — and that difference is the entire point of the
grid. Quartiles put about a quarter of the busy cells in each step whatever the
shape of the distribution, which is what GitHub's graph does and why it reads.
The cost is that a step means a rank, not an amount: the footer therefore names
the busiest cell in view, and every tooltip gives the cell's own count. Degenerate
input is handled explicitly — a run with no spread has no quartiles to cut at,
so every busy cell takes the top step rather than all landing in the bottom one.

`frontend/src/lib/heat.ts` holds the scale that the History calendar and
hour-of-day heatmaps had a private copy of, so the three grids in the product
cannot drift apart. Four filled steps plus empty is about as many as the eye
separates at this cell size. Colours stay on `--color-chart-1` over
`--color-surface-2`: no new tokens, and the existing light/dark pair carries
over unchanged.

## Consequences

- `GET /history` takes `granularity=10m` and `granularity=4h`. Both are additive
and every existing caller is untouched; an unrecognised width still falls back
to `day` rather than 400ing.
- The Overview makes one extra request on the `month`, `week` and `day` ranges,
and none on `year` / `all`.
- The block holds one height (~115 px of cells) across all four ranges, so
switching range does not move the page under the reader.
- The week grid reads across, the other three read down. Both axes are labelled
on every grid, which is what actually resolves it for a reader; the
inconsistency is deliberate and is the price of keeping one block shape.
- The heat scale moves out of `History.tsx`. Any future cell grid gets it by
importing it, and a change to the ramp lands on every grid at once — including
the two History heatmaps, which pick up the quartile cut with this change.
- The grid shows a maximum of 371 days on `all`, however far back the data goes.
A fixed lattice cannot grow unbounded, and the History page is one click away
for the full series.
- Cells are `<div>`s in one CSS grid with explicit `gridColumn` / `gridRow`, at
most 371 of them. Placement is explicit rather than flow-ordered so the same
code renders both the column-major and the row-major grids.
1 change: 1 addition & 0 deletions docs/decisions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ New ADRs go in this directory as `NNNN-short-title.md`, numbered sequentially.
| [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 |
| [ADR-0016](./0016-overview-activity-grid) | Overview — an activity grid, one cell per bucket | Accepted |
24 changes: 16 additions & 8 deletions docs/design/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,24 @@ These are rendered by a shared `<PageHeader>` component that wraps every page's

### Shipped section order

The page stacks six `<StatSection>` blocks, each a summary of one resource with a
"View all" link to its full page, in this order:

1. **Users** — top 5 by spend in the range. Hidden while the page is scoped to a
The page stacks six `<StatSection>` blocks, each a summary of one resource with
a "View all" link to its full page, in this order:

1. **Span activity** — a grid of cells, one span-count bucket each, GitHub
contribution-graph style. First, because it is the page's pulse; everything
under it is the itemised answer. The range picks the grid and the
`granularity` it fetches: 53 × 7 days on `Year` and `All`, 31 × 6 four-hour
cells on `Month`, 24 × 7 hourly cells on `Week`, 24 × 6 ten-minute cells on
`Day`. Cells outside the queried window — the leading edge, and the rest of
today — are drawn as an outline, never as an empty cell
([ADR-0016](../decisions/0016-overview-activity-grid.md)).
2. **Users** — top 5 by spend in the range. Hidden while the page is scoped to a
single user via `?user_id=`, where a top-5-users table would be the one panel
on the page not answering for that user.
2. **History** — activity area chart. `hour` granularity on the `Day` range,
`day` otherwise.
3. **Costs** — daily spend line. No inner by-model table: the Models block below
is the same data at full width.
3. **Activity & Cost** — spans as a filled area against the left axis and cost as
a line against the right, from one `/history` call. `hour` granularity on the
`Day` range, `day` otherwise; links to both full pages
([ADR-0015](../decisions/0015-overview-activity-and-cost-one-block.md)).
4. **Tools** — top 5 by call count.
5. **Models** — all models by span count.
6. **Sessions** — 5 most recent. Last, because it is the only block that cannot
Expand Down
Loading