Silvi

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

Oct 1, 2025 – Oct 8, 2026

Sales per month

Dec 2025Feb 2026Apr 2026Jun 2026Aug 2026Oct 20260100k200k300k400k500k

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 select or with. 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-DD text, timestamps as YYYY-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:

report.tsx
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.

  1. Every query runs once, over a wide window. $from is the first day of the month 23 months back, $to is today. The result is cached for ten minutes.
  2. The rows are cut to the dates you pick, by the query’s grain. query<Month>(monthly, { grain: "month" }): each row needs a month column (2026-03). With "week", a week column (2026-W14). With "all", the default, nothing is cut.
  3. 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.

Dates
Every tile, chart and table follows the range.
4 weeks13 weeksYear

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.

report.tsx
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.

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.”