--- name: dashboard-design description: How to build a working dashboard — the exact dashboard.json contract, how to explore a schema and test SQL before it becomes a panel, which chart answers which question, and how to lay panels out so the important number is where the eye lands. Use for EVERY dashboard request, before writing anything. --- # Dashboard design ## The rules that make this work here These travel with the Skill so that a dashboard made on any Harness that has it behaves the same. - THE FILE: `./dashboard.json`, in your current working directory. That exact path, always. Do not search for it and do not treat its absence as a puzzle: on a new dashboard it does not exist yet and you create it there. It is the single source of truth and the only file the app reads. - `dashboard.json` IS the deliverable. Never delete it or replace it with an image, a report or any other export unless the person asks for that by name, and then keep `dashboard.json` beside it. - Read it before every change and write it back WHOLE: the person may have moved panels between turns. Validate with this Skill's `validate_dashboard.py` before you finish. - The database is reachable only through the database tools in your tool list, and only for reading: one statement, SELECT, row-capped and timed out. Explore the schema with the schema tool instead of guessing table names, and RUN every query before you put it in a panel; a panel whose query you never ran is a panel you have not seen. You are never given a connection string; never write one into any file. - Never put a number in the file. Every value on a dashboard comes from a query at the moment someone opens it. A chart with its data written in is a lie that never refreshes. - Work directly. Every command you spend orienting is a command the person waits through. - TALK TO THE PERSON ABOUT THEIR DASHBOARD, never about how it is made: no file names, JSON, validators, this Skill, your working directory, commands, tests or tools. One short line of what changed, then stop. *How you talk to the person*, below, is the whole rule. ## How you talk to the person The person reads your replies in a pane beside their dashboard. Write every reply for them, about their dashboard, in their words: panels, numbers, charts, the question each one answers, their data. - Never mention how the dashboard is made. No file names (not even `dashboard.json`), paths, JSON, query or panel ids, validators, this Skill, its rules or contracts, your working directory, commands, tests or tools, the database tools included. Their data is theirs: name one of their tables or columns when that helps them follow, as someone who knows their own data would. - When the work is done, reply with one short line of what the dashboard now shows, then stop: "Added a churn trend under the revenue numbers, counted from cancellations since trials have no end date." Not how you did it, what you checked, or what passed. - Do not narrate the work before or while you do it: no "Reading the skill first", "Now exploring the schema", "Validated clean". Do the work, then say what changed. - No fallback chatter. If a query failed along the way and you got there another way, the person hears only the result. If part of what they asked for cannot be answered from their data, say so in one sentence, in their terms, with what you show instead. - Ask a question only when the answer is theirs to give (which customers count as active, which months matter), never about how to build the dashboard. You are answering a question with a database, not decorating one. ## The file you are writing There are no dashboard tools here. A dashboard is ONE file — `dashboard.json` in your working directory — and you write it with the ordinary file editor. Nothing else reads it, so a file that does not match this shape shows the person empty panels. Match it exactly. ```json { "meta": { "schema": 1, "title": "Revenue overview" }, "datasource": { "engine": "postgres", "ref": "" }, "queries": [ { "id": "q_headline", "name": "MRR and customers now", "sql": "SELECT SUM(mrr_cents) / 100.0 AS mrr, COUNT(*) AS customers FROM subscriptions WHERE status = 'active'" }, { "id": "q_revenue_by_month", "name": "Revenue by month", "sql": "SELECT to_char(date_trunc('month', paid_at), 'YYYY-MM') AS month, SUM(amount_cents) / 100.0 AS revenue\nFROM invoices\nWHERE status = 'paid' AND paid_at >= CURRENT_DATE - INTERVAL '12 months'\nGROUP BY 1 ORDER BY 1" } ], "panels": [ { "id": "p_mrr", "title": "Monthly recurring revenue", "caption": "Active paid subscriptions", "query": "q_headline", "layout": { "x": 0, "y": 0, "w": 3, "h": 2 }, "viz": { "kind": "stat", "column": "mrr", "format": "currency", "currency": "USD" } }, { "id": "p_customers", "title": "Paying customers", "query": "q_headline", "layout": { "x": 3, "y": 0, "w": 3, "h": 2 }, "viz": { "kind": "stat", "column": "customers", "format": "int" } }, { "id": "p_trend", "title": "Revenue by month", "query": "q_revenue_by_month", "layout": { "x": 0, "y": 2, "w": 8, "h": 5 }, "viz": { "kind": "chart", "option": { "xAxis": { "type": "category" }, "yAxis": { "type": "value" }, "series": [ { "type": "line", "smooth": true, "encode": { "x": "month", "y": "revenue" } } ] } } } ] } ``` That is the whole vocabulary. Three arrays of facts: where the data comes from, what to ask it, and what to draw with the answer. ### The rules that are not negotiable Each of these fails silently — an empty panel, or worse, a number nobody can trust: - **`meta.schema` is `1`.** The app refuses a file it cannot read rather than guessing at it. - **A panel names a query by id: `"query": "q_revenue_by_month"`.** Not an inline query, not a `dataSource` object. A panel whose `query` names nothing renders empty. - **You never write the data.** `viz.option` carries no `dataset`, and no `series[].data`. The app runs the query when the person opens the page and injects the result as the chart's dataset. Data written into the option is either ignored or, worse, shown — a chart that says something the database never said and never updates. - **Charts address result columns by name, through `encode`.** The app sets `dataset.dimensions` to the query's column names, so `"encode": { "x": "month", "y": "revenue" }` means the columns your own SELECT aliased `month` and `revenue`. Alias every expression in your SQL — `SUM(x)/100.0` with no alias gets a column name the database invented. - **`viz.kind` is one of `stat`, `chart`, `table`.** Not `type`; not the ECharts series name. The series type lives inside `option.series[].type`. - **A `stat` reads the FIRST row** of its query's result, at the named column. A query for a stat should return exactly one row. - **`currency` format requires a `currency` code** (`"USD"`, `"EUR"`, …). There is no default. Nobody may put a dollar sign in front of a number you did not confirm is dollars. - **`percent` takes a RATIO, and does the ×100 itself.** Write SQL that returns `0.021`, not `2.1` — do NOT multiply by 100. Every template in this kit shipped with `… * 100.0 AS churn_pct` under a `percent` panel, and a 2.1% churn rendered as **210%**. Both readings are plausible, so nothing looks wrong until someone knows the real number. Name the column for what it holds (`churn_rate`, not `churn_pct`) and the next person will not re-introduce it. - **A number with a unit says its unit.** `42` under "Median first reply" is minutes, hours or days depending on who is reading. Set `unit` (`"min"`, `"pages"`, `"days"`) on any `decimal` or `int` stat that is not self-evidently a count of the thing in its title. - **`layout` is a 12-column grid**: `x + w` never exceeds 12, `w` and `h` are at least 1, and no two panels overlap. Overlapping panels get shoved around by the grid and the person sees a layout you did not design. - **Ids are stable and unique** across `queries` and across `panels`. Reuse them when you edit; never renumber a dashboard, or the person's saved arrangement detaches from the panels. - **`datasource.ref` is not yours to invent, and never a connection string.** It names the connection the person set up when they created this dashboard. If the file already has one, copy it forward untouched. If you are creating the file, write `""` — the app fills it in. A connection string written here would be a password in a file the browser reads. Optional and worth having: `panel.caption`, one short line under the title for the thing the number does not say ("Excludes trials", "Last 12 complete months"). Everything not listed above is not a field. There is no refresh setting: opening the dashboard runs every query, and that is the only refresh there is. ### What the app does with the file Worth knowing, because it explains every rule above: 1. It runs **each query a panel reads, once**, whatever number of panels read it. Three stat panels pointing at one `SELECT a, b, c` cost one round trip; three near-identical queries cost three. A query no panel names is not run at all — so deleting a panel and leaving its query behind costs nothing, but nothing will tell you the query is dead either. 2. A result is `{ columns: [name…], rows: [[value…]…] }` — the query's own column names and its rows. 3. For a `chart` panel it merges your `option` over a small theme base — the palette, fonts, and tooltip and grid defaults — and sets `dataset` to `{ dimensions: columns, source: rows, sourceHeader: false }`. The base adds no axes: a `bar`, `line` or `scatter` declares its own `xAxis` and `yAxis`; a `pie` or `funnel` declares neither, or ECharts draws stray ones through it. 4. For a `stat` it formats `rows[0]` at the named column; for a `table` it draws the rows, restricted to `viz.columns` if you named them. 5. A query that fails shows its error in the panels that read it. It does not show a zero. Nothing on a dashboard is ever a stand-in for a number that did not arrive. Read `dashboard.json` before every change and write it back WHOLE. The person may have dragged panels between your turns, and a partial write loses that. ## The database is not yours to guess Your tool list contains the database tools for this dashboard — read it, and use the names you find there. Do not guess a tool name from this document. There are two capabilities: - **The schema tool** returns `{ engine, cut?, tables: [ { schema, name, kind, row_estimate, column_count, columns?: [ { name, type, nullable } ], sample? } ], table_count, not_found?, truncated, sampled }`. Call it FIRST, on every new dashboard, before you write a line of SQL. `engine` is what you copy into `datasource.engine`. Tables come largest first by estimated rows. Only the largest few carry `columns`; every other table carries its `kind` (table, view, …), `row_estimate` and `column_count` and nothing more. It takes two optional parameters: `tables`, up to 20 names (`schema.table` or `table`) to describe in full, and `search`, text that a table's `schema.table` name must contain. - **The query tool** runs one SELECT and returns `{ columns, rows, row_count, truncated, limit_applied }`. **Read `cut` first, every time.** When the schema answer leaves anything out (tables past the listing, columns of the tables it did not describe, the later columns of a very wide table, rows it did not sample), `cut` says what, in a sentence, ahead of everything else. Then ask for exactly what is missing: - a table you need is listed without `columns`: call the schema tool again with `tables` naming it, up to 20 in one call; - the table you need is not in the listing at all (a big database lists only its largest few hundred): call it with `search` and a word from the name you expect, such as `invoice`, `order` or `event`; - `not_found` names a table that does not exist under that name: search for it rather than guessing another spelling. Never call the schema tool again with no parameters, to retry or to get more: the same call gives the same answer, and on a large database it spends the person's time to learn nothing. `row_estimate` is the database's own estimate: good for choosing between tables, never a number to put on a dashboard. What `cut` says is for you; tell the person only what it means for their dashboard, in their words. If neither is in your tool list, stop: this dashboard has no database connected, or the one it had is switched off, and SQL you cannot run is SQL you cannot promise. Neither is yours to fix from here. Tell the person, in one sentence, that this dashboard cannot reach their database right now, so it has nothing to read until its connection is back on, and that restoring it is theirs to do. Never name the tools. **Sample rows are the person's choice.** When they set the dashboard up they decided whether the agent may see real rows alongside the column names; `sampled` in the schema response tells you which way it went. When it is `false`, they have deliberately kept their data out of this conversation — work from names and types, and do not go fishing with `SELECT *`. Confirm shapes with questions that are about shape (`COUNT(*)`, the aggregate you intend to chart), not with dumps of rows. **Read the schema like a person, not a parser.** Names and types tell you most of it: `status`, `deleted_at`, `is_test`, `plan`, `*_id`, timestamps. Before charting a category column, find out what is actually in it — `SELECT status, COUNT(*) FROM subscriptions GROUP BY 1 ORDER BY 2 DESC` — because a dashboard filtered on `status = 'active'` when the table says `'ACTIVE'` shows an honest, confident zero. That question is about shape and stays fair game whatever the sample setting: it asks which categories exist, not what any particular customer did. **When the schema cannot answer the question, say so.** If they ask for churn and there is no cancellation date anywhere, the correct move is one sentence telling them what is missing and what you can show instead. Not a plausible-looking query over the wrong column. When `cut` says the listing left tables out, "anywhere" means you searched for it first. ## SELECT, and nothing else Every statement you send is checked before it reaches the database, and refused unless it is a single read: - one statement — no semicolons joining two; - it starts with `SELECT` or `WITH`; - no `INSERT`, `UPDATE`, `DELETE`, `CREATE`, `DROP`, `ALTER`, `GRANT`, `SET`, `COPY`, `CALL`, `INTO`, … anywhere in it, including inside a CTE — a data-modifying CTE starts with `WITH` and still deletes rows; - on PostgreSQL it additionally runs inside a read-only transaction; - results are row-capped and the statement is timed out. That check is a parser, and a parser is a thing that can be wrong, which is why the setup guide also asks for a database account that only has SELECT. Two independent defences. Do not design around either of them: no temp tables, no `SET search_path`, no stored procedure calls. If a word in the forbidden list is genuinely a column name (`comment`, `set`, `into`), alias it — the check cannot tell a column from a command. Keep queries cheap. Aggregate in SQL, never in the panel: `GROUP BY` and `SUM`/`COUNT` server-side and return the dozen rows you will draw, rather than ten thousand rows for the browser to add up. Bound every time series (`WHERE occurred_at >= CURRENT_DATE - INTERVAL '90 days'`) and put a `LIMIT` on every "top N" query. **`ORDER BY` is not optional on anything with an axis.** A database returns rows in whatever order it likes; a month axis that is not ordered draws September before March and looks like data corruption. ### Dialect `datasource.engine` is `postgres` or `mysql` (MariaDB counts as `mysql`). Most of what a dashboard needs differs in exactly three places: | | PostgreSQL | MySQL / MariaDB | |---|---|---| | Month bucket | `date_trunc('month', created_at)` | `DATE_FORMAT(created_at, '%Y-%m-01')` | | Relative date | `CURRENT_DATE - INTERVAL '30 days'` | `CURRENT_DATE - INTERVAL 30 DAY` | | Identifier quoting | `"order"` | `` `order` `` | Write for the engine the schema tool reported. Do not write one query that tries to work on both. ## Test every query before it becomes a panel Run it. Read what came back. Then write the panel. A query you have not run is a guess about a column name, a join cardinality, an empty result and a type all at once, and the person finds all four for you when they open the page. Specifically, check: - **It runs at all.** Half of first drafts name a column that does not exist; the error says which. - **It returned rows.** Zero rows is a real answer sometimes and a wrong filter the rest of the time. Widen the window and see whether the data appears. - **The columns are named what your `encode` says.** This is the single most common cause of a blank chart: the SQL says `SUM(mrr_cents)/100.0` and the encode says `mrr`. - **A stat query returned exactly one row.** - **The numbers are plausible.** Revenue of 4,899,000 when they told you it is a small business usually means cents charted as currency, or a join fanning out rows. Divide by 100, or count `DISTINCT`. - **`truncated` is false.** If the row cap cut your result, the chart would be drawing a slice of the answer. Aggregate harder. ## Choosing the chart for the question Pick the form from the question, not from variety. One question per panel. | The question | The panel | |---|---| | How much / how many, right now? | `stat` — a big number, with a caption saying "of what" | | Is it going up? | `line`, time on the x-axis, one to three series | | Which is biggest? | `bar`, sorted by value, horizontal when the labels are words | | How does the whole split? | `pie` (donut) — only with ≤ 6 slices, otherwise a bar | | Where do things drop off? | `funnel`, stages in order | | Which specific rows need attention? | `table`, ≤ 10 rows, most urgent first | | Do two numbers move together? | `scatter` | Bars emphasise magnitude, lines emphasise trend ([eazyBI](https://eazybi.com/blog/data-visualization-and-chart-types)). A pie with nine slices and a pie with three near-equal slices are both unreadable; past six categories, use a sorted bar. Never a pie for a pipeline: it hides the one thing you wanted, which is where the drop is ([ORM](https://orm-tech.com/blog/sales-dashboard-chart-types)). A number is only a metric when it is compared to something. Give a headline stat either a `delta` (from a second column in the same query) or a trend line directly under it. `delta.good` says which direction is good — `"up"` for revenue, `"down"` for churn and response time — so the app can colour it correctly, and a falling response time is not painted red. ```json "viz": { "kind": "stat", "column": "mrr", "format": "currency", "currency": "USD", "delta": { "column": "mom_pct", "format": "percent", "good": "up" } } ``` Formats: `int` (whole number, grouped), `decimal` (up to 2 places), `compact` (1.2M — for a number too big for its tile), `percent` (**the query returns 12.4, not 0.124** — it renders as "12.4%"), `currency` (with its code), and `text` for a value that is not a number at all. Money in a database is usually an integer number of cents: divide in SQL, or the dashboard is off by a hundred. The series types that read a query result are `bar`, `line`, `pie`, `scatter` and `funnel`. `gauge`, `radar` and `treemap` do not — for a single number use a `stat`, which is what a gauge was pretending to be. Leave colours alone. The app themes every chart from one palette that works in both light and dark; set a colour only to make one series mean something (a target line, the one bar you are talking about), never to decorate. ## Laying it out The grid is **12 columns wide**, rows are a fixed height, and it scrolls vertically. Narrow screens stack every panel full width in reading order — top to bottom, left to right — so the order you lay out in is the order a phone reads. - **The top-left panel is the answer to their question.** Eyes land there first and the F-pattern takes them right and then down; the most important number gets the best real estate ([Domo](https://www.domo.com/learn/article/dashboard-design-examples-best-practices), [UXPin](https://www.uxpin.com/studio/blog/dashboard-design-principles/)). - **Row one is numbers, not charts.** Three or four stats across the top — `w: 4, h: 2` gives three, `w: 3, h: 2` gives four — then the charts that explain them underneath. - **Sizes that work**: stat `h: 2`; a chart `w: 6-8, h: 4-6` (never narrower than 4, or the axis labels collide); a table `w: 6, h: 5-8`. Anything wide and short is a trend; anything square is a comparison. - **Five to nine panels.** Stephen Few's definition of a dashboard is the information needed for a job, on one screen, monitored at a glance ([Perceptual Edge](https://www.perceptualedge.com/files/Dashboard_Design_Course.pdf)); working memory holds about seven things ([Figr](https://figr.design/blog/dashboard-design-best-practices)). Past a dozen panels nobody reads any of them — that is a second dashboard for a second audience. - **Group by subject, not by chart type.** Revenue next to revenue. Reading MRR and ARR apart hides the relationship between them ([Orbix](https://www.orbix.studio/blogs/saas-metrics-dashboard-guide)). - **Leave no half-row gaps.** Every row's panels sum to 12 columns. - **Titles are the question, in their words.** "Sign-ups by week", not "COUNT(id) GROUP BY week". Never name a table or a column in a title. `templates/templates.json` in this kit holds five worked dashboards — SaaS revenue, sales pipeline, web analytics, support operations, an executive summary — each with real SQL and real ECharts options. They are written against a schema the person almost certainly does not have. Use them for **which panels belong on this kind of dashboard and how they are arranged**, then rewrite every query against the schema you actually read. A template's SQL pasted in unchanged is a dashboard of error messages. ## Review pass (mandatory) The validator ships beside this SKILL.md, in this Skill's own folder; the command below finds it wherever your harness placed the Skill. From your working directory: ``` python3 "$(find . -path '*/dashboard-design/validate_dashboard.py' -not -path '*/node_modules/*' 2>/dev/null | head -1)" dashboard.json ``` It prints the exact path of anything that will render wrong, and what to write instead. **Fix and re-run until it exits clean.** It catches a statement the read-only gate would refuse, data written into a chart, a panel pointing at no query, and panels overlapping — none of which you can see from here. The validator cannot run your SQL. You can, and you must: every query, before it ships. Then reread the file as the person opening it: - Does the top-left panel answer the question they actually asked? - Is every number labelled well enough that they know what it excludes? - Would any two panels be better as one? Finish by telling them, in one line, what the dashboard shows and anything you could not answer from their data, as *How you talk to the person* says.