Dashboard Creation
When to create a dashboard
By the time you're here, you should either have explored the data in conversation already, or have a request specific enough to build from directly. If neither is true, go back to the conversation and explore first.
Check prior work
Read /dashboard/index.md before creating anything new. If related dashboards exist, tell the user what's there and how it relates to their request. Ask whether to extend or start fresh.
Working notes
Create notes.md in the dashboard folder to record context that isn't visible in the dashboard itself: analytical decisions, data caveats, why you chose one table over another, filter logic, or anything useful for understanding the dashboard later. This file is not rendered in the app.
Dashboard structure
Create dashboards at /dashboard/{name}/ with three required files:
dashboard/{name}/
├── queries/ # One .sql file per query
│ ├── regions.sql
│ └── trend.sql
├── outputs.py # @output functions → Metric, DataFrame, or Plotly figure
└── dashboard.md # Layout with containers and component tags
queries/*.sql
One file per query. Filename (without extension) = query name.
Use :param for filter binding. The framework extracts params via regex — only params found in the query are bound, others get NULL.
For optional filters, use the IS NULL OR pattern:
SELECT
tid, indhold as population
FROM fact.folk1a
WHERE kon = 'TOT'
AND (:region IS NULL OR omrade = :region)
AND (:period_from IS NULL OR tid >= :period_from)
AND (:period_to IS NULL OR tid <= :period_to)
ORDER BY tid
Avoid :: PostgreSQL casts in the same position as :param — the regex uses negative lookbehind to distinguish them, but keep casts away from param names to be safe.
Queries referenced by options="query:..." in a filter tag are options queries — run at page load to populate dropdowns. All others are data queries — run when outputs lazy-load.
outputs.py
Functions decorated with @output that produce dashboard content. The names output, Metric, px, go, and pd are pre-injected — no imports needed for these, though explicit imports also work.
@output
def population_trend(trend, filters):
return px.line(trend, x="tid", y="population", title="Population Over Time")
@output
def total_population(trend, filters):
current = trend["population"].iloc[-1]
return Metric(value=current, label="Total Population", format="number")
@output
def region_table(by_region, filters):
return by_region
Dependency injection: parameter names matching a query name receive that query's result as a DataFrame. The filters parameter receives all current filter values as a dict.
Return types:
| Return type | Rendered as | Markdown tag |
|---|---|---|
Plotly Figure | Interactive chart | <fig /> |
pd.DataFrame | DaisyUI table | <df /> |
Metric | Metric card | <metric /> |
Metric model:
Metric(
value=1234567, # float, int, or str
label="Total Revenue",
format="number", # "number" (K/M/B), "currency" (kr.), "percent" (%)
change=0.12, # optional ratio; renders as +12.0% ((current - prev) / prev)
change_label="vs last year",
)
change=0.2 renders as +20.0%, not +0.2 units.
dashboard.md
Extended markdown with ::: container syntax and self-closing component tags. Regular markdown (headers, paragraphs, lists) renders normally between components.
Containers
| Container | Attributes | Purpose |
|---|---|---|
filters | — | Wraps filter components in a form |
grid | cols (default: 2) | CSS grid layout |
tabs | — | Tab container |
tab | name | Individual tab panel |
Nesting: one level supported (e.g. grid inside tab). ::: always closes the most recently opened container.
Component tags
Output tags — reference an @output function by exact name:
<fig name="trend_chart" /> <df name="detail_table" /> <metric name="total_count" />
Filter tags:
<filter-select name="region" label="Region" options="query:regions" default="all" /> <filter-date name="period" label="Period" default="all" /> <filter-date name="period" label="Period" default_from="2020-01-01" default_to="2025-12-31" /> <filter-checkbox name="include_pending" label="Include Pending" default=false />
- •
filter-select:options="query:{query_name}"supports:- •single-column query: value and display label are the same.
- •two-column query: first column = value sent in URL/SQL param, second column = display label.
default="all"means no filter (NULL in SQL). If your options query isSELECT kode, titel ..., filter withd.kode::text = :region.
- •
filter-date: produces{name}_fromand{name}_toparams.default="all"= no date filter. Can setdefault_from/default_toindependently. - •
filter-checkbox:default=trueordefault=false. URL format:?name=trueor?name=false.
Example dashboard.md
# Det Danske Boligmarked ::: grid cols=4 <metric name="total_boliger" /> <metric name="andel_parcelhuse" /> <metric name="andel_etageboliger" /> <metric name="prisindeks_seneste" /> ::: ::: tabs ::: tab name="Boligbestand" Udvikling i boligbestanden over tid. <fig name="boligbestand_chart" /> <df name="boligbestand_tabel" /> ::: ::: tab name="Prisudvikling" Prisindeks for forskellige ejendomstyper (2015=100). <fig name="prisindeks_chart" /> <df name="prisudvikling_tabel" /> ::: ::: tab name="Salgsaktivitet" <fig name="salg_antal_chart" /> <fig name="salg_priser_chart" /> ::: :::
Workflow
- •Check prior work — read
/dashboard/index.md. If related dashboards exist, tell the user and ask whether to extend or start fresh. Reuse queries and patterns where they fit. - •Plan structure — decide which queries, outputs, and filters the dashboard needs. If you explored data in conversation, reuse those queries as starting points.
- •Write queries — one
.sqlper query inqueries/. Use:paramandIS NULL ORfor optional filters. - •Write outputs.py — one
@outputfunction per visual element. - •Write dashboard.md — layout with containers and component tags. Add markdown commentary to explain the data.
- •Validate — Use
ValidateDashboard(url?)to run full end-to-end check of the dashboard. - •Snapshot and inspect —
Snapshot()to materialize outputs. Read the PNGs and JSON selectively to verify the dashboard looks right. - •Iterate — fix issues, re-validate, re-snapshot.
- •Navigate —
UpdateUrl(path="/dashboard/{name}")to open it.
Validation
Use ValidateDashboard to execute all SQL queries and output functions before snapshotting:
ValidateDashboard(url="/dashboard/boligmarked")
If validation fails, fix the listed query/output errors first. If validation passes with warnings (for example empty results for filtered views), decide whether to continue to snapshot.
Snapshot
Use the Snapshot tool to capture the current dashboard state to disk. When already viewing a dashboard, call with no arguments:
Snapshot()
Or specify a URL with filters:
Snapshot(url="/dashboard/boligmarked?region=Hovedstaden")
Outputs are written to /dashboard/{name}/snapshots/{query}/:
- •
dashboard.png— full page screenshot - •
figures/{output_name}.png— each chart as PNG - •
tables/{output_name}.parquet— each table as parquet - •
metrics.json— all metrics as JSON
Inspect results with Read on the png and json files, or check parquet files to verify data.