Skip to content

Dashboard & Widget Recipes

Dashboards in Finzytrack are defined using JSON recipe files. Each dashboard defines a grid layout and the widgets it contains inline — there are no separate, standalone widget files.

A widget is the fundamental building block — a KPI card, chart, table, pivot table, or budget-progress list. Each widget is a small pipeline of steps feeding a visualization. A step is one of:

  • query — a read-only SQL query against your ledger data (the common case). You can freely write any query that makes sense for your widget.
  • compute — a server-side function that returns computed values (for example budget_for_range, which supplies budget numbers). There is a fixed catalog of available compute functions.
  • transform — a client-side function that reshapes or combines the outputs of earlier steps (sort, limit, pivot, budget-vs-actual, and so on). There is a fixed catalog of available transforms.

The widget names an output step whose result is visualized. Most widgets are simply one query step feeding the visualization; multi-source widgets (such as budget vs actual) combine a query step and a compute step in a transform. Widgets can have interactive parameters (dropdowns, date and number inputs) that flow into steps.

A dashboard arranges its widgets in a grid layout and can define shared parameters that cascade to every widget. It can also define shared steps that are computed once and fed to multiple widgets.

Recipe files live in the config/recipes/dashboards/ directory:

config/recipes/
└── dashboards/
├── financial-overview.json
├── year-summary.json
├── month-summary.json
└── widget-gallery.json # Reference: one example per widget type

Any *.json file under dashboards/ is automatically loaded. There is no index file to keep in sync: drop a file in, refresh the app, and it appears. Move or delete a file and it goes away. Files that fail to parse or validate are reported in the notification panel against their path so you can fix them.


A dashboard recipe is a JSON file with the following top-level structure:

{
"schemaVersion": 2,
"id": "my-dashboard",
"title": "My Dashboard",
"description": "Optional description shown in the dashboard picker",
"parameters": [],
"steps": [],
"layout": {
"columns": 12,
"gap": "1.5rem",
"rowHeight": "140px",
"widgets": []
},
"widgets": []
}
FieldTypeDescription
schemaVersionnumberRecipe format version. Always 2.
idstringUnique identifier. Lowercase letters, numbers, and hyphens only (e.g., my-dashboard).
titlestringDisplay title shown in the dashboard picker and header.
layoutobjectGrid layout configuration (see Layout).
widgetsarrayInline widget definitions, non-empty (see Widget Structure).
FieldTypeDescription
descriptionstringOne-line description shown in the dashboard picker.
parametersarrayDashboard-level parameters shared by all widgets (see Parameters).
stepsarrayDashboard shared steps — computed once and fed to multiple widgets (see Shared steps).

Every widget a dashboard shows is defined inline in its widgets array. There are no standalone widget files and no by-ID lookup across files: each layout.widgets[].widgetId must match the id of a widget defined in this dashboard’s own widgets array. To reuse a widget across dashboards, copy its definition (cheap) — or, to share the expensive part of a computation, use a dashboard shared step.


Dashboards use CSS Grid for layout. The layout object configures the grid and places widgets within it.

"layout": {
"columns": 12,
"gap": "1.5rem",
"rowHeight": "140px",
"widgets": [
{ "widgetId": "net-worth", "gridArea": "1 / 1 / 2 / 5" },
{ "widgetId": "total-assets", "gridArea": "1 / 5 / 2 / 9" },
{ "widgetId": "total-liabilities", "gridArea": "1 / 9 / 2 / 13" },
{ "widgetId": "assets-pie", "gridArea": "2 / 1 / 5 / 7" },
{ "widgetId": "liabilities-pie", "gridArea": "2 / 7 / 5 / 13" }
]
}
PropertyTypeDescription
columnsnumberNumber of grid columns. Use 12 for multi-widget layouts, 6 for simpler ones.
gapstringCSS gap between widgets. Default: "1.5rem".
rowHeightstringHeight of each grid row. Use "140px" for KPI-heavy layouts, "200px" for chart-heavy ones.
widgetsarrayWidget placement definitions (see below).

Each entry in layout.widgets places one widget on the grid:

PropertyTypeDescription
widgetIdstringMust match an id in the widgets array.
gridAreastringCSS grid-area: "row-start / col-start / row-end / col-end" (1-based).

Rules:

  • Every widgetId must have a matching widget id in the widgets array.
  • Column values must not exceed columns + 1.
  • Row and column indices are 1-based.

Three KPIs across the top:

{ "widgetId": "kpi-1", "gridArea": "1 / 1 / 2 / 5" },
{ "widgetId": "kpi-2", "gridArea": "1 / 5 / 2 / 9" },
{ "widgetId": "kpi-3", "gridArea": "1 / 9 / 2 / 13" }

Four KPIs across the top:

{ "widgetId": "kpi-1", "gridArea": "1 / 1 / 2 / 4" },
{ "widgetId": "kpi-2", "gridArea": "1 / 4 / 2 / 7" },
{ "widgetId": "kpi-3", "gridArea": "1 / 7 / 2 / 10" },
{ "widgetId": "kpi-4", "gridArea": "1 / 10 / 2 / 13" }

Full-width chart (3 rows tall, below KPIs):

{ "widgetId": "chart", "gridArea": "2 / 1 / 5 / 13" }

Two half-width charts side by side:

{ "widgetId": "chart-left", "gridArea": "2 / 1 / 5 / 7" },
{ "widgetId": "chart-right", "gridArea": "2 / 7 / 5 / 13" }

Single-widget dashboard (use columns: 6):

{ "widgetId": "main-chart", "gridArea": "1 / 1 / 5 / 7" }

Each widget is defined inline within the dashboard’s widgets array. A widget is a pipeline of named steps (steps) plus an output pointer naming the step whose result is visualized.

The simplest widget is one query step feeding the visualization. Here a transform step (firstRow) reduces the rows to a single one for a KPI:

{
"id": "total-income",
"title": "Total Income",
"description": "Sum of all income for the selected year",
"helpText": "Income amounts are shown as positive values",
"parameters": [],
"steps": [
{
"id": "rows",
"kind": "query",
"query": "SELECT currency, SUM(CAST(amount AS REAL)) * -1 AS amount FROM postings WHERE account_type = 'Income' AND year = :year GROUP BY currency HAVING amount != 0"
},
{ "id": "out", "kind": "transform", "fn": "firstRow", "inputs": ["{{steps.rows}}"] }
],
"output": "out",
"visualization": { "type": "kpi", "icon": "", "iconColor": "green" }
}

A widget that needs no transformation just points output at its query step directly:

{
"id": "assets-pie",
"title": "Assets Breakdown",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT account AS name, ROUND(SUM(CAST(amount AS REAL)), 2) AS value FROM postings WHERE account_type = 'Assets' GROUP BY account HAVING value > 0" }
],
"output": "rows",
"visualization": { "type": "chart", "chartType": "pie", "options": { /* ... */ } }
}
FieldTypeDescription
idstringUnique identifier within the dashboard. Lowercase letters, numbers, hyphens.
titlestringDisplay title shown in the widget header.
stepsarrayThe widget’s data pipeline — a non-empty array of step objects (see Steps).
outputstringThe id of the step whose result feeds the visualization. Must name a step in steps.
visualizationobjectHow to display the output (see Visualizations).
FieldTypeDescription
descriptionstringDescription shown below the title.
helpTextstringTooltip text shown when hovering the info icon.
parametersarrayWidget-level parameters (see Parameters).

A widget’s steps array is a small directed graph: each step produces a value, and later steps consume earlier steps’ values by reference. Array order is just for readability — the app runs the steps in dependency order (and independent steps concurrently). There are three kinds:

kindRunsPurpose
queryserver (SQLite mirror)A leaf data source: a read-only SQL query.
computeserverA vetted function that returns computed values (e.g. budget numbers).
transformbrowserReshapes or combines the outputs of earlier steps.

Every step has a unique, lowercase-hyphen id. A step’s output is referenced elsewhere as {{steps.<id>}} (or {{dashboard.steps.<id>}} for a shared step).

{ "id": "actuals", "kind": "query", "query": "SELECT account, SUM(CAST(amount AS REAL)) AS spent FROM postings WHERE year = :year GROUP BY account", "engine": "sqlite" }
FieldDescription
queryThe SQL (or BQL) query. Uses :paramName placeholders for parameters.
engineOptional engine: "sqlite" (default) or "beanquery". See Querying Data.

A query step is a leaf — it reads the ledger mirror and cannot read another step’s rows ({{...}} references are not allowed inside query). To combine a SQL result with anything else, do it in a transform. See SQL queries for the rules.

{ "id": "budgets", "kind": "compute", "fn": "budget_for_range", "args": { "from": "{{params.from}}", "to": "{{params.to}}", "currency": "{{params.currency}}" } }
FieldDescription
fnName of a server-side compute function. The catalog is fixed — you select from it.
argsObject of small scalar arguments. Values may be literals or {{params.x}} / {{steps.x}} templates.

Compute functions do calculations SQL can’t express — budget normalization, projections — and return JSON. The first one is budget_for_range (see Budgets). When the AI assistant is configured it discovers the catalog with its get_compute_functions tool; you can’t invent new function names.

{ "id": "variance", "kind": "transform", "fn": "joinBudgetActual", "inputs": ["{{steps.budgets}}", "{{steps.actuals}}"], "config": { "totalAccount": "Expenses:Insurance" } }
FieldDescription
fnName of a transform from the catalog (fixed).
inputsOrdered {{steps.<id>}} / {{dashboard.steps.<id>}} references to the step outputs this transform consumes.
configOptional transform-specific configuration; {{...}} templates inside it are resolved.

Transforms run in the browser over already-computed step outputs. Unlike query steps, a transform can take multiple inputs — that’s how a widget combines a SQL result with a compute result (for example budget vs actual). Like compute functions, the transform names are a fixed catalog — you select from it and can’t invent new ones (see the full catalog below).

Three reference scopes are available in args, inputs, and config via {{...}}:

  • {{params.<name>}} — a resolved parameter value.
  • {{steps.<id>}} — the output of another step in this widget.
  • {{dashboard.steps.<id>}} — the output of a dashboard shared step.

A string that is exactly one token ("{{steps.actuals}}") resolves to the actual value (object/array). A token inside a larger string resolves to its text. query steps are the exception — their query uses only :name parameter placeholders and never {{...}}.


Parameters add interactive controls (dropdowns, number inputs) to dashboards and widgets. Parameter values are injected into SQL queries as :paramName placeholders.

{
"name": "year",
"label": "Year",
"type": "select",
"default": { "$gen": "currentYear" },
"optionsFrom": "years"
}
FieldTypeDescription
namestringRequired. Identifier used as :name in queries.
labelstringRequired. Display label for the control.
typestringRequired. One of: "select", "number", "date".
defaultanyDefault value. Can be a literal or a $gen generator.
optionsarrayFor select type: array of { "value": ..., "label": "..." } objects. Can be a $gen generator.
optionsFromstringFor select type: dynamic option source, populated from the user’s ledger. "currencies" (currencies only — see Commodities and Currencies), "holdings" (non-currency commodities only, i.e. investment holdings like VOO, VTI), "commodities" (every commodity, currencies and holdings), "years" (years present in the data), "accounts" (all accounts), "expenseAccounts" / "incomeAccounts" (only that type), "budgetTotals" (accounts that carry a budget and have a budgeted descendant — valid top-down “total” accounts for a zero-based view, including quoted roots like Expenses). For the account sources, each option’s value is the full account path and its label is the path below the type root (e.g. Expenses:Insurance:HealthInsurance:Health).
minnumberFor number type: minimum value.
maxnumberFor number type: maximum value.
hiddenbooleanWhen true, the parameter is functional (its default applies, it can be set by a select action or the URL, and steps read it) but renders no control in the parameter bar. Use for a parameter driven only by click-to-select.
showWhenobjectConditional visibility: { "param": "<name>", "equals": <value> } — the control shows only while another parameter’s current value equals equals. The parameter stays functional when hidden this way (its default/last value still feeds steps). Use to reveal a control based on a toggle (e.g. a date shown only when a checkbox is on).
minParam / maxParamstringFor a date (or number) control: bind the input’s minimum/maximum to another parameter’s current value. Reactive — e.g. a “from” date with "maxParam": "asOf" can’t be set past the “as of” date.

Select — dropdown menu:

{
"name": "year",
"label": "Year",
"type": "select",
"default": { "$gen": "currentYear" },
"optionsFrom": "years"
}

Number — numeric input with constraints:

{
"name": "limit",
"label": "Show Top",
"type": "number",
"default": 10,
"min": 5,
"max": 50
}

Date — date picker:

{
"name": "startDate",
"label": "Start Date",
"type": "date",
"default": { "$gen": "startOfYear" }
}

Boolean — checkbox. The value is the string "true" or "false"; default "false". Read it in a transform’s config ("{{params.flag}}") or pair it with a showWhen on another control:

{
"name": "startFresh",
"label": "Start fresh",
"type": "boolean",
"default": "false"
}
  • Dashboard-level parameters are defined in the dashboard’s parameters array and cascade to all widgets. They appear in the dashboard header.
  • Widget-level parameters are defined in each widget’s parameters array. They appear in the widget header.
  • If a widget defines a parameter with the same name as a dashboard parameter, the dashboard value takes precedence.
  • Parameters that the dashboard already provides are hidden from the widget header (no duplicate controls).

Parameter selections — both dashboard-level and widget-level — are saved to the browser’s local storage so they survive across app launches. Dashboard-level selections are also reflected in the URL, which makes a particular view bookmarkable and shareable.

When a parameter’s default is a no-argument generator reference (for example { "$gen": "currentMonth" } or { "$gen": "defaultCurrency" }), the dropdown surfaces that generator as a sticky, templated option at the top of the list, rendered in italics. The label combines the generator’s display name and the value it currently resolves to — for example “Current Month (May)” or “Default Currency (USD)”.

Picking the templated option means “always evaluate this generator on load.” On the next dashboard load it produces the value that is current then — March in March, April in April. Picking a specific literal value (for example the month “May”) pins that value until the user changes it. To go back to the templated behavior after pinning, the user re-selects the italicized templated option.

This lets the same parameter satisfy two different intents — the dashboard author declares a sensible default behavior, and the user can override it (sticky) or stay with it (templated). It also means recipe authors don’t need to choose between “this should always reflect the current month” and “the user’s pick should stick” — both are reachable from a single { "$gen": "currentMonth" } default.

Generators that take config arguments (for example { "$gen": "startOfMonth", "offset": -1 }) are resolved at load time and are not exposed as templated options — only no-argument generator defaults are templatable.

Reference parameters in SQL using :paramName:

SELECT account, SUM(CAST(amount AS REAL)) AS total
FROM postings
WHERE account_type = 'Expenses'
AND year = :year
AND currency = :currency
GROUP BY account
ORDER BY total DESC
LIMIT :limit

Generators produce dynamic values at load time — default parameter values, option lists, and dates that stay current. Use the { "$gen": "generatorName" } syntax.

Generators can appear anywhere in the recipe JSON. Any object with a "$gen" key is replaced with the generator’s output when the recipe is loaded.

GeneratorOutputUsage
currentYearCurrent year as a number{ "$gen": "currentYear" }
currentMonthCurrent month (1-12){ "$gen": "currentMonth" }
defaultCurrencyUser’s default currency string{ "$gen": "defaultCurrency" }
todayToday’s date as YYYY-MM-DD{ "$gen": "today" }
GeneratorArgsOutputUsage
startOfMonthoffset (optional, default 0)First day of month{ "$gen": "startOfMonth" }
endOfMonthoffset (optional, default 0)Last day of month{ "$gen": "endOfMonth" }
startOfYearoffset (optional, default 0)First day of year{ "$gen": "startOfYear" }
endOfYearoffset (optional, default 0)Last day of year{ "$gen": "endOfYear" }

The offset argument shifts relative to the current date. For month generators, -1 means the previous month, 1 means the next month. For year generators, -1 means the previous year, and so on. For example, { "$gen": "startOfMonth", "offset": -1 } returns the first day of last month.

These return arrays of { "value": ..., "label": "..." } objects, suitable for select parameter options.

GeneratorArgsOutput
monthOptionsformat ("long" or "short", default "long")All 12 months. { "$gen": "monthOptions" }
quarterOptionsQ1 through Q4. { "$gen": "quarterOptions" }
accountTypeOptionsAssets, Liabilities, Income, Expenses, Equity.
datePresetsPredefined date range labels: Today, Yesterday, Last 7 Days, Last 30 Days, This Month, Last Month, This Quarter, This Year, Last Year.

A query step’s query field fetches data from your ledger. By default queries use SQL against a SQLite export of your Beancount ledger; you can also use BQL (Beancount Query Language) by setting engine: "beanquery" on the step.

For the complete query reference — table schema, sign conventions, multi-currency rules, SQL syntax, BQL syntax, and common query patterns — see the Querying Data reference.

Here’s a quick summary of what you need to know for writing recipe queries:

  • Queries run against the postings table (SQL) or Beancount entries directly (BQL).
  • Use :paramName placeholders for parameter values in SQL queries (e.g., :year, :currency).
  • Always GROUP BY currency or filter WHERE currency = :currency when summing amounts — never sum across currencies.
  • Use HAVING amount != 0 or HAVING value > 0 to exclude zero-value rows.
  • Income amounts are negative (credit) — use SUM(CAST(amount AS REAL)) * -1 to display as positive.
  • Expense amounts are positive (debit) — use SUM(CAST(amount AS REAL)) directly.
  • For treemap and pie charts, the query must return name and value columns, and must include HAVING value > 0 to exclude negative/zero values (which these chart types cannot display).

A transform step calls one named function from a fixed catalog over the outputs of the steps named in its inputs. The first input is the primary rowset; config shapes behavior. The simplest is none (pass rows through); reducers like firstRow adapt rows for a KPI; and multi-input transforms (the budget family) merge two step outputs.

{ "id": "out", "kind": "transform", "fn": "sortBy", "inputs": ["{{steps.rows}}"], "config": { "field": "total", "order": "desc" } }
fninputsconfigOutput
none[rows]the rows unchanged
firstRow[rows]the first row as a single object (single-value KPIs)
firstValue[rows]the first value of the first row
sortBy[rows]{ field, order? }sorted rows (order: asc/desc)
limit[rows]{ count }the first count rows
pluck[rows]{ field }an array of one field’s values
where[rows]{ field, equals? | notEquals? | in? }the rows matching the predicate (chain firstRow/limit to reduce to one)
appendTotal[rows]{ field?, labelField?, label?, count? }the rows (first count, if given) plus a grand-total row summing field over all input rows (isTotal: true), so a top-N table still totals the full set
groupBy[rows]{ key, sum }one row per distinct key (a field name or array of names), each field in sum totalled exactly; first-seen order. E.g. roll per-account-per-period budgets up to a per-period total
pivot[rows]{ rowField, columnField, valueField, formatColumn?, sortRowsBy? }a cross-tabulation (see below)
joinBudgetActual[budgets, actuals]{ totalAccount?, periodStart?, periodEnd? }budget-vs-actual variance rows
joinByPeriod[budgetsByPeriod, actualsByPeriod][{ period, budget, actual }]
joinBudgetActualByPeriod[budgetsByPeriod, actualsByPeriod]one row per budgeted { account, currency, period, budget, actual, remaining, pctUsed } — budget-vs-actual keyed on the composite (account, period), inclusive-parent. Feed pctUsed into a pivot with colorByValue for an account×month adherence heat-map
budgetSummary[budgets, actuals]one aggregate row { budget, actual, remaining, pctUsed, pctUsedPct } for a ring/KPIs (maximal-named-subtree, so nested budgets aren’t double-counted)
unbudgetedSpending[budgets, actuals]actual rows for accounts not covered by any budget, sorted by spend desc (inclusive-parent aware)
runningSum[rows]{ fields, orderBy }rows plus a cumulative column per field
budgetTree[budgets]{ totalAccount }hierarchical zero-based allocation for a sunburst: recursively decomposes each budgeted node into its maximal budgeted children + a synthetic "<node>:Unbudgeted" remainder leaf (budget − Σ children), emitting flat { account, value } rows the sunburst’s path-tree reassembles. Needs the total node in budgets (fetch it with budget_for_range includeRoots: true)
envelopeRollover[budgetsByPeriod, actualsByPeriod]{ reset?, resetFrom? }per-period { period, currency, budget, actual, available, carryover, overspent, dateFrom, dateTo }. Accumulates from the envelope’s inception (the first month with a real budget) — leading budget-less months are skipped, and spend before inception isn’t counted. Pass reset (truthy) + resetFrom (a date) to start fresh from that month instead (clamped to ≥ inception). dateFrom/dateTo are the period’s month bounds (for a per-point chart click-through)
envelopeBalances[budgetsByPeriod, actualsByPeriod]{ reset?, resetFrom? }one row per budgeted { account, currency, budget, actual, remaining, pctUsed, direction } — each envelope’s inception-aware running balance (remaining = what’s in the envelope now, equal to envelopeRollover’s final carryover). For a multi-envelope overview list; inclusive-parent, each envelope counted from its own inception (or from resetFrom when reset is set)

Required by the pivot visualization. Restructures flat rows (one per account+month) into a cross-tabulation:

PropertyDescription
rowFieldColumn to use as row labels (default: "account").
columnFieldColumn whose values become column headers (default: "year_month").
valueFieldColumn containing the numeric values (default: "amount").
formatColumnHeader format: "monthYear" (“Jan 2026”) or "yearMonth" (“2026-01”).
sortRowsBy"total_desc" (default), "total_asc", "label_asc", "label_desc".

When columnField holds YYYY-MM values, the pivot generates per-column metadata (columnMeta.rawValue, columnMeta.startDate, columnMeta.endDate) available in pivot click-through templates (see Click-Through Links).

joinBudgetActual, joinByPeriod, runningSum, and envelopeRollover pair a query step (actuals) with a compute step (budget_for_range) to build budget dashboards. joinBudgetActual in remainder mode (set config.totalAccount) adds synthetic “Unbudgeted” and “Total” rows for catch-all/zero-based budgeting. To feed one of those roll-up rows to a single-value widget, slice it out with where — e.g. where { field: "kind", equals: "total" } yields the grand-total row for a KPI (see the Budget: Overview dashboard under Dashboard shared steps). See the Budgets guide for the styles and the seeded demo dashboards that use each.


A compute step calls a vetted server-side function that returns values SQL can’t compute directly. The catalog is fixed and currently centers on budgeting:

  • budget_for_range — resolves budgets from custom "budget" directives over a date range (or per calendar month with groupBy: "period"). Returns [{ account, currency, budget }]. Pair it with a query actuals step and a budget transform. from is optional: omit it to start each account at its own inception (its first budget directive) — the natural “from the beginning” for envelope balances, with no empty pre-inception months. Bare-root total budgets (a quoted account with no :, e.g. "Expenses") are excluded by default — they’re top-down totals, not per-account budgets — so they never double-count or appear as an inclusive peer in bottom-up views; pass includeRoots: true (the zero-based/sunburst view does) to include them.
{ "id": "budgets", "kind": "compute", "fn": "budget_for_range",
"args": { "from": "2026-01-01", "to": "2026-12-31", "currency": "USD" } }

args are small scalars (dates, a currency, an account). Bulk data is read by the function on the server — don’t pass large rowsets into args. When the AI assistant is configured it lists the available functions with get_compute_functions.


A dashboard may declare a top-level steps array (the same step kinds, but no output). These run once per dashboard render and their outputs are available to every widget via {{dashboard.steps.<id>}}. Use them to compute an expensive value once and feed several widgets — instead of every widget repeating the same query, resolve, and join.

The seeded Budget: Overview dashboard is the worked example: it resolves budgets, queries actuals, and joins them into a variance table once at the dashboard level, then six widgets (three KPIs, a breakdown table, a chart, and a reconciliation) each render a slice of that single result.

{
"schemaVersion": 2,
"id": "budget-overview",
"title": "Budget: Overview",
"parameters": [ /* monthStart, monthEnd, currency */ ],
"steps": [
{ "id": "actuals", "kind": "query",
"query": "SELECT account, currency, SUM(CAST(amount AS REAL)) AS actual FROM postings WHERE account_type = 'Expenses' AND transaction_date BETWEEN :monthStart AND :monthEnd AND currency = :currency GROUP BY account, currency" },
{ "id": "budgets", "kind": "compute", "fn": "budget_for_range",
"args": { "from": "{{params.monthStart}}", "to": "{{params.monthEnd}}", "currency": "{{params.currency}}" } },
{ "id": "totals", "kind": "transform", "fn": "joinBudgetActual",
"inputs": ["{{steps.budgets}}", "{{steps.actuals}}"], "config": { "totalAccount": "Expenses" } }
],
"layout": { "columns": 12, "widgets": [ /* … */ ] },
"widgets": [
{
"id": "kpi-spent",
"title": "Spent This Month",
"steps": [
{ "id": "row", "kind": "transform", "fn": "where",
"inputs": ["{{dashboard.steps.totals}}"], "config": { "field": "kind", "equals": "total" } }
],
"output": "row",
"visualization": { "type": "kpi", "multiCurrency": true, "amountField": "actual", "currencyField": "currency" }
}
/* … more widgets, each a thin transform over {{dashboard.steps.totals}} … */
]
}

Two things to note in the shared steps themselves:

  • Sibling shared steps reference each other with {{steps.<id>}} (as in the totals transform above), exactly like widget steps. It’s only widgets that reach the shared outputs via {{dashboard.steps.<id>}}.
  • A shared step can be any kind — query, compute, or transform. The expensive one here is budget_for_range (the compute), which now runs once instead of once per widget.

Shared steps are parameterized by dashboard parameters only. If a widget locally overrides a parameter a shared step depends on, it still sees the shared output computed with the dashboard value — the shared step does not re-run per widget. When a widget needs its own parameterization of a computation, give it its own widget step instead.


Anywhere a color is accepted — a series itemStyle.color, a KPI iconColor, budget-progress colors, a chart’s top-level options.color array — you can use a {{theme.*}} token instead of a raw hex value. Tokens draw from the active dashboard theme, so colors stay consistent across every dashboard and can be recolored from one file. Prefer tokens; a raw hex/CSS color still works as a per-value override.

TokenUse for
{{theme.brand}}The accent/focus color — a single-series chart, a primary/magnitude KPI icon
{{theme.baseline}}The muted “target/budget” a value is measured against
{{theme.valence.good | warn | bad | complete}}Favorability: under / approaching / over / exactly-on-budget. The only place green/amber/red should appear
{{theme.series.<name>}}A named recurring series: budget, actual, income, expense, savings
{{theme.categorical}} / {{theme.categorical.N}}Category identity (pie/treemap) — auto-assigned, or a specific 0-based slot

Conventions the runtime applies for you:

  • Pie and treemap charts get the theme’s categorical palette automatically — don’t set colors on them unless you want to override. Treemaps and sunbursts also color by account family (hue) and depth (lightness), so a category’s tiles read as a group.
  • Budget-progress bars and the pivot heat-map are colored by favorability from the theme — no color config needed.
  • KPI icons: use {{theme.brand}} for a magnitude (Spent, Total, Assets); set colorBySign: true for a signed value (Remaining, Net change) so it goes green/red by its sign.

The token set above is fixed — don’t invent new tokens. For the full theme file and how to edit it, see the Dashboard Colors & Themes reference.


The visualization object in each widget determines how query results are displayed.

Displays a single value prominently, with an optional icon and color.

PropertyTypeDescription
typestringRequired. Must be "kpi".
iconstringSingle character or emoji displayed in a colored circle (e.g., "$", "↑", "↓", "#").
iconColorstringIcon background color. Prefer a theme token — "{{theme.brand}}" for a magnitude, "{{theme.series.income}}" to match a series (see Colors). A hex/CSS color or a legacy name ("blue"/"green"/"red"/"purple"/"amber") also works. For a signed figure use colorBySign instead.
formatstringValue format (see Formats).
valueFieldstringColumn name to display as the KPI value (default: "value").
multiCurrencybooleanIf true, displays one amount per currency, stacked vertically. The query must return one row per currency with currency and amount columns (or the columns specified by currencyField and amountField).
amountFieldstringColumn name for amounts when multiCurrency is true (default: "amount").
currencyFieldstringColumn name for currencies when multiCurrency is true (default: "currency").
colorBySignbooleanColour both the value text and the icon by sign, from the theme’s favorability colors — good when > 0, on-the-mark when exactly 0, bad when negative — overriding iconColor while it’s on. Use for figures where negative is bad, e.g. a Remaining / over-budget KPI. (A single number has no “approaching” state, so there’s no amber tier here — unlike the budget-progress bars.)
showTrendbooleanShow a trend indicator below the value (e.g., “+5.2% vs prior”). Requires trendField.
trendFieldstringColumn name containing the trend percentage. Positive values show as green (up), negative as red (down).
clickLinkobjectMakes the KPI value clickable, navigating to a filtered view. See Click-Through Links.

The query step returns one row with a numeric column. A firstRow transform reduces it to a single object, and valueField extracts the value.

{
"id": "transaction-count",
"title": "Transaction Count",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT COUNT(DISTINCT transaction_id) AS value FROM postings WHERE year = :year" },
{ "id": "out", "kind": "transform", "fn": "firstRow", "inputs": ["{{steps.rows}}"] }
],
"output": "out",
"visualization": {
"type": "kpi",
"icon": "#",
"iconColor": "purple",
"valueField": "value",
"format": "number"
}
}

The query returns one row per currency. Each currency is displayed stacked vertically with the amount formatted in that currency.

{
"id": "total-income",
"title": "Total Income",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT currency, SUM(CAST(amount AS REAL)) * -1 AS amount FROM postings WHERE account_type = 'Income' AND year = :year GROUP BY currency HAVING amount != 0" }
],
"output": "rows",
"visualization": {
"type": "kpi",
"icon": "",
"iconColor": "green",
"multiCurrency": true
}
}

If your query uses different column names than currency and amount, specify them with currencyField and amountField:

{
"id": "assets-by-currency",
"title": "Total Assets",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT currency AS cur, SUM(CAST(amount AS REAL)) AS total FROM postings p JOIN commodities c ON p.currency = c.code WHERE c.is_currency = 1 AND account_type = 'Assets' GROUP BY currency HAVING total != 0" }
],
"output": "rows",
"visualization": {
"type": "kpi",
"icon": "",
"iconColor": "green",
"multiCurrency": true,
"amountField": "total",
"currencyField": "cur"
}
}

The query includes a trend column (typically a percentage change vs a prior period). The trend is shown below the main value.

{
"id": "monthly-expenses",
"title": "This Month's Expenses",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT SUM(CAST(amount AS REAL)) AS value, ROUND((SUM(CAST(amount AS REAL)) - prev.total) * 100.0 / prev.total, 1) AS trend FROM postings, (SELECT SUM(CAST(amount AS REAL)) AS total FROM postings WHERE account_type = 'Expenses' AND year_month = strftime('%Y-%m', date('now', '-1 month'))) prev WHERE account_type = 'Expenses' AND year_month = strftime('%Y-%m', 'now')" },
{ "id": "out", "kind": "transform", "fn": "firstRow", "inputs": ["{{steps.rows}}"] }
],
"output": "out",
"visualization": {
"type": "kpi",
"icon": "",
"iconColor": "red",
"valueField": "value",
"format": "currency",
"showTrend": true,
"trendField": "trend"
}
}

Clicking the KPI navigates to the Transactions view with filters applied. The clickLink object is not a SQL query — it defines navigation parameters. See Click-Through Links for the full reference.

Values in {{...}} are template variables that get replaced at click time. For KPI widgets, {{dateFrom}} and {{dateTo}} are special shorthand variables automatically computed from the widget’s year and month parameters — for example, if year is 2026, {{dateFrom}} resolves to "2026-01-01" and {{dateTo}} to "2026-12-31". If both year and month are present, the range narrows to that specific month. You can also use {{parameters.paramName}} to reference any parameter value directly.

{
"id": "total-expenses",
"title": "Total Expenses",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT currency, SUM(CAST(amount AS REAL)) AS amount FROM postings WHERE account_type = 'Expenses' AND year = :year GROUP BY currency HAVING amount != 0" }
],
"output": "rows",
"visualization": {
"type": "kpi",
"icon": "",
"iconColor": "red",
"multiCurrency": true,
"clickLink": {
"name": "transactions",
"query": {
"accountContains": "Expenses",
"dateFrom": "{{dateFrom}}",
"dateTo": "{{dateTo}}"
}
}
}
}

Renders charts using Apache ECharts. Supported chart types: bar, line, pie, area, scatter, treemap.

{
"type": "chart",
"chartType": "bar",
"seriesLabelFormat": "compact",
"yAxisLabelFormat": "compact",
"xAxisLabelFormat": "accountName",
"options": { ... },
"clickLink": { ... },
"seriesClickLinks": { ... }
}
PropertyTypeDescription
typestringRequired. Must be "chart".
chartTypestringRequired. One of: "bar", "line", "pie", "area", "scatter", "treemap".
optionsobjectECharts configuration (grid, axes, series, legend, tooltip).
seriesLabelFormatstringFormat for data point labels (see Formats).
yAxisLabelFormatstringFormat for Y-axis tick labels.
xAxisLabelFormatstringFormat for X-axis tick labels.
clickLinkobjectDefault click-through link for all series (see Click-Through Links).
seriesClickLinksobjectPer-series click-through link overrides (see Click-Through Links).

The options object uses standard Apache ECharts configuration. Properties like xAxis, yAxis, series, grid, legend, and tooltip follow the ECharts API directly — refer to the ECharts documentation for the full set of available options. The app processes options lightly before passing it to ECharts: it injects your query results as the chart’s dataset, applies dark mode styling to text and grid lines, and applies any label formats you specified (e.g., seriesLabelFormat). Everything else is standard ECharts.

The app takes your query results and injects them into ECharts as a dataset.source — an array of row objects. For example, if your query returns:

[
{ "month_label": "Jan", "expenses": 1200, "income": 3000 },
{ "month_label": "Feb", "expenses": 900, "income": 3100 }
]

You then use the standard ECharts encode property in your series to map query column names to chart dimensions:

"series": [
{
"name": "Expenses",
"type": "bar",
"encode": { "x": "month_label", "y": "expenses" }
},
{
"name": "Income",
"type": "bar",
"encode": { "x": "month_label", "y": "income" }
}
]

ECharts matches the encode field names against the keys in the dataset objects. This is how you control which query columns appear on which axes and series — you write the SQL column names (or aliases) and reference them in encode.

Vertical bars (category on X, value on Y):

{
"type": "chart",
"chartType": "bar",
"seriesLabelFormat": "compact",
"yAxisLabelFormat": "compact",
"options": {
"legend": { "data": ["Expenses", "Income"], "top": 0, "left": "left", "itemGap": 20 },
"grid": { "top": 40, "bottom": 40, "left": 50, "right": 20 },
"xAxis": { "type": "category" },
"yAxis": { "type": "value" },
"series": [
{
"name": "Expenses",
"type": "bar",
"encode": { "x": "month_label", "y": "expenses" },
"itemStyle": { "color": "#E8A951" },
"label": { "show": true, "position": "top", "fontSize": 10 }
},
{
"name": "Income",
"type": "bar",
"encode": { "x": "month_label", "y": "income" },
"itemStyle": { "color": "#7DD3C0" },
"label": { "show": true, "position": "top", "fontSize": 10 }
}
]
}
}

Horizontal bars (category on Y, value on X):

{
"type": "chart",
"chartType": "bar",
"seriesLabelFormat": "currency",
"xAxisLabelFormat": "compact",
"yAxisLabelFormat": "accountName",
"options": {
"grid": { "left": 120, "right": 24, "top": 16, "bottom": 16 },
"xAxis": { "type": "value" },
"yAxis": { "type": "category", "axisLabel": { "width": 100, "overflow": "truncate" } },
"series": [
{
"name": "Amount",
"type": "bar",
"encode": { "x": "total", "y": "account" },
"itemStyle": { "color": "#6366f1" },
"label": { "show": true, "position": "right" }
}
]
}
}

Key concepts:

  • encode maps query column names to chart dimensions: { "x": "column_name", "y": "column_name" }.
  • Multiple series entries create grouped bars. Use "barGap": "10%" to control spacing.
  • The app injects query results as a dataset.source — you don’t need to provide data in the options.

Same structure as bar chart, but with "chartType": "line" and series "type": "line":

{
"type": "chart",
"chartType": "line",
"options": {
"grid": { "top": 40, "bottom": 40, "left": 50, "right": 20 },
"xAxis": { "type": "category" },
"yAxis": { "type": "value" },
"series": [
{
"name": "Balance",
"type": "line",
"encode": { "x": "month_label", "y": "balance" },
"smooth": true,
"itemStyle": { "color": "#6366f1" }
}
]
}
}

Add "smooth": true for smooth curves. Add "areaStyle": {} to fill the area under the line.

Use "chartType": "area" with series "type": "line" and "areaStyle": {}:

{
"type": "chart",
"chartType": "area",
"options": {
"xAxis": { "type": "category" },
"yAxis": { "type": "value" },
"series": [
{
"name": "Net Worth",
"type": "line",
"encode": { "x": "month_label", "y": "net_worth" },
"areaStyle": { "opacity": 0.3 },
"itemStyle": { "color": "#6366f1" }
}
]
}
}
{
"type": "chart",
"chartType": "pie",
"options": {
"tooltip": { "trigger": "item" },
"series": [
{
"type": "pie",
"radius": ["30%", "60%"],
"encode": { "itemName": "name", "value": "value" },
"label": { "show": true, "formatter": "{b}: {d}%" }
}
]
}
}
  • Query must return name and value columns.
  • Pie charts cannot display negative or zero values. Your SQL query must include HAVING value > 0 to filter them out.
  • radius: ["30%", "60%"] creates a donut chart. Use "50%" for a solid pie.
  • Pie charts have no axes — don’t include xAxis or yAxis.
  • Use "tooltip": { "trigger": "item" } (not "axis").
{
"type": "chart",
"chartType": "scatter",
"options": {
"xAxis": { "type": "value" },
"yAxis": { "type": "value" },
"series": [
{
"type": "scatter",
"encode": { "x": "income", "y": "expenses" },
"itemStyle": { "color": "#6366f1" }
}
]
}
}
{
"type": "chart",
"chartType": "treemap",
"options": {
"tooltip": { "trigger": "item" },
"series": [
{
"type": "treemap",
"roam": false,
"breadcrumb": { "show": false },
"label": { "show": true, "formatter": "{b}" },
"itemStyle": { "borderColor": "#fff", "borderWidth": 2, "gapWidth": 2 },
"levels": [
{
"itemStyle": { "borderColor": "#555", "borderWidth": 2, "gapWidth": 2 }
}
]
}
]
}
}

Ranked stages, larger at top, narrowing down. Useful for budget allocation and savings funnels.

{
"type": "chart",
"chartType": "funnel",
"options": {
"tooltip": { "trigger": "item" },
"legend": { "show": false },
"series": [
{
"type": "funnel",
"sort": "descending",
"label": { "show": true, "position": "inside", "formatter": "{b}" }
}
]
}
}
  • Query must return name and value columns.
  • Like treemap, the runtime injects rows directly into series[0].data — do not use encode.
  • Use HAVING value > 0 (negative stages don’t make sense).

A dial showing a single value against a min/max range. Useful for budget progress, savings rate, etc.

{
"type": "chart",
"chartType": "gauge",
"options": {
"tooltip": { "trigger": "item" },
"series": [
{
"type": "gauge",
"min": 0,
"max": 100,
"detail": { "formatter": "{value}%", "fontSize": 22 }
}
]
}
}
  • Query should return one row with a numeric value column. The runtime uses the first row.
  • Set min and max on the series to define the dial range.

GitHub-contributions style — one cell per day across a date range.

{
"type": "chart",
"chartType": "calendar",
"options": {
"tooltip": { "trigger": "item" },
"legend": { "show": false },
"visualMap": { "min": 0, "max": 5000, "orient": "horizontal", "left": "center", "top": 0 },
"calendar": { "top": 60, "cellSize": ["auto", 14] },
"series": [
{ "type": "heatmap", "coordinateSystem": "calendar" }
]
}
}
  • Query must return date (YYYY-MM-DD) and value columns.
  • The calendar.range is auto-derived from the data’s min/max date when not specified.
  • The runtime auto-injects a tooltip formatter that shows the date plus the formatted value.
  • Always set legend: { show: false } — the global legend swatch clashes with the visualMap colour ramp.

See the gallery’s gallery-calendar widget (in widget-gallery.json) for a complete worked example with a clickLink filtering by date.

Flow diagram between source and target categories with link width proportional to value. Great for showing how money flows from income sources through to expenses.

{
"type": "chart",
"chartType": "sankey",
"options": {
"tooltip": { "trigger": "item", "triggerOn": "mousemove" },
"series": [
{
"type": "sankey",
"lineStyle": { "color": "gradient", "curveness": 0.5, "opacity": 0.5 },
"label": { "fontSize": 10 },
"emphasis": { "focus": "adjacency" }
}
]
}
}
  • Query must return source, target, and value columns. The runtime derives unique nodes and uses rows as links.
  • For click-through routing, also emit sourceAccount and targetAccount columns carrying the real account paths (NULL for synthetic intermediates like a “Total Income” node). The runtime attaches a realAccount field to both nodes (rectangles) and links (flows), so a clickLink template using {{data.realAccount}} resolves correctly regardless of which side the user clicks. See the gallery gallery-sankey widget for the canonical SQL pattern.

Multi-dimensional comparison on a single shape. Useful for spending profiles across categories.

{
"type": "chart",
"chartType": "radar",
"options": {
"tooltip": { "trigger": "item" },
"legend": { "show": false },
"series": [
{ "type": "radar", "areaStyle": { "opacity": 0.4 }, "lineStyle": { "width": 2 } }
]
}
}
  • Query returns one row per dimension with category and value columns.
  • The runtime auto-builds the top-level radar.indicator from the categories, scaled to 1.2× the maximum observed value (override via series.indicatorMaxRatio).
  • A recipe-supplied radar: config (e.g. for axis styling) is merged with the auto-derived indicator — recipe wins for everything except the indicator field.
  • No clickLink — ECharts radar emits clicks at the subject level rather than per-axis.

Hierarchical breakdown rendered as concentric rings. The complement to treemap for nested category hierarchies.

{
"type": "chart",
"chartType": "sunburst",
"options": {
"tooltip": { "trigger": "item" },
"series": [
{
"type": "sunburst",
"radius": [0, "90%"],
"label": { "rotate": "tangential", "minAngle": 6, "fontSize": 10 }
}
]
}
}
  • Query returns account (a colon-separated path like "Expenses:Food:Restaurants") and value. The runtime splits the paths and builds the nested tree automatically.
  • Outer rings represent deeper levels in the account hierarchy.
  • No clickLink — ECharts sunburst uses clicks for built-in zoom/drill-down. Adding a click-link would conflict with that interaction.

Displays query results as a simple data table.

{
"type": "table",
"columns": [
{ "key": "account", "label": "Account" },
{ "key": "total", "label": "Total", "align": "right", "format": "currency" },
{ "key": "transaction_count", "label": "Transactions", "align": "right", "format": "number" }
]
}
PropertyTypeDescription
typestringRequired. Must be "table".
columnsarrayRequired. Column definitions (see below).
PropertyTypeDescription
keystringRequired. Query column name to display.
labelstringRequired. Column header text.
alignstringText alignment: "left" (default), "center", "right".
formatstringValue format (see Formats).
linkobjectClick-through link for cell values (see Click-Through Links).

Displays a cross-tabulation with row and column totals. Requires a pivot transform on the widget.

{
"type": "pivot",
"rowHeader": "Account",
"format": "currency",
"showRowTotals": true,
"showColumnTotals": true,
"valueLink": {
"name": "transactions",
"query": {
"accountContains": "{{row.label}}",
"dateFrom": "{{columnMeta.startDate}}",
"dateTo": "{{columnMeta.endDate}}"
}
}
}
PropertyTypeDescription
typestringRequired. Must be "pivot".
rowHeaderstringLabel for the row header column (default: "Account").
formatstringCell value format (see Formats).
showRowTotalsbooleanShow a “Total” column on the right (default: true).
showColumnTotalsbooleanShow a totals row at the bottom (default: true).
colorByValuebooleanTint each cell by its value read as a budget-usage fraction (e.g. pctUsed) — a budget-adherence heat-map. Uses the same green/amber/blue/red scale as budget-progress. Point valueField at a pctUsed-style column and turn totals off.
warnAtnumberWith colorByValue: fraction where a cell turns amber. Default 0.85.
colorsobjectWith colorByValue: override the status colours ({ under, approaching, exact, over } hex), same as budget-progress.
valueLinkobjectClick-through link for cell values (see Click-Through Links).

A complete pivot widget requires both a pivot transform and a pivot visualization:

{
"id": "expenses-pivot",
"title": "Monthly Expenses by Account",
"parameters": [
{
"name": "currency",
"label": "Currency",
"type": "select",
"default": { "$gen": "defaultCurrency" },
"optionsFrom": "currencies"
}
],
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT account, year_month, SUM(CAST(amount AS REAL)) AS amount FROM postings WHERE account_type = 'Expenses' AND year = :year AND currency = :currency GROUP BY account, year_month ORDER BY account, year_month" },
{ "id": "pivoted", "kind": "transform", "fn": "pivot", "inputs": ["{{steps.rows}}"],
"config": { "rowField": "account", "columnField": "year_month", "valueField": "amount", "formatColumn": "monthYear", "sortRowsBy": "total_desc" } }
],
"output": "pivoted",
"visualization": {
"type": "pivot",
"rowHeader": "Account",
"showRowTotals": true,
"showColumnTotals": true,
"valueLink": {
"name": "transactions",
"query": {
"accountContains": "{{row.label}}",
"dateFrom": "{{columnMeta.startDate}}",
"dateTo": "{{columnMeta.endDate}}"
}
}
}
}

A purpose-built budget-vs-actual list — not an ECharts chart, but a dedicated widget type. It shows one row per budgeted account, each with a fill bar (spent vs budget, over-budget in red) and $spent / $budget with the amount remaining. It reads the flat rows a joinBudgetActual transform produces, so a typical widget is a query (actuals) + compute (budget_for_range) + joinBudgetActual feeding this visualization.

{
"type": "budget-progress",
"accountFormat": "accountName2",
"emptyText": "No budgets for this period.",
"link": {
"name": "transactions",
"query": { "accountContains": "{{row.account}}", "dateFrom": "{{parameters.monthStart}}", "dateTo": "{{parameters.monthEnd}}" }
}
}
PropertyTypeDescription
typestringRequired. Must be "budget-progress".
accountFieldstringRow field for the category label (default: "account").
budgetFieldstringRow field for the budget amount (default: "budget").
actualFieldstringRow field for the actual spend (default: "actual").
remainingFieldstringRow field for budget − actual (default: "remaining").
pctUsedFieldstringRow field for the fraction of budget used, e.g. 1.23 = 123% (default: "pctUsed").
currencyFieldstringRow field for the currency code (default: "currency").
directionFieldstringRow field holding "under-good" or "over-good" (expenses vs income). Default "direction"; absent → under-good.
accountFormatstringOptional format for the account label (e.g. "accountName2").
warnAtnumberFraction of budget where a bar turns amber (“approaching”). Default 0.85 (85%).
colorsobjectOverride the status bar colours — { under, approaching, exact, over }. Each defaults to the matching theme valence colour (good/warn/complete/bad), so you normally omit this entirely. To override a status, set its key to a {{theme.*}} token or a raw hex/CSS colour; omitted keys keep the theme default.
linkobjectOptional per-row click-through (see Click-Through Links); templates can use {{row.<field>}} and {{parameters.<name>}}.
emptyTextstringMessage shown when there are no rows.

Bar colours are a status scale — green (under warnAt), amber (approaching), blue (exactly on budget, e.g. a fixed expense paid in full at 100%), red (over budget, strictly > 100%). These come from the active theme’s valence scale automatically, so you normally set no colors at all (none of the seeded budget dashboards do). Each colors key maps to the theme valence token that is its default — override a status by pointing its key at a different token (preferred) or a raw hex/CSS colour:

"colors": {
"under": "{{theme.valence.good}}", // under budget — default
"approaching": "{{theme.valence.warn}}", // ≥ warnAt — default
"exact": "{{theme.valence.complete}}", // exactly 100% — default
"over": "{{theme.valence.bad}}" // > 100% — default
}

A raw hex (e.g. "over": "#ef4444") works too, as a per-value escape hatch. See Colors and the Dashboard Colors & Themes reference.

The defaults match the joinBudgetActual flat output (account, budget, actual, remaining, pctUsed, direction, currency), so a widget usually needs only accountFormat, emptyText, and an optional link. Bar colours are a traffic light on how much of the budget is used — green (comfortable, under 85%), amber (approaching, 85–100%), red (over) — flipped for income (over-good), where reaching the target is good.

A complete widget:

{
"id": "budget-progress",
"title": "Budget vs Actual",
"steps": [
{ "id": "actuals", "kind": "query", "query": "SELECT account, currency, SUM(CAST(amount AS REAL)) AS actual FROM postings WHERE account_type = 'Expenses' AND transaction_date BETWEEN :monthStart AND :monthEnd AND currency = :currency GROUP BY account, currency" },
{ "id": "budgets", "kind": "compute", "fn": "budget_for_range", "args": { "from": "{{params.monthStart}}", "to": "{{params.monthEnd}}", "currency": "{{params.currency}}" } },
{ "id": "variance", "kind": "transform", "fn": "joinBudgetActual", "inputs": ["{{steps.budgets}}", "{{steps.actuals}}"] }
],
"output": "variance",
"visualization": {
"type": "budget-progress",
"accountFormat": "accountName2",
"link": { "name": "transactions", "query": { "accountContains": "{{row.account}}", "dateFrom": "{{parameters.monthStart}}", "dateTo": "{{parameters.monthEnd}}" } }
}
}

Predefined format strings control how numbers are displayed. They can be used in KPI format, chart seriesLabelFormat/yAxisLabelFormat/xAxisLabelFormat, table column format, and pivot format.

FormatOutput ExampleUse For
"currency"$14,200.00 or ₹14,20,000.00Monetary amounts (currency-aware)
"signedCurrency"+$14,200 or -₹500Signed monetary amounts (currency-aware)
"compact"14.2k, 1.5MLarge numbers
"number"14,200Plain numbers with thousand separators
"percent"42%Percentages
"date"Jan 15, 2026ISO dates as readable text
"dateShort"1/15/26Short date format
"accountName"GroceriesLast segment of an account path
"accountName2"Food:GroceriesLast two segments of an account path

The "currency" and "signedCurrency" formats are locale-aware — they use the correct currency symbol and number grouping based on the widget’s currency parameter. For example:

  • A widget with a currency parameter set to "USD" formats as $1,234,567.89 (en-US locale)
  • A widget with a currency parameter set to "INR" formats as ₹12,34,567.89 (en-IN locale)

This works automatically: if your widget (or its parent dashboard) has a parameter named currency, the format functions pick it up and apply the appropriate locale. If no currency parameter exists, the formats default to USD.

Multi-currency KPI widgets (multiCurrency: true) are a special case — they format each row individually using the currency code from that row’s data, so multiple currencies are each displayed with their correct symbol and grouping.


Widgets can be made interactive by adding a click action. A click action is one of two modes:

  • Navigate ({name, query}) — clicking a value goes to the Transactions view with filters pre-applied.
  • Select ({select}) — clicking sets dashboard parameters from the clicked context, driving other widgets on the same dashboard (master-detail drill-down). See Select Action.

Every click-action field — a chart clickLink/seriesClickLinks, a KPI clickLink, a pivot valueLink, a table column link, and a budget-progress link — accepts either mode.

{
"name": "transactions",
"query": {
"accountContains": "Expenses",
"dateFrom": "2026-01-01",
"dateTo": "2026-12-31"
}
}
PropertyTypeDescription
namestringRoute name. Currently only "transactions" is supported.
queryobjectFilter parameters for the Transactions view.

Provide either {name, query} (navigate) or select (below) — not both.

FilterDescription
accountContainsFilter transactions where an account path contains this string.
dateFromStart date (YYYY-MM-DD).
dateToEnd date (YYYY-MM-DD).
payeeContainsFilter by payee name.
narrationContainsFilter by narration text.

Link values can use template variables with {{...}} syntax. The available variables depend on the visualization type:

VariableDescription
{{data.columnName}}Any column from the clicked data row.
{{parameters.paramName}}Current value of a parameter.
VariableDescription
{{parameters.paramName}}Current value of a parameter.
{{dateFrom}}Computed start date from year/month parameters (YYYY-01-01 or YYYY-MM-01).
{{dateTo}}Computed end date from year/month parameters (YYYY-12-31 or last day of month).
VariableDescription
{{row.label}}The row’s label (typically the account name).
{{column}}The column name.
{{value}}The cell value.
{{columnMeta.startDate}}Start date of the column period (for YYYY-MM columns: first day of month).
{{columnMeta.endDate}}End date of the column period (for YYYY-MM columns: last day of month).
{{columnMeta.rawValue}}The raw column key value.
{{parameters.paramName}}Current value of a parameter.
VariableDescription
{{row.columnName}}Any column from the row.
{{value}}The cell value.

For charts with multiple series, you can specify different click-through links for each series:

"seriesClickLinks": {
"Income": {
"name": "transactions",
"query": {
"accountContains": "Income",
"dateFrom": "{{data.dateFrom}}",
"dateTo": "{{data.dateTo}}"
}
},
"Expenses": {
"name": "transactions",
"query": {
"accountContains": "Expenses",
"dateFrom": "{{data.dateFrom}}",
"dateTo": "{{data.dateTo}}"
}
},
"Savings": null
}
  • Keys are the series name values from the options.series array.
  • Set a series to null to disable clicking for that series.
  • If seriesClickLinks is present, it takes priority over clickLink for the matching series.

Instead of navigating away, a click can set dashboard parameters from the clicked row/value/series, re-running the widgets that depend on those parameters. This builds a master-detail dashboard: a list or chart on top acts as a picker for a detail view below.

"link": { "select": { "account": "{{row.account}}" } }
PropertyTypeDescription
selectobjectMap of dashboard-parameter name → template. On click, each parameter is set to its interpolated value.
  • Templates use the same click variables as a navigate link for that widget type (e.g. {{row.<field>}} for a table column or budget-progress row, {{data.<field>}} for a chart series).
  • Only keys that are actual dashboard parameters are applied — a select can’t invent unknown parameters.
  • Setting a parameter re-runs the dashboard shared steps and every widget that reads that parameter — exactly as if the user had changed it in the dropdown. The parameter is persisted and reflected in the URL like any other selection.
  • For a budget-progress list, the row whose selection matches the current parameter values is highlighted as the active (drilled-in) row.

Example — click an account to drive an account-scoped drill-down. A budget-progress overview whose rows each set the account parameter; a chart below reads :account and shows just that account’s trend:

{
"id": "overview",
"output": "ranked",
"visualization": {
"type": "budget-progress",
"accountFormat": "accountName2",
"link": { "select": { "account": "{{row.account}}" } }
}
}

The seeded Budget: Envelopes dashboard (config/recipes/dashboards/budget-envelopes.json) is a complete worked example: its envelope-balances list selects the account parameter, and the KPIs + trend chart below drill into the chosen envelope.


The bundled dashboards under config/recipes/dashboards/ are the living, validated reference — open any of them in Settings → Dashboards to see a full recipe in the current format. The examples below show the shapes you’ll use most.

Three KPI cards over one chart. Each widget is a single query step; the KPIs reduce to one row with a firstRow transform, the chart points output straight at its query step.

{
"schemaVersion": 2,
"id": "overview",
"title": "Overview",
"parameters": [
{ "name": "currency", "label": "Currency", "type": "select",
"default": { "$gen": "defaultCurrency" }, "optionsFrom": "currencies" }
],
"layout": {
"columns": 12, "gap": "1.5rem", "rowHeight": "140px",
"widgets": [
{ "widgetId": "net-worth", "gridArea": "1 / 1 / 2 / 5" },
{ "widgetId": "assets", "gridArea": "1 / 5 / 2 / 9" },
{ "widgetId": "liabilities", "gridArea": "1 / 9 / 2 / 13" },
{ "widgetId": "assets-pie", "gridArea": "2 / 1 / 5 / 13" }
]
},
"widgets": [
{
"id": "net-worth", "title": "Net Worth",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT currency, SUM(CASE WHEN account_type IN ('Assets','Liabilities') THEN CAST(amount AS REAL) ELSE 0 END) AS amount FROM postings WHERE currency = :currency GROUP BY currency HAVING amount != 0" },
{ "id": "out", "kind": "transform", "fn": "firstRow", "inputs": ["{{steps.rows}}"] }
],
"output": "out",
"visualization": { "type": "kpi", "icon": "$", "multiCurrency": true }
},
{
"id": "assets", "title": "Total Assets",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT currency, SUM(CAST(amount AS REAL)) AS amount FROM postings WHERE account_type = 'Assets' AND currency = :currency GROUP BY currency HAVING amount != 0" },
{ "id": "out", "kind": "transform", "fn": "firstRow", "inputs": ["{{steps.rows}}"] }
],
"output": "out",
"visualization": { "type": "kpi", "icon": "", "iconColor": "green", "multiCurrency": true }
},
{
"id": "liabilities", "title": "Total Liabilities",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT currency, SUM(CAST(amount AS REAL)) * -1 AS amount FROM postings WHERE account_type = 'Liabilities' AND currency = :currency GROUP BY currency HAVING amount != 0" },
{ "id": "out", "kind": "transform", "fn": "firstRow", "inputs": ["{{steps.rows}}"] }
],
"output": "out",
"visualization": { "type": "kpi", "icon": "", "iconColor": "red", "multiCurrency": true }
},
{
"id": "assets-pie", "title": "Assets Breakdown",
"steps": [
{ "id": "rows", "kind": "query", "query": "SELECT REPLACE(account, 'Assets:', '') AS name, account, ROUND(SUM(CAST(amount AS REAL)), 2) AS value FROM postings WHERE account_type = 'Assets' AND currency = :currency GROUP BY account HAVING value > 0 ORDER BY value DESC" }
],
"output": "rows",
"visualization": {
"type": "chart", "chartType": "pie",
"options": { "series": [{ "type": "pie", "radius": ["35%", "65%"], "encode": { "itemName": "name", "value": "value" } }] }
}
}
]
}

Example: budget vs actual (sql + compute + transform)

Section titled “Example: budget vs actual (sql + compute + transform)”

This is the canonical multi-source widget: a query step for actuals, a compute step for budgets, and a transform that merges them into a variance table. The joinBudgetActual transform rolls actuals up to each budgeted account inclusively and emits budget, actual, remaining, and pctUsed.

{
"schemaVersion": 2,
"id": "budget-vs-actual",
"title": "Budget vs Actual",
"parameters": [
{ "name": "monthStart", "label": "From", "type": "date", "default": { "$gen": "startOfMonth" } },
{ "name": "monthEnd", "label": "To", "type": "date", "default": { "$gen": "endOfMonth" } },
{ "name": "currency", "label": "Currency", "type": "select",
"default": { "$gen": "defaultCurrency" }, "optionsFrom": "currencies" }
],
"layout": { "columns": 12, "rowHeight": "320px", "widgets": [{ "widgetId": "variance", "gridArea": "1 / 1 / 2 / 13" }] },
"widgets": [
{
"id": "variance",
"title": "This Month",
"steps": [
{ "id": "actuals", "kind": "query",
"query": "SELECT account, currency, SUM(CAST(amount AS REAL)) AS actual FROM postings WHERE account_type = 'Expenses' AND transaction_date BETWEEN :monthStart AND :monthEnd AND currency = :currency GROUP BY account, currency" },
{ "id": "budgets", "kind": "compute", "fn": "budget_for_range",
"args": { "from": "{{params.monthStart}}", "to": "{{params.monthEnd}}", "currency": "{{params.currency}}" } },
{ "id": "out", "kind": "transform", "fn": "joinBudgetActual",
"inputs": ["{{steps.budgets}}", "{{steps.actuals}}"] }
],
"output": "out",
"visualization": {
"type": "table",
"columns": [
{ "key": "account", "label": "Account", "format": "accountName2" },
{ "key": "budget", "label": "Budget", "format": "currency", "align": "right" },
{ "key": "actual", "label": "Actual", "format": "currency", "align": "right" },
{ "key": "remaining", "label": "Remaining", "format": "signedCurrency", "align": "right" },
{ "key": "pctUsed", "label": "% Used", "format": "percent", "align": "right" }
]
}
}
]
}

The bundled budget-overview, budget-envelopes, budget-zero-based, and budget-history dashboards build on this pattern with the other budget transforms. For the user-facing workflow — copying a demo and adapting it to your own accounts — see Build your own budget dashboard.


Recipes are validated when saved. Here’s a summary of the validation rules:

  • schemaVersion: Required, must be 2.
  • id: Required, non-empty, must match ^[a-z0-9][a-z0-9-]*[a-z0-9]$.
  • title: Required, non-empty string.
  • layout: Required object with columns (number), widgets (array).
  • Each layout widget must have widgetId (string) and gridArea (string).
  • widgets: Required, non-empty. Every widgetId in the layout must match an id in the widgets array (widgets are inline only).
  • id: Required, non-empty, must match the same pattern as dashboard IDs.
  • title: Required, non-empty string.
  • steps: Required, non-empty. Each step has a unique lowercase-hyphen id and a kind (query/compute/transform).
    • query steps need a non-empty query and may not contain {{...}} references.
    • compute steps need an fn; transform steps need an fn and non-empty inputs.
    • {{steps.x}} references must resolve to a declared step, and the step graph must be acyclic.
  • output: Required. Must name a step in steps.
  • visualization: Required object with type in ["kpi", "chart", "table", "pivot"].
  • For chart: chartType must be in ["bar", "line", "pie", "area", "scatter", "treemap", "funnel", "gauge", "calendar", "sankey", "radar", "sunburst"].
  • For kpi: iconColor is any string — a {{theme.*}} token, a hex/CSS color, or a legacy name (blue/green/red/purple/amber). See Colors.
  • format and label format strings must be valid format names.
  • query steps are dry-run against the database at save time to catch syntax errors (SELECT/WITH only).
  • compute fn names must exist in the server’s function catalog, and their args must satisfy the function’s schema.
  • transform fn names must exist in the transform catalog.
  • The output’s shape is checked against the visualization at render time — if a step returns the wrong shape for the chosen viz (e.g. a plain row list wired to a pivot), the widget shows a clear error instead of a blank panel.

Dashboard IDs must be unique across recipe files; step IDs must be unique within a widget. When two dashboard files share an id, an amber warning banner appears at the top of the Dashboards view listing the conflict and the files involved. When saving in the Settings editor, a confirmation dialog appears if the dashboard ID is already in use, letting you change it or save anyway.


  • Always use HAVING amount != 0 or HAVING value > 0 to exclude zero-value rows — especially for KPIs and pie charts.
  • Use REPLACE(account, 'Expenses:', '') AS name to create cleaner display names from account paths.
  • Include ORDER BY for deterministic chart rendering.
  • For horizontal bar charts showing top categories, use ORDER BY total ASC (not DESC) so the largest bars appear at the top of the chart.
  • Compute dateFrom and dateTo columns in SQL when you need them for click-through links.
  • Start with three KPIs in row 1, a full-width chart in rows 2-4, and a table or pivot in rows 5-8.
  • KPIs need 1 row of height. Charts and pivots need at least 3 rows.
  • Use "rowHeight": "140px" for KPI-focused dashboards, "200px" for chart-heavy ones.
  • For KPIs showing totals across all currencies, use multiCurrency: true and GROUP BY currency. For money totals (net worth, assets, liabilities), also JOIN commodities c ON p.currency = c.code WHERE c.is_currency = 1 so investment holdings (stocks/funds) aren’t stacked as share counts — see Commodities and Currencies.
  • For charts and pivots that need a single currency, add a currency parameter with "optionsFrom": "currencies" and filter with WHERE currency = :currency.
  • For year selectors, use "optionsFrom": "years" to dynamically populate from years present in the ledger data.
  • Common pattern: dashboard-level year parameter + widget-level currency parameter on charts/pivots, while KPIs show all currencies.
  • The app automatically handles dark mode styling for charts, including text colors, grid lines, and borders.
  • Don’t hardcode text colors in chart labels — the app adjusts them automatically.
  • Exception: itemStyle.color (bar/line/pie colors) is preserved as specified.