Kanzo UI
Analytics

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

FieldTypeMeaning
filtersDashboardFilterSpec[]The filter bar, in order.
tilesTile[]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

FieldTypeMeaning
fieldstringThe 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:

FieldTypeMeaning
idstringStable identity, used as the React key. Unique within tiles.
kind"stat" | "chart" | "table"What the tile is.
titlestring?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:

FieldTypeMeaning
spanTileSpan — 1 | 2 | 3Columns 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

FieldTypeMeaning
measureDashboardMeasureThe figure.
trendstring?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.
goodWhenUpboolean?Whether a rise is good news. Default true.

ChartTile

FieldTypeMeaning
typeDashboardChartType"bar", "line", "area", "histogram", "dot" (scatter) or "regression" (scatter with a fit).
xstringThe field along the axis: a category for bar; a time or a number for line, area, histogram; a number for dot and regression.
yDashboardMeasureWhat is measured per group — or, for dot and regression, { "op": "value", "field": … }: the raw column, one point per row.
colorstring?A category of 2–8 values drawn as series. Not on regression.
facetstring?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

FieldTypeMeaning
columnsstring[]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.

FieldTypeMeaning
opDashboardAggregate | "value"The aggregate — see below.
fieldstring?What the aggregate reads. None for count.
equalsstring | number | booleanFor share only: the value whose share is measured.
opSQLfieldNotes
countcount(*)—
distinctcount(DISTINCT field)any field
sum · avg · min · max · medianthe aggregate of fielda numberavg is labelled Mean, sum Total
share100 * count(*) FILTER (WHERE field = equals) / count(*)a categorya percentage; a tile's delta is in pp. Written in the spec, not offered by the editor, because it needs a value
valuefielda numberdot 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 hall any 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 becomes count. 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 onChange hands 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. null or undefined is { 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;

On this page