Dashboard & Widget Recipes
Dashboards and widgets in FinzyTrack are defined using JSON recipe files. There are two types of recipes:
- Dashboard recipes define a grid layout containing multiple widgets — KPI cards, charts, tables, and pivot tables.
- Widget recipes define a single, self-contained visualization that can be reused across multiple dashboards.
Both types are plain JSON files — no code changes or rebuilds required.
Concepts
Section titled “Concepts”A widget is the fundamental building block. Each widget runs a query (SQL or BQL) against your ledger data and displays the results as a KPI card, chart, table, or pivot table. Widgets can have interactive parameters (dropdowns, number inputs) that filter the data.
A dashboard arranges multiple widgets in a grid layout. Dashboards can define shared parameters that cascade to all contained widgets. A dashboard can define widgets inline in its own file, reference standalone widget recipes by ID, or mix both approaches (see Widget Resolution).
Widget recipes are standalone JSON files that define a single widget. They are useful when you want a reusable visualization (e.g., an expense treemap or a top-spending bar chart) that can be shared across multiple dashboards without duplicating the definition.
File Structure
Section titled “File Structure”Recipe files live in the config/recipes/ directory:
config/recipes/├── manifest.json # Index of all recipes├── dashboards/│ ├── financial-overview.json│ ├── year-summary.json│ └── month-summary.json└── widgets/ ├── expense-treemap.json └── top-spending-categories.jsonThe manifest.json file lists all recipe files:
{ "widgets": [ "widgets/expense-treemap.json", "widgets/top-spending-categories.json" ], "dashboards": [ "dashboards/year-summary.json", "dashboards/month-summary.json", "dashboards/financial-overview.json" ]}When you add a new recipe file through the app’s recipe editor, the manifest is updated automatically. If you add files manually, you must add their paths to the manifest for them to be loaded.
Dashboard Structure
Section titled “Dashboard Structure”A dashboard recipe is a JSON file with the following top-level structure:
{ "id": "my-dashboard", "title": "My Dashboard", "description": "Optional description shown in the dashboard picker", "parameters": [], "layout": { "columns": 12, "gap": "1.5rem", "rowHeight": "140px", "widgets": [] }, "widgets": []}Required Fields
Section titled “Required Fields”| Field | Type | Description |
|---|---|---|
id | string | Unique identifier. Lowercase letters, numbers, and hyphens only (e.g., my-dashboard). |
title | string | Display title shown in the dashboard picker and header. |
layout | object | Grid layout configuration (see Layout). |
widgets | array | Inline widget definitions (see Widget Structure). |
Optional Fields
Section titled “Optional Fields”| Field | Type | Description |
|---|---|---|
description | string | One-line description shown in the dashboard picker. |
parameters | array | Dashboard-level parameters shared by all widgets (see Parameters). |
Widget Resolution
Section titled “Widget Resolution”When a dashboard layout references a widgetId, the app looks for the widget in this order:
- Inline widgets — the dashboard’s own
widgetsarray. - Standalone widget recipes — widget recipe files listed in the manifest.
This means a dashboard can reference standalone widget recipes by ID without redefining them inline. For example, a dashboard can use the expense-treemap widget from widgets/expense-treemap.json simply by referencing it in the layout:
{ "layout": { "columns": 12, "widgets": [ { "widgetId": "expense-treemap", "gridArea": "2 / 1 / 6 / 13" } ] }, "widgets": []}The widgets array can be empty (or contain only the other widgets) — the expense-treemap widget will be resolved from the standalone recipe file. You can mix inline and standalone widgets freely in the same dashboard.
Layout
Section titled “Layout”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" } ]}Layout Properties
Section titled “Layout Properties”| Property | Type | Description |
|---|---|---|
columns | number | Number of grid columns. Use 12 for multi-widget layouts, 6 for simpler ones. |
gap | string | CSS gap between widgets. Default: "1.5rem". |
rowHeight | string | Height of each grid row. Use "140px" for KPI-heavy layouts, "200px" for chart-heavy ones. |
widgets | array | Widget placement definitions (see below). |
Widget Placement
Section titled “Widget Placement”Each entry in layout.widgets places one widget on the grid:
| Property | Type | Description |
|---|---|---|
widgetId | string | Must match an id in the widgets array. |
gridArea | string | CSS grid-area: "row-start / col-start / row-end / col-end" (1-based). |
Rules:
- Every
widgetIdmust have a matching widgetidin thewidgetsarray. - Column values must not exceed
columns + 1. - Row and column indices are 1-based.
Common Grid Patterns (12-column)
Section titled “Common Grid Patterns (12-column)”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" }Widget Structure
Section titled “Widget Structure”Each widget is defined inline within the dashboard’s widgets array:
{ "id": "total-income", "title": "Total Income", "description": "Sum of all income for the selected year", "helpText": "Income amounts are shown as positive values", "parameters": [], "dbType": "sqlite", "query": "SELECT currency, SUM(amount) * -1 AS amount FROM postings WHERE account_type = 'Income' AND year = :year GROUP BY currency HAVING amount != 0", "transform": "firstRow", "visualization": { "type": "kpi", "icon": "↑", "iconColor": "green" }}Required Fields
Section titled “Required Fields”| Field | Type | Description |
|---|---|---|
id | string | Unique identifier within the dashboard. Lowercase letters, numbers, hyphens. |
title | string | Display title shown in the widget header. |
query | string | Query to execute — SQL or BQL depending on dbType (see Queries). |
visualization | object | How to display results (see Visualizations). |
Optional Fields
Section titled “Optional Fields”| Field | Type | Description |
|---|---|---|
description | string | Description shown below the title. |
helpText | string | Tooltip text shown when hovering the info icon. |
parameters | array | Widget-level parameters (see Parameters). |
dbType | string | Query engine: "sqlite" (default) or "beanquery". See Querying Data for details on each engine. |
transform | string or object | Data transformation before visualization (see Transforms). |
Parameters
Section titled “Parameters”Parameters add interactive controls (dropdowns, number inputs) to dashboards and widgets. Parameter values are injected into SQL queries as :paramName placeholders.
Parameter Definition
Section titled “Parameter Definition”{ "name": "year", "label": "Year", "type": "select", "default": { "$gen": "currentYear" }, "options": { "$gen": "yearRange", "count": 6 }}Parameter Fields
Section titled “Parameter Fields”| Field | Type | Description |
|---|---|---|
name | string | Required. Identifier used as :name in queries. |
label | string | Required. Display label for the control. |
type | string | Required. One of: "select", "number", "date". |
default | any | Default value. Can be a literal or a $gen generator. |
options | array | For select type: array of { "value": ..., "label": "..." } objects. Can be a $gen generator. |
optionsFrom | string | For select type: dynamic option source. Currently only "currencies" is supported (populates from ledger currencies). |
min | number | For number type: minimum value. |
max | number | For number type: maximum value. |
Parameter Types
Section titled “Parameter Types”Select — dropdown menu:
{ "name": "year", "label": "Year", "type": "select", "default": { "$gen": "currentYear" }, "options": { "$gen": "yearRange", "count": 6 }}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" }}Dashboard vs Widget Parameters
Section titled “Dashboard vs Widget Parameters”- Dashboard-level parameters are defined in the dashboard’s
parametersarray and cascade to all widgets. They appear in the dashboard header. - Widget-level parameters are defined in each widget’s
parametersarray. They appear in the widget header. - If a widget defines a parameter with the same
nameas a dashboard parameter, the dashboard value takes precedence. - Parameters that the dashboard already provides are hidden from the widget header (no duplicate controls).
Using Parameters in Queries
Section titled “Using Parameters in Queries”Reference parameters in SQL using :paramName:
SELECT account, SUM(amount) AS totalFROM postingsWHERE account_type = 'Expenses' AND year = :year AND currency = :currencyGROUP BY accountORDER BY total DESCLIMIT :limitGenerators ($gen)
Section titled “Generators ($gen)”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.
Available Generators
Section titled “Available Generators”Value Generators
Section titled “Value Generators”| Generator | Output | Usage |
|---|---|---|
currentYear | Current year as a number | { "$gen": "currentYear" } |
currentMonth | Current month (1-12) | { "$gen": "currentMonth" } |
defaultCurrency | User’s default currency string | { "$gen": "defaultCurrency" } |
today | Today’s date as YYYY-MM-DD | { "$gen": "today" } |
Date Generators
Section titled “Date Generators”| Generator | Args | Output | Usage |
|---|---|---|---|
startOfMonth | offset (optional, default 0) | First day of month | { "$gen": "startOfMonth" } |
endOfMonth | offset (optional, default 0) | Last day of month | { "$gen": "endOfMonth" } |
startOfYear | offset (optional, default 0) | First day of year | { "$gen": "startOfYear" } |
endOfYear | offset (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.
Option Generators
Section titled “Option Generators”These return arrays of { "value": ..., "label": "..." } objects, suitable for select parameter options.
| Generator | Args | Output |
|---|---|---|
yearRange | count (default 5) | Array of recent years, descending. { "$gen": "yearRange", "count": 6 } |
monthOptions | format ("long" or "short", default "long") | All 12 months. { "$gen": "monthOptions" } |
quarterOptions | — | Q1 through Q4. { "$gen": "quarterOptions" } |
accountTypeOptions | — | Assets, Liabilities, Income, Expenses, Equity. |
datePresets | — | Predefined date range labels: Today, Yesterday, Last 7 Days, Last 30 Days, This Month, Last Month, This Quarter, This Year, Last Year. |
Queries
Section titled “Queries”Each widget’s query field contains a query that 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 dbType: "beanquery" on the widget.
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
postingstable (SQL) or Beancount entries directly (BQL). - Use
:paramNameplaceholders for parameter values in SQL queries (e.g.,:year,:currency). - Always
GROUP BY currencyor filterWHERE currency = :currencywhen summing amounts — never sum across currencies. - Use
HAVING amount != 0orHAVING value > 0to exclude zero-value rows. - Income amounts are negative (credit) — use
SUM(amount) * -1to display as positive. - Expense amounts are positive (debit) — use
SUM(amount)directly. - For treemap and pie charts, the query must return
nameandvaluecolumns, and must includeHAVING value > 0to exclude negative/zero values (which these chart types cannot display).
Transforms
Section titled “Transforms”Transforms modify query results before they are passed to the visualization. Most widgets don’t need a transform — the query results are used directly.
Simple Transforms
Section titled “Simple Transforms”Specify as a string in the widget’s transform field:
| Transform | Description |
|---|---|
"none" | No transform (default). Rows passed as-is. |
"firstRow" | Extracts the first row as a single object. Use for single-value KPIs from multi-row queries. |
"firstValue" | Extracts the first numeric value from the first row. |
Configurable Transforms
Section titled “Configurable Transforms”Specify as an object:
Sort rows:
{ "type": "sortBy", "field": "total", "order": "desc" }| Property | Type | Description |
|---|---|---|
field | string | Column name to sort by. |
order | string | "asc" or "desc" (default: "asc"). |
Limit rows:
{ "type": "limit", "count": 10 }Pivot (cross-tabulation):
{ "type": "pivot", "rowField": "account", "columnField": "year_month", "valueField": "amount", "formatColumn": "monthYear", "sortRowsBy": "total_desc"}| Property | Type | Description |
|---|---|---|
rowField | string | Column to use as row labels (default: "account"). |
columnField | string | Column to use as column headers (default: "year_month"). |
valueField | string | Column containing the numeric values (default: "amount"). |
formatColumn | string | Column header format: "monthYear" (e.g., “Jan 2026”) or "yearMonth" (e.g., “2026-01”). |
sortRowsBy | string | Row sort order. total sorts by the sum of all values across columns for each row; label sorts alphabetically by the row label. Options: "total_desc", "total_asc", "label_asc", "label_desc". Default: "total_desc". |
The pivot transform is required when using the pivot visualization type. It restructures flat query results (one row per account+month) into a cross-tabulation structure.
When columnField contains YYYY-MM values (e.g., "2026-01", "2026-02"), the pivot transform automatically generates column metadata for each column:
columnMeta.rawValue— the original column key (e.g.,"2026-01")columnMeta.startDate— the first day of that month (e.g.,"2026-01-01")columnMeta.endDate— the last day of that month (e.g.,"2026-01-31")
This metadata is available in pivot table click-through link templates (see Click-Through Links).
Visualizations
Section titled “Visualizations”The visualization object in each widget determines how query results are displayed.
KPI — Single Metric Display
Section titled “KPI — Single Metric Display”Displays a single value prominently, with an optional icon and color.
| Property | Type | Description |
|---|---|---|
type | string | Required. Must be "kpi". |
icon | string | Single character or emoji displayed in a colored circle (e.g., "$", "↑", "↓", "#"). |
iconColor | string | Icon background color: "blue", "green", "red", "purple", or "amber". |
format | string | Value format (see Formats). |
valueField | string | Column name to display as the KPI value (default: "value"). |
multiCurrency | boolean | If 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). |
amountField | string | Column name for amounts when multiCurrency is true (default: "amount"). |
currencyField | string | Column name for currencies when multiCurrency is true (default: "currency"). |
showTrend | boolean | Show a trend indicator below the value (e.g., “+5.2% vs prior”). Requires trendField. |
trendField | string | Column name containing the trend percentage. Positive values show as green (up), negative as red (down). |
clickLink | object | Makes the KPI value clickable, navigating to a filtered view. See Click-Through Links. |
Single-value KPI
Section titled “Single-value KPI”The query returns one row with a numeric column. Use transform: "firstRow" and valueField to extract the value.
{ "id": "transaction-count", "title": "Transaction Count", "query": "SELECT COUNT(DISTINCT transaction_id) AS value FROM postings WHERE year = :year", "transform": "firstRow", "visualization": { "type": "kpi", "icon": "#", "iconColor": "purple", "valueField": "value", "format": "number" }}Multi-currency KPI
Section titled “Multi-currency KPI”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", "query": "SELECT currency, SUM(amount) * -1 AS amount FROM postings WHERE account_type = 'Income' AND year = :year GROUP BY currency HAVING amount != 0", "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", "query": "SELECT currency AS cur, SUM(amount) AS total FROM postings WHERE account_type = 'Assets' GROUP BY currency HAVING total != 0", "visualization": { "type": "kpi", "icon": "↑", "iconColor": "green", "multiCurrency": true, "amountField": "total", "currencyField": "cur" }}KPI with trend
Section titled “KPI with trend”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", "query": "SELECT SUM(amount) AS value, ROUND((SUM(amount) - prev.total) * 100.0 / prev.total, 1) AS trend FROM postings, (SELECT SUM(amount) 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')", "transform": "firstRow", "visualization": { "type": "kpi", "icon": "↓", "iconColor": "red", "valueField": "value", "format": "currency", "showTrend": true, "trendField": "trend" }}KPI with click-through
Section titled “KPI with click-through”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", "query": "SELECT currency, SUM(amount) AS amount FROM postings WHERE account_type = 'Expenses' AND year = :year GROUP BY currency HAVING amount != 0", "visualization": { "type": "kpi", "icon": "↓", "iconColor": "red", "multiCurrency": true, "clickLink": { "name": "transactions", "query": { "accountContains": "Expenses", "dateFrom": "{{dateFrom}}", "dateTo": "{{dateTo}}" } } }}Chart — ECharts Visualizations
Section titled “Chart — ECharts Visualizations”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": { ... }}| Property | Type | Description |
|---|---|---|
type | string | Required. Must be "chart". |
chartType | string | Required. One of: "bar", "line", "pie", "area", "scatter", "treemap". |
options | object | ECharts configuration (grid, axes, series, legend, tooltip). |
seriesLabelFormat | string | Format for data point labels (see Formats). |
yAxisLabelFormat | string | Format for Y-axis tick labels. |
xAxisLabelFormat | string | Format for X-axis tick labels. |
clickLink | object | Default click-through link for all series (see Click-Through Links). |
seriesClickLinks | object | Per-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.
How Query Results Connect to Charts
Section titled “How Query Results Connect to Charts”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.
Bar Chart
Section titled “Bar Chart”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:
encodemaps query column names to chart dimensions:{ "x": "column_name", "y": "column_name" }.- Multiple
seriesentries 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.
Line Chart
Section titled “Line Chart”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.
Area Chart
Section titled “Area Chart”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" } } ] }}Pie Chart
Section titled “Pie Chart”{ "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
nameandvaluecolumns. - Pie charts cannot display negative or zero values. Your SQL query must include
HAVING value > 0to filter them out. radius: ["30%", "60%"]creates a donut chart. Use"50%"for a solid pie.- Pie charts have no axes — don’t include
xAxisoryAxis. - Use
"tooltip": { "trigger": "item" }(not"axis").
Scatter Chart
Section titled “Scatter Chart”{ "type": "chart", "chartType": "scatter", "options": { "xAxis": { "type": "value" }, "yAxis": { "type": "value" }, "series": [ { "type": "scatter", "encode": { "x": "income", "y": "expenses" }, "itemStyle": { "color": "#6366f1" } } ] }}Treemap
Section titled “Treemap”{ "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 } } ] } ] }}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" } ]}| Property | Type | Description |
|---|---|---|
type | string | Required. Must be "table". |
columns | array | Required. Column definitions (see below). |
Column Definition
Section titled “Column Definition”| Property | Type | Description |
|---|---|---|
key | string | Required. Query column name to display. |
label | string | Required. Column header text. |
align | string | Text alignment: "left" (default), "center", "right". |
format | string | Value format (see Formats). |
link | object | Click-through link for cell values (see Click-Through Links). |
Pivot Table
Section titled “Pivot Table”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}}" } }}| Property | Type | Description |
|---|---|---|
type | string | Required. Must be "pivot". |
rowHeader | string | Label for the row header column (default: "Account"). |
format | string | Cell value format (see Formats). |
showRowTotals | boolean | Show a “Total” column on the right (default: true). |
showColumnTotals | boolean | Show a totals row at the bottom (default: true). |
valueLink | object | Click-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" } ], "query": "SELECT account, year_month, SUM(amount) AS amount FROM postings WHERE account_type = 'Expenses' AND year = :year AND currency = :currency GROUP BY account, year_month ORDER BY account, year_month", "transform": { "type": "pivot", "rowField": "account", "columnField": "year_month", "valueField": "amount", "formatColumn": "monthYear", "sortRowsBy": "total_desc" }, "visualization": { "type": "pivot", "rowHeader": "Account", "showRowTotals": true, "showColumnTotals": true, "valueLink": { "name": "transactions", "query": { "accountContains": "{{row.label}}", "dateFrom": "{{columnMeta.startDate}}", "dateTo": "{{columnMeta.endDate}}" } } }}Formats
Section titled “Formats”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.
| Format | Output Example | Use For |
|---|---|---|
"currency" | $14,200.00 or ₹14,20,000.00 | Monetary amounts (currency-aware) |
"signedCurrency" | +$14,200 or -₹500 | Signed monetary amounts (currency-aware) |
"compact" | 14.2k, 1.5M | Large numbers |
"number" | 14,200 | Plain numbers with thousand separators |
"percent" | 42% | Percentages |
"date" | Jan 15, 2026 | ISO dates as readable text |
"dateShort" | 1/15/26 | Short date format |
"accountName" | Groceries | Last segment of an account path |
"accountName2" | Food:Groceries | Last two segments of an account path |
Currency-Aware Formatting
Section titled “Currency-Aware Formatting”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
currencyparameter set to"USD"formats as$1,234,567.89(en-US locale) - A widget with a
currencyparameter 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.
Click-Through Links
Section titled “Click-Through Links”Widgets can be made interactive by adding click-through links. Clicking a value navigates to the Transactions view with filters pre-applied.
Link Structure
Section titled “Link Structure”{ "name": "transactions", "query": { "accountContains": "Expenses", "dateFrom": "2026-01-01", "dateTo": "2026-12-31" }}| Property | Type | Description |
|---|---|---|
name | string | Route name. Currently only "transactions" is supported. |
query | object | Filter parameters for the Transactions view. |
Supported Query Filters
Section titled “Supported Query Filters”| Filter | Description |
|---|---|
accountContains | Filter transactions where an account path contains this string. |
dateFrom | Start date (YYYY-MM-DD). |
dateTo | End date (YYYY-MM-DD). |
payeeContains | Filter by payee name. |
narrationContains | Filter by narration text. |
Template Variables
Section titled “Template Variables”Link values can use template variables with {{...}} syntax. The available variables depend on the visualization type:
In Chart clickLink and seriesClickLinks
Section titled “In Chart clickLink and seriesClickLinks”| Variable | Description |
|---|---|
{{data.columnName}} | Any column from the clicked data row. |
{{parameters.paramName}} | Current value of a parameter. |
In KPI clickLink
Section titled “In KPI clickLink”| Variable | Description |
|---|---|
{{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). |
In Pivot Table valueLink
Section titled “In Pivot Table valueLink”| Variable | Description |
|---|---|
{{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. |
In Table Column link
Section titled “In Table Column link”| Variable | Description |
|---|---|
{{row.columnName}} | Any column from the row. |
{{value}} | The cell value. |
Per-Series Click Links (Charts)
Section titled “Per-Series Click Links (Charts)”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
namevalues from theoptions.seriesarray. - Set a series to
nullto disable clicking for that series. - If
seriesClickLinksis present, it takes priority overclickLinkfor the matching series.
Complete Examples
Section titled “Complete Examples”Example: Financial Overview Dashboard
Section titled “Example: Financial Overview Dashboard”A dashboard showing net worth, total assets, total liabilities, and breakdown pie charts.
{ "id": "financial-overview", "title": "Financial Overview", "description": "Overview of your financial status with net worth, assets, liabilities, and breakdowns", "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" } ] }, "widgets": [ { "id": "net-worth", "title": "Net Worth", "query": "SELECT currency, SUM(CASE WHEN account_type IN ('Assets', 'Liabilities') THEN amount ELSE 0 END) AS amount FROM postings GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "$", "multiCurrency": true } }, { "id": "total-assets", "title": "Total Assets", "query": "SELECT currency, SUM(amount) AS amount FROM postings WHERE account_type = 'Assets' GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "↑", "iconColor": "green", "multiCurrency": true } }, { "id": "total-liabilities", "title": "Total Liabilities", "query": "SELECT currency, SUM(amount) AS amount FROM postings WHERE account_type = 'Liabilities' GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "↓", "iconColor": "red", "multiCurrency": true } }, { "id": "assets-pie", "title": "Assets Breakdown", "helpText": "Only shows accounts with a positive balance", "parameters": [ { "name": "currency", "label": "Currency", "type": "select", "default": { "$gen": "defaultCurrency" }, "optionsFrom": "currencies" } ], "query": "SELECT REPLACE(account, 'Assets:', '') AS name, account, ROUND(SUM(amount), 2) AS value FROM postings WHERE account_type = 'Assets' AND currency = :currency GROUP BY account HAVING value > 0 ORDER BY value DESC", "visualization": { "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}%" } } ] }, "clickLink": { "name": "transactions", "query": { "accountContains": "{{data.account}}" } } } }, { "id": "liabilities-pie", "title": "Liabilities Breakdown", "helpText": "Only shows accounts with outstanding balances", "parameters": [ { "name": "currency", "label": "Currency", "type": "select", "default": { "$gen": "defaultCurrency" }, "optionsFrom": "currencies" } ], "query": "SELECT REPLACE(account, 'Liabilities:', '') AS name, account, ROUND(SUM(amount) * -1, 2) AS value FROM postings WHERE account_type = 'Liabilities' AND currency = :currency GROUP BY account HAVING value > 0 ORDER BY value DESC", "visualization": { "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}%" } } ] }, "clickLink": { "name": "transactions", "query": { "accountContains": "{{data.account}}" } } } } ]}What this demonstrates:
- Three multi-currency KPI widgets across the top row
- Two half-width pie charts in the second section
- Widget-level currency parameter on pie charts (not shared at dashboard level, since KPIs show all currencies)
REPLACE(account, 'Assets:', '')to create cleaner display names- Liabilities multiplied by
-1to show as positive values in pie chart HAVING value > 0to exclude negative/zero entries from pie charts- Click-through links using
{{data.account}}
Example: Year Summary Dashboard
Section titled “Example: Year Summary Dashboard”An annual overview with income, expenses, savings KPIs, a monthly bar chart, and an expense pivot table.
{ "id": "year-summary", "title": "Year Summary", "description": "Annual financial summary with income, expenses, and savings breakdown", "parameters": [ { "name": "year", "label": "Year", "type": "select", "default": { "$gen": "currentYear" }, "options": { "$gen": "yearRange", "count": 6 } } ], "layout": { "columns": 12, "gap": "1.5rem", "rowHeight": "140px", "widgets": [ { "widgetId": "total-income", "gridArea": "1 / 1 / 2 / 5" }, { "widgetId": "total-expenses", "gridArea": "1 / 5 / 2 / 9" }, { "widgetId": "savings", "gridArea": "1 / 9 / 2 / 13" }, { "widgetId": "monthly-income-expenses", "gridArea": "2 / 1 / 5 / 13" }, { "widgetId": "expenses-pivot-table", "gridArea": "5 / 1 / 9 / 13" } ] }, "widgets": [ { "id": "total-income", "title": "Total Income", "query": "SELECT currency, SUM(amount) * -1 AS amount FROM postings WHERE account_type = 'Income' AND year = :year GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "↑", "iconColor": "green", "multiCurrency": true, "clickLink": { "name": "transactions", "query": { "accountContains": "Income", "dateFrom": "{{dateFrom}}", "dateTo": "{{dateTo}}" } } } }, { "id": "total-expenses", "title": "Total Expenses", "query": "SELECT currency, SUM(amount) AS amount FROM postings WHERE account_type = 'Expenses' AND year = :year GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "↓", "iconColor": "red", "multiCurrency": true, "clickLink": { "name": "transactions", "query": { "accountContains": "Expenses", "dateFrom": "{{dateFrom}}", "dateTo": "{{dateTo}}" } } } }, { "id": "savings", "title": "Savings", "query": "SELECT currency, (SUM(CASE WHEN account_type = 'Income' THEN -amount ELSE 0 END)) - (SUM(CASE WHEN account_type = 'Expenses' THEN amount ELSE 0 END)) AS amount FROM postings WHERE year = :year AND account_type IN ('Income', 'Expenses') GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "$", "iconColor": "blue", "multiCurrency": true } }, { "id": "monthly-income-expenses", "title": "Monthly Income & Expenses", "description": "Monthly comparison of income, expenses, and savings", "parameters": [ { "name": "currency", "label": "Currency", "type": "select", "default": { "$gen": "defaultCurrency" }, "optionsFrom": "currencies" } ], "query": "SELECT year_month, SUBSTR('JanFebMarAprMayJunJulAugSepOctNovDec', (CAST(strftime('%m', year_month || '-01') AS INTEGER) - 1) * 3 + 1, 3) AS month_label, year_month || '-01' AS dateFrom, date(year_month || '-01', '+1 month', '-1 day') AS dateTo, SUM(CASE WHEN account_type = 'Income' THEN -amount ELSE 0 END) AS income, SUM(CASE WHEN account_type = 'Expenses' THEN amount ELSE 0 END) AS expenses, SUM(CASE WHEN account_type = 'Income' THEN -amount ELSE 0 END) - SUM(CASE WHEN account_type = 'Expenses' THEN amount ELSE 0 END) AS savings FROM postings WHERE year = :year AND currency = :currency AND account_type IN ('Income', 'Expenses') GROUP BY year_month ORDER BY year_month", "visualization": { "type": "chart", "chartType": "bar", "seriesLabelFormat": "compact", "yAxisLabelFormat": "compact", "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 }, "options": { "legend": { "data": ["Expenses", "Income", "Savings"], "top": 0, "left": "left", "itemGap": 20 }, "grid": { "top": 40, "bottom": 40, "left": 50, "right": 20 }, "xAxis": { "type": "category" }, "yAxis": { "type": "value", "splitLine": { "lineStyle": { "type": "dashed", "opacity": 0.6 } } }, "series": [ { "name": "Expenses", "type": "bar", "encode": { "x": "month_label", "y": "expenses" }, "itemStyle": { "color": "#E8A951" }, "barGap": "10%", "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 } }, { "name": "Savings", "type": "bar", "encode": { "x": "month_label", "y": "savings" }, "itemStyle": { "color": "#7B83AD" }, "label": { "show": true, "position": "top", "fontSize": 10 } } ] } } }, { "id": "expenses-pivot-table", "title": "Expenses Pivot Table", "description": "Monthly breakdown of expenses by account", "parameters": [ { "name": "currency", "label": "Currency", "type": "select", "default": { "$gen": "defaultCurrency" }, "optionsFrom": "currencies" } ], "query": "SELECT account, year_month, SUM(amount) AS amount FROM postings WHERE account_type = 'Expenses' AND year = :year AND currency = :currency GROUP BY account, year_month ORDER BY account, year_month", "transform": { "type": "pivot", "rowField": "account", "columnField": "year_month", "valueField": "amount", "formatColumn": "monthYear", "sortRowsBy": "total_desc" }, "visualization": { "type": "pivot", "rowHeader": "Account", "showRowTotals": true, "showColumnTotals": true, "valueLink": { "name": "transactions", "query": { "accountContains": "{{row.label}}", "dateFrom": "{{columnMeta.startDate}}", "dateTo": "{{columnMeta.endDate}}" } } } } ]}What this demonstrates:
- Dashboard-level
yearparameter shared by all widgets - Widget-level
currencyparameter on the chart and pivot (KPIs show all currencies) - Multi-series bar chart with per-series click links (
seriesClickLinks) - Savings series with click disabled (
null) - Computed
dateFrom/dateTocolumns in SQL for click-through links - Pivot table with month-formatted columns and cell-level click-through links
{{dateFrom}}/{{dateTo}}shorthand in KPI click links (auto-computed from year parameter)
Example: Month Summary Dashboard
Section titled “Example: Month Summary Dashboard”A monthly breakdown with KPIs and an expense treemap. This dashboard mixes inline widgets with a reference to a standalone widget recipe.
{ "id": "month-summary", "title": "Month Summary", "description": "Monthly financial summary with income, expenses, and expense treemap breakdown", "parameters": [ { "name": "year", "label": "Year", "type": "select", "default": { "$gen": "currentYear" }, "options": { "$gen": "yearRange", "count": 5 } }, { "name": "month", "label": "Month", "type": "select", "default": { "$gen": "currentMonth" }, "options": { "$gen": "monthOptions" } } ], "layout": { "columns": 12, "gap": "1.5rem", "rowHeight": "140px", "widgets": [ { "widgetId": "monthly-income", "gridArea": "1 / 1 / 2 / 5" }, { "widgetId": "monthly-expenses", "gridArea": "1 / 5 / 2 / 9" }, { "widgetId": "monthly-savings", "gridArea": "1 / 9 / 2 / 13" }, { "widgetId": "expense-treemap", "gridArea": "2 / 1 / 6 / 13" } ] }, "widgets": [ { "id": "monthly-income", "title": "Total Income", "query": "SELECT currency, SUM(amount) * -1 AS amount FROM postings WHERE account_type = 'Income' AND year = :year AND CAST(strftime('%m', transaction_date) AS INTEGER) = :month GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "↑", "iconColor": "green", "multiCurrency": true, "clickLink": { "name": "transactions", "query": { "accountContains": "Income", "dateFrom": "{{dateFrom}}", "dateTo": "{{dateTo}}" } } } }, { "id": "monthly-expenses", "title": "Total Expenses", "query": "SELECT currency, SUM(amount) AS amount FROM postings WHERE account_type = 'Expenses' AND year = :year AND CAST(strftime('%m', transaction_date) AS INTEGER) = :month GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "↓", "iconColor": "red", "multiCurrency": true, "clickLink": { "name": "transactions", "query": { "accountContains": "Expenses", "dateFrom": "{{dateFrom}}", "dateTo": "{{dateTo}}" } } } }, { "id": "monthly-savings", "title": "Savings", "query": "SELECT currency, (SUM(CASE WHEN account_type = 'Income' THEN -amount ELSE 0 END)) - (SUM(CASE WHEN account_type = 'Expenses' THEN amount ELSE 0 END)) AS amount FROM postings WHERE year = :year AND CAST(strftime('%m', transaction_date) AS INTEGER) = :month AND account_type IN ('Income', 'Expenses') GROUP BY currency HAVING amount != 0 ORDER BY currency", "visualization": { "type": "kpi", "icon": "$", "iconColor": "blue", "multiCurrency": true } } ]}What this demonstrates:
- Dashboard-level
yearandmonthparameters (shared by all widgets) - Mixing inline and standalone widgets — the three KPI widgets are defined inline, while
expense-treemapis referenced by ID from the standalone Expense Treemap widget recipe. It is not defined in this dashboard’swidgetsarray — it is resolved via widget resolution fromwidgets/expense-treemap.json. - The dashboard’s
yearandmonthparameters cascade to the standalone treemap widget, overriding its own defaults. The treemap’scurrencyparameter (not provided by the dashboard) appears as a widget-level control in the treemap header. - Month filtering with
CAST(strftime('%m', transaction_date) AS INTEGER) = :month {{dateFrom}}/{{dateTo}}shorthand in KPI click links (auto-computed from year + month parameters)
Widget Recipes
Section titled “Widget Recipes”Widget recipes are standalone JSON files in config/recipes/widgets/ that define a single widget. They have the same structure as inline widget definitions (see Widget Structure) — the only difference is that they live in their own file rather than inside a dashboard’s widgets array.
Widget recipes are listed in manifest.json under the "widgets" array. Dashboards can reference standalone widget recipes by ID in their layout — see Widget Resolution for details.
Example: Expense Treemap Widget
Section titled “Example: Expense Treemap Widget”A treemap showing expense categories for a given month, with click-through to transactions.
{ "id": "expense-treemap", "title": "Expense Treemap", "description": "Treemap visualization of expenses by account for a given month", "helpText": "Only shows categories with net positive expenses", "parameters": [ { "name": "year", "label": "Year", "type": "select", "default": { "$gen": "currentYear" }, "options": { "$gen": "yearRange", "count": 5 } }, { "name": "month", "label": "Month", "type": "select", "default": { "$gen": "currentMonth" }, "options": { "$gen": "monthOptions" } }, { "name": "currency", "label": "Currency", "type": "select", "default": { "$gen": "defaultCurrency" }, "optionsFrom": "currencies" } ], "query": "SELECT REPLACE(account, 'Expenses:', '') AS name, account, SUM(amount) AS value, :year || '-' || printf('%02d', :month) || '-01' AS dateFrom, date(:year || '-' || printf('%02d', :month) || '-01', '+1 month', '-1 day') AS dateTo FROM postings WHERE account_type = 'Expenses' AND year = :year AND CAST(strftime('%m', transaction_date) AS INTEGER) = :month AND currency = :currency GROUP BY account HAVING value > 0 ORDER BY value DESC", "visualization": { "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 } } ] } ] }, "clickLink": { "name": "transactions", "query": { "accountContains": "{{data.account}}", "dateFrom": "{{data.dateFrom}}", "dateTo": "{{data.dateTo}}" } } }}What this demonstrates:
- Three parameters (year, month, currency) all using
$gengenerators optionsFrom: "currencies"to dynamically load currency options from the ledgerREPLACE(account, 'Expenses:', '')for cleaner treemap labels- Computed
dateFrom/dateTocolumns in SQL for click-through links - Treemap-specific rules:
name/valuecolumns, noencode,HAVING value > 0
Example: Top Spending Categories Widget
Section titled “Example: Top Spending Categories Widget”A horizontal bar chart of the highest expense accounts.
{ "id": "top-spending-categories", "title": "Top Spending Categories", "description": "Highest expense accounts by total amount", "parameters": [ { "name": "limit", "label": "Show Top", "type": "number", "default": 10, "min": 5, "max": 20 } ], "query": "SELECT account, SUM(amount) AS total FROM postings WHERE account_type = 'Expenses' AND currency = 'USD' GROUP BY account ORDER BY total ASC LIMIT :limit", "visualization": { "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" } } ] } }}What this demonstrates:
- A
numberparameter withmin/maxconstraints - Horizontal bar chart (value on X, category on Y)
ORDER BY total ASCso the largest bars appear at the top of the chartaccountNameformat on Y-axis labels to show only the last segment of account pathscurrencyformat on series labels for dollar amounts
Validation Rules
Section titled “Validation Rules”Recipes are validated when saved. Here’s a summary of the validation rules:
Dashboard Validation
Section titled “Dashboard Validation”id: Required, non-empty, must match^[a-z0-9][a-z0-9-]*[a-z0-9]$.title: Required, non-empty string.layout: Required object withcolumns(number),widgets(array).- Each layout widget must have
widgetId(string) andgridArea(string). - Every
widgetIdmust match anidin thewidgetsarray.
Widget Validation
Section titled “Widget Validation”id: Required, non-empty, must match the same pattern as dashboard IDs.title: Required, non-empty string.query: Required, non-empty string. Must be a SELECT statement.visualization: Required object withtypein["kpi", "chart", "table", "pivot"].- For
chart:chartTypemust be in["bar", "line", "pie", "area", "scatter", "treemap"]. - For
kpi:iconColormust be in["blue", "green", "red", "purple", "amber"]. formatand label format strings must be valid format names.transform: Must be a valid simple string or object transform.
SQL Validation
Section titled “SQL Validation”- Queries are dry-run against the database at save time to catch syntax errors.
- Only SELECT (and WITH) statements are allowed.
ID Conflict Detection
Section titled “ID Conflict Detection”Widget and dashboard IDs must be unique across all recipe files. FinzyTrack detects the following conflicts:
- Duplicate widget IDs — two standalone widget files with the same
id. - Duplicate dashboard IDs — two dashboard files with the same
id. - Inline widget conflicts — a standalone widget file with an
idthat matches an inline widget definition inside a dashboard.
When conflicts are detected at load time, an amber warning banner appears at the top of the Dashboards view listing each conflict and the files involved.
When saving a recipe in the Settings view, the editor checks for ID conflicts and shows a confirmation dialog if a conflict is found, giving you the option to go back and change the ID or save anyway.
Tips and Best Practices
Section titled “Tips and Best Practices”Query Tips
Section titled “Query Tips”- Always use
HAVING amount != 0orHAVING value > 0to exclude zero-value rows — especially for KPIs and pie charts. - Use
REPLACE(account, 'Expenses:', '') AS nameto create cleaner display names from account paths. - Include
ORDER BYfor 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
dateFromanddateTocolumns in SQL when you need them for click-through links.
Layout Tips
Section titled “Layout Tips”- 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.
Multi-Currency Tips
Section titled “Multi-Currency Tips”- For KPIs showing totals across all currencies, use
multiCurrency: trueandGROUP BY currency. - For charts and pivots that need a single currency, add a
currencyparameter with"optionsFrom": "currencies"and filter withWHERE currency = :currency. - Common pattern: dashboard-level
yearparameter + widget-levelcurrencyparameter on charts/pivots, while KPIs show all currencies.
Dark Mode
Section titled “Dark Mode”- 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.