Documentation menu

Writing dashboards#

A dashboard is a dashboards/<slug>/page.md document plus optional sibling .sql and .tsx files. Markdown supplies the narrative. Components turn named query results into charts, tables, numbers, pivots, and maps.

Create the page#

text
dashboards/revenue/
  page.md
  revenue-daily.sql

Put the page title, refresh interval, result cap, and optional theme in frontmatter:

text
---
title: Revenue
refresh: 4h
max_rows: 2000
theme: supernova
---

Add a query#

revenue-daily.sql is addressed as revenue-daily from the page:

sql
select
  cast(created_at as date) as day,
  sum(amount) / 100.0 as revenue
from titan.stripe.charges
where status = 'succeeded' and not _deleted
group by day
order by day

An inline fence named after its language is also a data source: ``` `sql revenue-daily ```. Sibling files keep longer queries easier to review.

Build on another query#

A query can read another query of the same dashboard as queries.<name>:

sql
select sum(revenue) as revenue
from queries."revenue-daily"
where day >= $period.start

The referenced query is inlined where you name it, so it runs inside the same job and always reads current data — nothing is stored between the two. Inputs like $period.start work in either query. A reference to a query that does not exist, or a loop between two queries, is reported above the page.

Render the result#

text
<Chart data={revenue-daily} type="line" x="day" y="revenue"
       title="Revenue by day" format={{"type":"money","currency":"USD"}} />

Built-in data components are Chart, Table, BigNumber, Pivot, and Map. Chart types are line, bar, area, donut, pie, and scatter. Put related components inside <Row> to share a row.

Mark a target#

text
<Chart data={revenue-daily} type="bar" x="day" y="revenue"
       goal={{"y":120000,"label":"Target"}} />

goal draws a reference line on the value axis and stretches the scale so the line always lands inside the plot. Pass an array for up to four of them — a floor, a plan, a stretch. On horizontal bars the line runs vertically, because it follows the value, not the screen. Donut and pie charts have no value axis, so they reject it.

Point at one series#

text
<Chart data={revenue-by-plan} type="line" x="day" y="revenue" series="plan" emphasis="enterprise" />

emphasis keeps the named series in its color and drops every other one to muted ink — lines, areas, bars, dots, legend and tooltip swatches together. Matching ignores case. A name that no series in the result carries changes nothing, so a chart never breaks because a category disappeared from the data.

Name a moment#

text
<Chart data={revenue-daily} type="line" x="day" y="revenue"
       annotations={[{"x":"2026-03-01","label":"Price change"}]} />

Each annotation draws a vertical hairline at that x with a small label at the top of the plot, up to eight of them. The x has to be a value the query actually returned — filters move the window, so one that falls outside it is skipped rather than snapped to the edge. Labels are truncated and stagger down a row when they would collide; past four rows the hairline still draws and the label is dropped, because a fifth row of text is the plot.

Reuse one query for numbers and charts#

text
<BigNumber data={revenue-daily} value="revenue" label="Revenue" agg="sum" trend />

A big number reads the first row by default. agg folds the whole column instead — sum, avg, last, or first — so a daily query can feed a chart and an honest total from the same rows. trend adds a sparkline of that column in row order beside the value.

A column of whole numbers sums exactly, past the point a floating-point total starts drifting, so counts and ids stay right. The default compact format still rounds what it prints — that is what compact is for; ask for number or integer when every digit matters.

Compare against the previous period#

text
<Chart data={revenue-daily} type="line" x="day" y="revenue" compare="prev-period" />
<BigNumber data={revenue-total} value="revenue" label="Revenue" compare="prev-year" />

compare runs the same query a second time with its date range shifted back — prev-period by the length of the current window, prev-year by one calendar year. A chart draws the earlier period as a ghost under the current one and, unless you wrote a subtitle, adds the change as a dek. A big number turns it into the change beside the value.

The query has to read a date range for there to be anything to shift, so filter it with $period.start and $period.end. A chart compares one measure: no y list, no series, no donut. On a big number, compare replaces delta.

The two periods are lined up by value, not by row order. On a date axis each earlier row moves forward by exactly the shift the query used, so a period missing a day leaves a gap instead of sliding everything left. On a category axis — a top-N chart — the earlier period's rows match by name: a category that was in the top N last period but not this one has nowhere to sit and is not drawn, and one that is new this period has no ghost. The change in the dek is still measured over the whole earlier period, including the rows the chart had no room for, so the sentence stays true even when the picture is partial.

Add typed inputs#

text
<DateRange name="period" default="-30d" />
<Select name="currency" options={["usd","eur","gbp"]} default="usd" />

Reference inputs in SQL as $period.start, $period.end, and $currency. Values are validated against the declaration and bound as prepared-statement parameters. URL values are never interpolated into SQL.

TextInput, NumberInput, Select, and DateRange are supported. A Select with multiple binds a bounded list. Relative date presets include -30d, -2w, -6m, -1y, mtd, ytd, today, and all.

Extend a dashboard#

Add <Name>.tsx beside page.md for a page-specific component, or place a shared component under dashboards/components/. Custom components receive bounded table data and the same theme utilities as built-ins.