Dashboard spec
The JSON a Dashboard is drawn from and hands back on every edit — every field, what it accepts, how an invalid one is treated, how a host keys it by relation, and the one way to read it back.
A dashboard is data. Dashboard draws a DashboardSpec, its editor produces the next one, and the
host keeps whatever it was last handed. Nothing in it is a function, a component or a query, so it
survives JSON.stringify, a database column and a version-control diff.
It is shaped after Grafana's panel model and Metabase's dashboard JSON — tiles of a kind in an
ordered layout, each with a width, and a filter row scoping every tile — without their query layer.
Every field name in a spec is a column of the one relation the Dashboard is given; the spec
does not say which relation that is, and the host keeps that association
(below).
A whole spec
The one the Sightings showcase draws, abridged:
{
"filters": [{ "field": "region" }, { "field": "hall" }, { "field": "leagues" }],
"tiles": [
{ "id": "sightings", "kind": "stat", "title": "Sightings", "measure": { "op": "count" }, "trend": "hour" },
{ "id": "hour", "kind": "chart", "span": 3, "type": "line", "x": "hour", "y": { "op": "count" }, "title": "Sightings by hour" },
{
"id": "hoaxes", "kind": "stat", "title": "Hoaxes",
"measure": { "op": "share", "field": "verdict", "equals": "hoax" }, "trend": "hour", "goodWhenUp": false
},
{ "id": "bounty", "kind": "chart", "span": 3, "type": "area", "x": "hour", "y": { "op": "sum", "field": "bounty" }, "facet": "region" },
{ "id": "fit", "kind": "chart", "span": 2, "type": "regression", "x": "leagues", "y": { "op": "value", "field": "bounty" } },
{ "id": "hall", "kind": "chart", "span": 1, "type": "bar", "x": "hall", "y": { "op": "count" }, "color": "verdict" },
{ "id": "rows", "kind": "table", "span": 3, "columns": ["beast", "region", "hall", "hour", "leagues", "bounty", "verdict"] }
]
}DashboardSpec
| Field | Type | Meaning |
|---|---|---|
filters | DashboardFilterSpec[] | The filter bar, in order. |
tiles | Tile[] | Order is layout: the figures are drawn first, as one band sharing a row; the charts and tables flow left to right through a three-column grid beneath it, each as wide as its span. [] for none. |
DashboardFilterSpec
| Field | Type | Meaning |
|---|---|---|
field | string | The column to filter by. The control is chosen from the field's stats when the dashboard draws — a timeline, a facet filter, a search or a slider (the table) — and is never stored. |
A filter on a field that has no control, or that the relation no longer has, is skipped.
Tile
Tile is StatTile | ChartTile | TableTile, told apart by kind (TileKind). Every tile has:
| Field | Type | Meaning |
|---|---|---|
id | string | Stable identity, used as the React key. Unique within tiles. |
kind | "stat" | "chart" | "table" | What the tile is. |
title | string? | Overrides the title derived from what the tile reads — Mean bounty, Count by hall and verdict, leagues × bounty, Rows. |
A chart and a table also have:
| Field | Type | Meaning |
|---|---|---|
span | TileSpan — 1 | 2 | 3 | Columns of the three-column row. The grid is one column on a phone, two from md and three from xl, and a span never exceeds the columns there are. |
A figure has no span: the figures share the band above the grid equally, so one never stands as
tall as the chart beside it.
StatTile
| Field | Type | Meaning |
|---|---|---|
measure | DashboardMeasure | The figure. |
trend | string? | An ordered field — a time, or a number with an extent. Adds the measure along it as a sparkline and the last step's change as a delta. |
goodWhenUp | boolean? | Whether a rise is good news. Default true. |
ChartTile
| Field | Type | Meaning |
|---|---|---|
type | DashboardChartType | "bar", "line", "area", "histogram", "dot" (scatter) or "regression" (scatter with a fit). |
x | string | The field along the axis: a category for bar; a time or a number for line, area, histogram; a number for dot and regression. |
y | DashboardMeasure | What is measured per group — or, for dot and regression, { "op": "value", "field": … }: the raw column, one point per row. |
color | string? | A category of 2–8 values drawn as series. Not on regression. |
facet | string? | A category of 2–6 values drawn as small multiples sharing one pair of scales. Not on bar. |
origin | { rule: string }? | The recommend rule that proposed the chart, set by autoDashboard and dropped by any edit but its width. What its Automatic badge and rationale are read from. |
TableTile
| Field | Type | Meaning |
|---|---|---|
columns | string[] | The rows under the selection, as these columns, in order. |
DashboardMeasure
A number per group, or the raw column for the two charts that plot rows.
| Field | Type | Meaning |
|---|---|---|
op | DashboardAggregate | "value" | The aggregate — see below. |
field | string? | What the aggregate reads. None for count. |
equals | string | number | boolean | For share only: the value whose share is measured. |
op | SQL | field | Notes |
|---|---|---|---|
count | count(*) | — | |
distinct | count(DISTINCT field) | any field | |
sum · avg · min · max · median | the aggregate of field | a number | avg is labelled Mean, sum Total |
share | 100 * count(*) FILTER (WHERE field = equals) / count(*) | a category | a percentage; a tile's delta is in pp. Written in the spec, not offered by the editor, because it needs a value |
value | field | a number | dot and regression only |
DashboardAggregate is the eight aggregates above without value.
How an invalid spec is treated
A spec is stored for months while the relation under it changes, so nothing in it is trusted to be valid when it draws:
- A chart that names a field the relation no longer has draws The relation has no
hallany more. in place of its chart. The rest of the dashboard draws. - A filter or a table column on a missing field is skipped.
- The editor never writes an invalid chart. Every edit is normalised for the chart's mark: an
encoding the mark can keep is kept, one it cannot is replaced by the first field that fits, an
optional one (
color,facet) that fits nothing is dropped, and a measure the mark cannot use becomescount. A mark or a kind the relation has nothing to draw with is disabled.
A host that writes specs by hand — a template, an import — gets no such normalisation on the way in:
the fields it names are drawn as named. Check it with parseDashboard and the
relation's useFieldStats before saving one, or let a reader open it and save one edit.
The automatic spec
autoDashboard(fields) returns the spec drawn while value is undefined — the rules are on
Dashboard. Its ids are positional (card-0,
stat-count, stat-bounty, rows); ids the editor mints for a new tile are
crypto.randomUUID()s. Neither is meaningful beyond the spec it is in.
The first onChange hands the host the automatic tiles as a real spec, so from that edit on the
dashboard is what was saved, not what the stats would choose today. Reset to automatic, in the
dashboard's options menu, calls onChange(undefined): the host deletes what it stored for the
relation, and the dashboard draws autoDashboard again — and follows the stats from then on.
Persisting it
A spec names columns, so it belongs to a relation. The host stores one spec per relation, keyed
by the relation's identity, and hands it back as value with the same table.
- Store what
onChangehands you, whole. Never merge a patch into a stored spec: the editor already produced the complete next state, and a merge resurrects what the reader removed. - Store nothing until the first edit.
value={undefined}draws the automatic dashboard; storing it eagerly freezes today's stats into a spec nobody chose. - Debounce the write. Moving a tile or adjusting a title is many edits and one decision — the host recipe has the pattern.
- Treat it as opaque on the server. The server needs no knowledge of the shape: a JSON column and a size cap are enough. A layout and its encodings are a few kilobytes; data never is.
Dashboards
A host that draws dashboards over the relations of a join
graph keeps every one of them in one document, keyed by relationKey:
interface Dashboards {
byRelation: Record<string, DashboardSpec>; // "Person", "Person>knows>Person", … → its dashboard
}A relation nobody has edited has no entry, and draws its automatic dashboard. A product keeps the document per whatever owns the corpus — a job, a dataset — written whole after a pause.
Reading it back
What a host stored comes back through one door, checked against the spec's own declaration — the types on this page are inferred from it, so the check and the types cannot disagree:
parseDashboards(saved)reads the document.nullorundefinedis{ byRelation: {} }.parseDashboard(saved)reads one relation's spec, for a host that stores them apart.
Anything that is not a current spec throws, naming the first field that is wrong and, from
parseDashboards, the relation: not a dashboards document at byRelation.Person.tiles.0.span: ….
Nothing is coerced, defaulted or dropped, and a key the spec does not have is refused, so a
document is current or it is not read: a reader never half-understands one and then overwrites it
with its first edit. Show the failure where the dashboard would be, not the automatic dashboard.
There is no version. A dashboard saved by an earlier release is refused, not migrated: the host deletes what it stored when it upgrades. The shape check is the compatibility check; a version and a migration chain come back together the day a release has to carry stored dashboards across a change of shape, never one without the other.
Two checks hold whatever the relation: every measure but count names a field, and a share
names the value it measures (equals). Whether the fields exist is the relation's to say, and is
treated when it draws.
Types
interface DashboardSpec {
filters: DashboardFilterSpec[];
tiles: Tile[];
}
interface DashboardFilterSpec {
field: string;
}
type Tile = StatTile | ChartTile | TableTile;
type TileKind = Tile["kind"]; // "stat" | "chart" | "table"
type TileSpan = 1 | 2 | 3;
interface StatTile {
id: string;
kind: "stat";
title?: string;
measure: DashboardMeasure;
trend?: string;
goodWhenUp?: boolean;
}
interface ChartTile {
id: string;
kind: "chart";
span: TileSpan;
title?: string;
type: DashboardChartType;
x: string;
y: DashboardMeasure;
color?: string;
facet?: string;
origin?: { rule: string };
}
interface TableTile {
id: string;
kind: "table";
span: TileSpan;
title?: string;
columns: string[];
}
interface DashboardMeasure {
op: DashboardAggregate | "value";
field?: string;
equals?: string | number | boolean;
}
type DashboardChartType = "bar" | "line" | "area" | "histogram" | "dot" | "regression";
type DashboardAggregate = "count" | "distinct" | "sum" | "avg" | "min" | "max" | "median" | "share";
interface Dashboards {
byRelation: Record<string, DashboardSpec>;
}
function parseDashboard(saved: unknown): DashboardSpec;
function parseDashboards(saved: unknown): Dashboards;Dashboard
A dashboard over any relation for finding what it holds — a filter bar and tiles (figures, charts, tables), chosen from the relation's stats, edited where they sit, and kept as a JSON spec the host saves.
Host recipe
Wiring a Dashboard to a backend — loading a saved spec, writing it after a pause, a read-only mode, your own labels and colours, one dashboard per relation, and where failures go.