Report files
The report.tsx format. meta, page options, one query per .sql file, the components, the "(i)" dialog and reshaping rows in code.
A report is a folder of app/src/reports/ with a report.tsx in it. The folder’s name is the report’s address: app/src/reports/sales/ opens at /reports/sales. Adding the folder adds the report to the list. Nothing else needs changing.
You rarely write these files yourself. Ask Claude, and use this page to know what is possible.
app/src/reports/sales/
report.tsx the page: meta, page options, queries, components, dialog
monthly.sql a query: first line its -- title, $from and $to for the dates
app/src/metrics/metrics.ts the named metrics, used by name on components| File | What it is |
|---|---|
report.tsx |
The page: its name, its options, its queries, its components and the “(i)” dialog |
<name>.sql |
One query each, read by report.tsx |
A report folder holds only report.tsx and .sql files. Any other file fails the check.
Anatomy#
Sales
Sales per month
-- Sales and orders per month
select to_char(sold_on, 'YYYY-MM') as month,
sum(amount) as sales, count(*) as orders
from sales
where sold_on between $from and $to
group by 1import { defineReport, query } from "~/components/report-kit/define"
import { Info, Rule, Rules, Sql, Technical } from "~/components/report-kit/info"
import monthly from "./monthly.sql?raw"
type Month = { month: string; sales: number; orders: number }
export default defineReport({
meta: {
title: "Sales",
description: "Sales and orders per month.",
connector: "postgres",
},
page: {
info: () => (
<>
<Info>
<Rules>
<Rule id="sales" title="Sales">
the order amount, including tax, by the day it was sold.
</Rule>
</Rules>
</Info>
<Technical>
<Sql query="monthly" />
</Technical>
</>
),
},
queries: { monthly: query<Month>(monthly, { grain: "month" }) },
components: (k) => [
<k.Line
key="sales"
series="monthly"
x="month"
xFormat="month"
y={{ sales: "Sales" }}
title="Sales per month"
info={{ what: "Sales per month.", rules: ["sales"], sql: ["monthly"] }}
/>,
],
})defineReport takes four parts: meta (the report’s name), page (its options and the dialog), queries (each .sql file, by the name components read it under) and components (the page, top to bottom). The page itself holds no text: explanations go in the dialog.
meta#
| Key | Required | Default | Meaning |
|---|---|---|---|
title |
yes | The page’s title, and its name on /reports |
|
description |
yes | One line under the title, and on /reports |
|
connector |
no | see below | The connector every query of this report runs on: excel, csv, json, sqlite, sqlserver, postgres, mysql, bigquery or redshift |
Which connector a report reads: the one its meta.connector names; otherwise SILVI_CONNECTOR in app/.env; otherwise the only connector installed; otherwise csv. With more than one connector, each report names its own.
page#
| Key | Default | Meaning |
|---|---|---|
filterBar |
true |
false hides the date filter, for a report with no dates |
filterPresets |
every preset, and Start and End fields | Offers only these presets, in this order: ["Last Month", "Last Year"] |
filterEarliest |
none | The first day the date filter may start on, "YYYY-MM-DD" |
filterParams |
none | The report’s inputs and filter-bar choices |
inputsLabel |
“View” | The label on the button that holds the page’s inputs |
empty |
none | { series, text }: when this query returns no rows, the page shows text instead of its components |
emptyText |
“No data in this range” | What an empty component says |
width |
the app’s | "6xl" or "7xl", the page’s maximum width |
downloadHtml |
none | A header button that saves the page as HTML, under this file name |
info |
none | The “(i)” dialog, a function that returns its content |
asOfIn |
"header" |
Where the dialog’s “Data as of” line goes: "header" or "info" |
A key the page does not take fails the type check.
Queries#
Each query is a .sql file in the folder, imported with ?raw and named in queries: queries: { monthly: query<Month>(monthly) }. The name is how components read the result: series="monthly". Month is the row the query returns, one entry per column: components may name only those columns.
- Start the file with a
--comment line. It is the query’s title in the dialog; a file without one fails the check. - Queries only read: they must start with
selectorwith. The app refuses anything else. - Write the SQL in your database’s own dialect. Claude picks the right one for the report’s connector.
- Every connector returns rows in one shape: numbers as numbers, dates as
YYYY-MM-DDtext, timestamps asYYYY-MM-DD HH:MM:SS.
Reshaping rows in code#
When the SQL cannot say it, a query can reshape its rows with reconstruct. It runs on the server after the rows are cut to the date filter’s range, and the components then read what it returns:
queries: {
regions: query(regions, {
reconstruct: (rows: Region[]) =>
rows.map((r) => ({ region: r.region, perOrder: r.sales / r.orders })),
}),
},Here the components may name region and perOrder, and no longer sales. Prefer SQL when it can do the job: the dialog shows the SQL, not the code.
$from, $to and the date filter#
A query may use $from and $to, plus the param of any input the page declares. They are sent to the database as parameters, never pasted into the SQL text.
- Every query runs once, over a wide window.
$fromis the first day of the month 23 months back,$tois today. The result is cached for ten minutes. - The rows are cut to the dates you pick, by the query’s grain.
query<Month>(monthly, { grain: "month" }): each row needs amonthcolumn (2026-03). With"week", aweekcolumn (2026-W14). With"all", the default, nothing is cut. - So changing the dates runs no query. It is instant. A report opens on the first of the month a year ago through today.
A chart that should follow the date filter returns a week or month column and its query sets grain. With every query on "all" the filter changes nothing, so a report with no dates sets filterBar: false.
Dialects#
| Postgres, Redshift | SQLite and the data files | MySQL | BigQuery | SQL Server | |
|---|---|---|---|---|---|
| Month key | to_char(d, 'YYYY-MM') |
strftime('%Y-%m', d) |
date_format(d, '%Y-%m') |
format_date('%Y-%m', d) |
format(d, 'yyyy-MM') |
| ISO week key | to_char(d, 'IYYY-"W"IW') |
strftime('%Y-W%W', d) (weeks from Monday, not ISO) |
date_format(d, '%x-W%v') |
format_date('%G-W%V', d) |
concat(year(dateadd(day, 26 - datepart(iso_week, d), d)), '-W', right(concat('0', datepart(iso_week, d)), 2)) |
| A date parameter | cast($from as date) |
date($from) |
cast($from as date) |
date($from) |
cast($from as date) |
| First rows | limit 10 |
limit 10 |
limit 10 |
limit 10 |
select top 10 |
| True and false | true, false |
1, 0 |
true, false |
true, false |
1, 0 |
$from::date runs only on Postgres and Redshift. For a yes/no column a component reads, return 1 and 0: they work on all nine.
Components#
components is a function of k, the kit’s components typed by your queries, and returns the page’s components top to bottom: tiles, charts, tables, layout and inputs. Each reads one series by name, and the columns it names.
components: (k) => [
<k.Kpis
key="totals"
series="totals"
tiles={[
{ label: "Sales", value: "sales" },
{ label: "Orders", value: "orders" },
]}
/>,
<k.CategoryBars key="bars" series="regions" x="region" value="sales" title="Sales by region" />,
],Charts take half a row, so two sit side by side. Tiles, tables and sections take the whole row. Every component is on All components, with its props and an example.
A few props may read a value from the results instead of a fixed text: title, description, y, target, tooltip, reference, columns, groups, lines and segmentParams. Give them a function of the data: title={(d) => String(d.totals[0].label)} is the label column of the first row of totals.
The “(i)” dialog#
The “(i)” button in the report’s header opens its dialog: page.info, a function that returns its content. Every explanation of a report lives here, never on the page.
Sales
Sales per region.
Data as of 8 Oct 2026, 09:00
The measures
- Sales the order total after discounts, before tax and shipping.
import { H2, Info, Rule, Rules, Sql, Technical } from "~/components/report-kit/info"
// in defineReport({ … })
page: {
info: () => (
<>
<Info>
<H2>The measures</H2>
<Rules>
<Rule id="sales" title="Sales">
the order total after discounts, before tax and shipping.
</Rule>
</Rules>
</Info>
<Technical>
<H2>The SQL</H2>
<Sql query="regions" />
</Technical>
</>
),
},All of these come from ~/components/report-kit/info:
| Component | Does |
|---|---|
Info |
The Info tab: what the figures mean, for the reader |
Technical |
The Technical tab: how they are made |
Panel |
A further tab with its own label, such as worked examples |
H2, P |
A heading and a paragraph on the dialog’s scale |
Rules, Rule, Lead |
Definitions with an id. A component’s own “(i)” picks them by id, so a rule is written once |
Sql |
Shows one of the report’s queries |
Value |
Prints one figure from the report’s data, such as how many rows were left out |
Definition |
Quotes a named metric’s one-line definition |
The dialog ends with a “Data as of” line: when the report’s rows were read.
A component’s own “(i)”#
Each component can have its own “(i)” too, with info:
info={{ what: "Sales per month.", rules: ["sales"], sql: ["monthly"] }}what is one line and required; rules picks rules from the dialog by id; sql lists query names. A <Rules as="table"> group lists the source tables, and a component’s “(i)” names the ones its SQL reads.
The checks#
Claude runs npm run lint after every change to a report. It checks three things:
| Check | Fails when |
|---|---|
| The code rules | A page imports server-only code |
| The report folders | A folder holds a file other than report.tsx and .sql files, has no report.tsx, or a .sql file lacks its -- title line |
| The types | A component reads a series that is not one of the queries, or a column its rows do not have; a prop is missing, unknown or of the wrong type |
A query that fails on the database shows its error on the page. To fix one, ask: “The Sales report fails the check. Fix it.”