Dashboard metadata — charts and KPIs
Build analytics dashboards with chart widgets, global filters, and auto-refresh
A Dashboard defines an analytics page with chart widgets, key metrics, and data visualizations. Dashboards support configurable layouts, global date filters, and auto-refresh.
Basic Structure
Every widget binds to a dataset (the semantic layer, ADR-0021) and selects
the dataset's dimensions and values by name. The dataset owns the base
object, joins, and measures, so the same numbers stay consistent
across every dashboard and report.
const salesDashboard = {
name: 'sales_overview',
label: 'Sales Overview',
description: 'Key sales metrics and pipeline analysis',
refreshIntervalSeconds: 300, // Refresh every 5 minutes
dateRange: {
field: 'close_date',
defaultRange: 'this_quarter',
allowCustomRange: true,
},
widgets: [
{
id: 'total_revenue',
title: 'Total Revenue',
type: 'metric',
dataset: 'sales',
values: ['revenue'],
layout: { x: 0, y: 0, w: 3, h: 2 },
},
{
id: 'revenue_by_region',
title: 'Revenue by Region',
type: 'bar',
dataset: 'sales',
dimensions: ['region'],
values: ['revenue'],
layout: { x: 3, y: 0, w: 6, h: 4 },
},
{
id: 'deals_by_month',
title: 'Deals by Month',
type: 'pie',
dataset: 'sales',
dimensions: ['close_month'],
values: ['deal_count'],
layout: { x: 9, y: 0, w: 3, h: 4 },
},
],
};Dashboard Properties
| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Machine name (snake_case) |
label | string | ✅ | Display label |
description | string | optional | Dashboard description |
widgets | DashboardWidget[] | ✅ | Chart and metric widgets |
refreshIntervalSeconds | number | optional | Auto-refresh interval (seconds) |
dateRange | object | optional | Global date range filter |
globalFilters | GlobalFilter[] | optional | Global filter controls |
Widgets
Each widget defines a data visualization. A widget binds to a dataset and
selects dimensions (X / group / split) and values (the measures to plot):
{
id: 'monthly_revenue',
title: 'Monthly Revenue',
type: 'line',
dataset: 'invoicing',
dimensions: ['invoice_month'],
values: ['paid_total'],
filter: { status: 'paid' },
layout: { x: 0, y: 0, w: 6, h: 4 },
}Widget Properties
| Property | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Unique widget id (snake_case) |
dataset | string | ✅ | Dataset name to bind |
values | string[] | ✅ | Measure names (at least one) |
dimensions | string[] | optional | Dimension names — X / group / split |
type | ChartType | optional | Visualization type (default metric, see below) |
title | string | optional | Widget display title |
description | string | optional | Text shown below the title |
filter | FilterCondition | optional | Presentation-scope filter (runtimeFilter) |
layout | object | optional | Grid position and size (auto-flowed into the grid when omitted) |
chartConfig | object | optional | Chart appearance: title, subtitle, description, colors, height, showLegend, showDataLabels, annotations, interaction. The bound dataset decides which series exist and which column each one reads, so type, xAxis, yAxis and series are refused here — see below |
colorVariant | enum | optional | KPI/card accent color |
compareTo | object | optional | Period-over-period comparison: { kind: 'previousPeriod' | 'previousYear', dimension? }. Omit dimension when the selection dates exactly one time dimension — the runtime resolves it, and errors naming the candidates rather than guessing when it cannot. |
options | object | optional | The query keys below. Presentation does not go here — see below |
What chartConfig does not carry
A dashboard widget is always bound to a dataset (dataset is required), and the
dataset owns the chart's structure: which series exist, and which column
each one reads. chartConfig carries appearance. So four keys that the
shared chart config declares are refused on a dashboard widget by name, each
refusal naming where the intent belongs:
you wrote inside chartConfig | write this on the widget instead |
|---|---|
type: 'line' | type: 'line' — the widget's own type is the chart family, and it always won |
xAxis: { field: 'stage' } | dimensions: ['stage'] — the dataset dimension the category axis plots |
yAxis: [{ field: 'amount' }] | values: ['amount'] — the dataset measures, one entry per mark |
series: [{ name: 'amount' }] | values (+ a second dimensions entry to split) — series membership follows the selection |
Axis titles, number formats, axis bounds and per-series labels, colours and mark
types went with those keys: a dataset-bound chart takes them from the dataset's
own dimension and measure declarations. colors on the chart config is the
palette channel that remains.
The keys are not gone from the chart config everywhere. A react-tier
<ObjectChart data={…} /> binds inline rows with no dataset behind them, so its
axes are the author's and are unchanged; a report chart names its bound
dataset's dimension and measure directly in xAxis / yAxis.
Existing sources: os migrate meta --from 17 lists the mechanical edits.
Widget options
options carries five declared keys, and four of them change the query the
dataset compiles to. The bag is open, so any other key parses, but a dashboard
widget is always bound to a dataset and nothing on that render path reads
another key: an icon, columns, format or color written here styles
nothing. os validate, os build and os lint warn on each such key by name
(unconsumed-widget-option) and list the keys that are read.
Presentation has declared homes instead:
| You want | Write it here |
|---|---|
| A number's format or currency | The dataset measure's format and currency |
| A KPI tile's accent color | The widget's colorVariant |
| A chart's look (colors, legend, data labels, height) | The widget's chartConfig |
A key that something outside the renderer genuinely reads can say so with
suppressWarnings: ['unconsumed-widget-option'] on the widget.
The declared keys:
| Key | Type | Effect |
|---|---|---|
dateGranularity | day | week | month | quarter | year | Buckets this widget's selected date dimensions. Overrides the dataset dimension's own default for this widget only. |
sortBy | string | Orders rows by a dimension or measure this widget selects. |
sortOrder | asc | desc | Direction for sortBy (default asc). |
limit | number | Max rows, applied after ordering. |
stageOrder | (string | number | boolean)[] | Explicit stage order for a funnel widget, as the dimension's stored values. funnel is the only widget type that reads it, and the schema refuses it on any other type; order those with sortBy / sortOrder. Omit to use the dimension field's picklist option order. |
Because they are declared, a wrong value (sortOrder: 'sideways') is an
author-time error. A misspelled key (sortDirection, granularity) is
not declared, so it parses like any other extra and changes nothing; the
unconsumed-widget-option warning is what names it.
{
id: 'revenue_trend',
type: 'area',
dataset: 'opportunity_metrics',
dimensions: ['close_date'],
values: ['total_amount'],
// Group by month rather than by raw timestamp.
options: { dateGranularity: 'month' },
}Notes on behaviour:
sortBymust name something the widget selects; the server rejects an order key it cannot resolve rather than ignoring it.- A
limitwith nosortByorders by the selected dimensions first, so it truncates a reproducible window instead of an arbitrary subset. - Ordering is applied to the finished grid, so a derived measure is a valid
sortByeven though no single SQL statement computes it. - A
sortBynaming a dimension whose field carriesoptions, or a reference dimension, orders by the display label the rows render (the option label / the related record's name), not the stored value or foreign-key id — and the label is resolved beforelimitapplies, so a top-N by name truncates the right N. The option arm keys on theoptionslist being there, not on the field's type, so it is not limited toselect; reference is the whole class of fields whose stored value is another record's id, so auserperson axis sorts by name too. Sorting by a measure (the common case) involves no label lookup and is unaffected. - A
funnelwith no declared stage order falls back to sorting by value descending.
Datasets
Every widget binds to a dataset — the single semantic layer (ADR-0021). A dataset defines the base object, allowed relationship joins, reusable dimensions, measures, and intrinsic filters once; dashboards and reports then reference the same names so "revenue", "pipeline", or "win rate" stay consistent across surfaces.
The legacy inline-query shape (
object+categoryField+valueField+aggregatedirectly on the widget) was removed in the ADR-0021 single-form cutover. Define a dataset and bind widgets to it instead.
import { defineDataset } from '@objectstack/spec/ui';
export const salesDataset = defineDataset({
name: 'sales',
label: 'Sales',
object: 'opportunity',
include: ['account'],
dimensions: [
{ name: 'region', field: 'account.region', type: 'string' },
{ name: 'close_month', field: 'close_date', type: 'date', dateGranularity: 'month' },
],
measures: [
{ name: 'revenue', field: 'amount', aggregate: 'sum' },
{ name: 'deal_count', aggregate: 'count' },
],
});
export const salesDashboard = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{
id: 'revenue_by_region',
title: 'Revenue by Region',
type: 'bar',
dataset: 'sales',
dimensions: ['region'],
values: ['revenue'],
layout: { x: 0, y: 0, w: 6, h: 4 },
},
],
};Each widget requires a dataset and at least one values entry. The widget's
dimensions and values must reference names declared on the bound dataset.
At runtime, dataset queries are executed through the analytics service. When
@objectstack/plugin-security is registered, analytics auto-bridges to
security.getReadFilter(object, context) and applies the caller's RLS scope to
the base object and joined objects.
ObjectUI currently renders dataset-backed metric, chart,
table/list, pivot, funnel, and gauge-style widgets with type-aware cells,
field/option labels, date bucketing, and numeric/currency formatting.
table and pivot widgets are also drill-through (see
Drill-down below); the dataset preserves raw group keys so the
generated filter navigates to the exact underlying records.
Chart Types
| Type | Description | Best For |
|---|---|---|
metric | Single number KPI | Revenue, count, percentage |
bar | Bar chart (vertical/horizontal) | Category comparison |
line | Line chart | Trends over time |
pie | Pie/donut chart | Distribution |
area | Area chart | Volume over time |
scatter | Scatter plot | Correlation |
radar | Radar chart | Multi-dimensional comparison |
funnel | Funnel chart | Conversion stages |
gauge | Gauge (single value) | Progress toward goal |
treemap | Tree map | Hierarchical proportions |
table | Data table widget | Detailed records |
pivot | Pivot table | Cross-tab summaries |
Additional types: horizontal-bar, column, donut, sankey, solid-gauge,
kpi, and bullet. The default type is metric.
Drill-down
table and pivot widgets are drill-through: clicking an aggregated row
(table) or cell (pivot) opens a side drawer listing the underlying records
behind that group. Because the dataset preserves each grouped row's raw group
keys, the drawer's filter matches the exact records — no label-to-id guessing.
The drilled record list is itself drill-to-record: click any row in the drawer to open that single record's detail. This completes the group → records → record chain (e.g. Revenue by Region → the orders in the West bucket → one order).
The drawer also offers an "Open in list →" escape hatch — escalate the peek
to the object's full list page (sort / bulk-select / export / shareable URL),
scoped by the same drill filter. In-place drawer is the default (keep context);
the escape hatch is there when you need the full surface. Reports drill
identically: a summary / matrix report opens the same in-place drawer on
row/cell click, so dashboard and report drill behave the same.
// A drill-through table widget. No drill configuration is required — it is
// automatic whenever the dataset exposes the base object and the widget groups
// by at least one dimension.
{
id: 'orders_by_region',
title: 'Orders by Region',
type: 'table',
dataset: 'sales',
dimensions: ['region'],
values: ['revenue', 'deal_count'],
layout: { x: 0, y: 0, w: 6, h: 4 },
}Drill-through is automatic — there is no per-widget drill configuration in the
dataset form. metric and chart widgets render the aggregate only and are not
click-drillable; expose the detail through a table/pivot widget instead.
Renderer note. Object/record-backed list and table surfaces (and the ObjectUI renderer's
options.drillDownblock) support a richer drill model — amode: 'filter' | 'record'discriminator, atargetof'drawer'/'dialog'/'navigate'(the last opens the list page directly), a column whitelist, and chart-segment drill for thescatter/treemap/sankeyfamilies. Those knobs live in the renderer; dataset-bound dashboards drill through the semantic layer as described above (and get the "Open in list →" escape hatch from the host app, e.g. the console).
Aggregation Functions
Aggregation is declared on the dataset's measures (via aggregate), not on
the widget. Supported functions:
| Function | Description |
|---|---|
count | Count records |
sum | Sum values |
avg | Average values |
min | Minimum value |
max | Maximum value |
count_distinct | Count unique values |
Widget Layout
Widgets are positioned on a 12-column grid:
layout: {
x: 0, // Column position (0-11)
y: 0, // Row position
w: 6, // Width in columns (1-12)
h: 4, // Height in rows
}Date Range
Configure a global time filter that applies to all widgets:
dateRange: {
field: 'created_at',
defaultRange: 'this_month',
allowCustomRange: true,
}Preset Ranges
| Range | Description |
|---|---|
today | Current day |
yesterday | Previous day |
this_week | Current week |
last_week | Previous week |
this_month | Current month |
last_month | Previous month |
this_quarter | Current quarter |
last_quarter | Previous quarter |
this_year | Current year |
last_year | Previous year |
last_7_days | Rolling 7 days |
last_30_days | Rolling 30 days |
last_90_days | Rolling 90 days |
custom | User-defined range |
The thirteen named ranges are published as DATE_RANGE_PRESETS in
@objectstack/spec/ui — the vocabulary's single source of truth. custom is not
one of them: it selects no window, it opens the picker.
Global Filters
Add interactive filter controls that apply to all widgets:
globalFilters: [
// `account.region` is a DOTTED PATH walked from the bound widget's
// `dataset.object` (`opportunity`) through the `include: ['account']` the
// dataset declares. It is not the dataset's dimension that is also spelled
// `region` — a filter's `field` never reads dimension names.
{ name: 'region', field: 'account.region', label: 'Region', type: 'select' },
{ field: 'owner', label: 'Sales Rep', type: 'lookup' },
]Each filter's name is its stable identity: the key its value is published
under as a dashboard-level variable (readable in widget expressions as
page.<name>) and the key widgets reference in filterBindings. It defaults
to field; the name dateRange is reserved for the built-in date range. The
filter above names itself region for exactly that reason — left to default it
would be keyed account.region, dots and all, in every expression and binding.
Where a Filter's field Resolves
A global filter's field resolves against the object behind each bound
widget's dataset (dataset.object) — never against that dataset's declared
dimensions. Widget dimensions are selected from the dataset by name.
They are two different namespaces:
| Key | Resolved against | Rejected by |
|---|---|---|
globalFilters[].field (after any filterBindings re-target) | The bound widget's dataset.object — a bare name against that object's own fields, a dotted relationship.field path hop by hop with its prefix declared in the dataset's include | dashboard-filter-field-unknown, severity error |
widgets[].dimensions[] | The dataset's declared dimensions, by name | widget-dimension-unknown, severity error |
Two consequences follow, and their asymmetry is the part that surprises authors:
- A filter needs no dataset dimension. The Complete Example
below filters on
projectandassignee, neither of which theproject_tasksdataset declares — both are fine, because theproject_taskobject has those fields. - Declaring a dimension confers no filterability, and a filterable field is not group-by-able. The same field can be filterable and un-group-by-able on one dataset: to filter on it the object must have it, and to group by it the dataset must declare it as a dimension. Declaring either buys the other nothing.
A filter naming a field the widget's own object lacks makes that widget's query
address a column that does not exist. os validate and the runtime publish door
both refuse the board rather than shipping it — see
Dangling widget bindings.
Opt a single widget out with filterBindings: { <name>: false } (see
Per-Widget Filter Bindings below).
This describes the authorable surface — what a dashboard may declare, and what
author-time validation accepts. It is not a claim about the analytics query API,
which does accept an object's own field as an ad-hoc dimension without the dataset
declaring it; widget-dimension-unknown is what holds the line for authored
dashboards.
Date Filter Defaults
A type: 'date' filter's defaultValue must be a value the dashboard can
actually resolve to a window. Three spellings qualify:
| Spelling | Example | Means |
|---|---|---|
| Preset name | last_7_days | The preset range of that name (table above) |
| ISO date | 2026-01-15, 2026-01-15T08:30:00Z | That day exactly |
| Date macro | {today}, {30_days_ago} | Resolved at query time |
globalFilters: [
{ field: 'created_at', label: 'Date Range', type: 'date', defaultValue: 'last_7_days' },
]Anything else is rejected at author time. This matters because the failure it
replaces was silent: an unrecognised name cannot be lifted to a range, so it fell
through to "a bare string date means equality on that day" and produced
created_at = 'last_7_dayz' — a condition no row matches, which the backend
answers 200 OK with a zero. Every tile read 0 while the filter bar showed
"All time", so the dashboard looked deliberately empty rather than misconfigured.
custom is not accepted here. It is a dateRange.defaultRange sentinel
meaning "open the picker with no preset applied", and a bare filter value gives
it no from/to to hand over.
Per-Widget Filter Bindings
By default a filter applies to its own field on every widget (the date
range defaults to dateRange.field ?? 'created_at'). When a widget stores
the concept under a different field — or should ignore a filter — declare
filterBindings on the widget:
widgets: [
// Default binding: dateRange → created_at, region → region.
{ id: 'invoices_by_status', /* … */ },
// This widget's own fields differ — map each filter explicitly.
{
id: 'accounts_signed',
filterBindings: { dateRange: 'signed_at', region: 'sales_region' },
/* … */
},
// Opt out of a filter with `false`.
{ id: 'total_invoices', filterBindings: { region: false }, /* … */ },
]Binding precedence: an explicit filterBindings entry (string override or
false opt-out) → the filter's legacy targetWidgets allow-list → the
filter's own field.
Complete Example
First define the dataset the widgets bind to:
import { defineDataset } from '@objectstack/spec/ui';
export const projectTasksDataset = defineDataset({
name: 'project_tasks',
label: 'Project Tasks',
object: 'project_task',
dimensions: [
{ name: 'status', field: 'status', type: 'string' },
{ name: 'priority', field: 'priority', type: 'string' },
{ name: 'completed_month', field: 'completed_at', type: 'date', dateGranularity: 'month' },
],
measures: [
{ name: 'open_count', aggregate: 'count', filter: { status: { $ne: 'done' } } },
{ name: 'done_count', aggregate: 'count', filter: { status: 'done' } },
{ name: 'task_count', aggregate: 'count' },
],
});Then bind the dashboard widgets to it:
const projectDashboard = {
name: 'project_overview',
label: 'Project Overview',
description: 'Real-time project health metrics',
refreshIntervalSeconds: 60,
dateRange: {
field: 'created_at',
defaultRange: 'this_month',
allowCustomRange: true,
},
globalFilters: [
{ field: 'project', label: 'Project', type: 'lookup' },
{ field: 'assignee', label: 'Team Member', type: 'lookup' },
],
widgets: [
{
id: 'open_tasks',
title: 'Open Tasks',
type: 'metric',
dataset: 'project_tasks',
values: ['open_count'],
layout: { x: 0, y: 0, w: 3, h: 2 },
},
{
id: 'completed_this_week',
title: 'Completed This Week',
type: 'metric',
dataset: 'project_tasks',
values: ['done_count'],
layout: { x: 3, y: 0, w: 3, h: 2 },
},
{
id: 'tasks_by_status',
title: 'Tasks by Status',
type: 'pie',
dataset: 'project_tasks',
dimensions: ['status'],
values: ['task_count'],
layout: { x: 6, y: 0, w: 3, h: 4 },
},
{
id: 'tasks_by_priority',
title: 'Tasks by Priority',
type: 'bar',
dataset: 'project_tasks',
dimensions: ['priority'],
values: ['task_count'],
layout: { x: 9, y: 0, w: 3, h: 4 },
},
{
id: 'completion_trend',
title: 'Completion Trend',
type: 'line',
dataset: 'project_tasks',
dimensions: ['completed_month'],
values: ['task_count'],
layout: { x: 0, y: 4, w: 12, h: 4 },
},
],
};Related
- App Metadata — Reference dashboards in app navigation
- View Metadata — List views for record browsing
- Page Metadata — Custom pages with component layouts
Actions
Declare buttons as metadata with server-side behavior — bind them to lists, records and navigation, permission-check both sides, and expose them to AI.
Report Metadata
Analytics reports as metadata — the four report shapes, dataset binding, drill-through, and how a report differs from a list view and a dashboard widget