Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,26 +276,26 @@ export const GlobalFilterSchema = lazySchema(() => z.object({
* Dashboard Schema
* Represents a page containing multiple visualizations.
*
* @example Sales Executive Dashboard
* @example Sales Executive Dashboard (ADR-0021: widgets bind a `dataset` and
* select `dimensions`/`values` BY NAME — each metric/measure is defined once in
* `defineDataset`, never inline on the widget)
* {
* name: "sales_overview",
* label: "Sales Executive Overview",
* widgets: [
* {
* title: "Total Pipe",
* type: "metric",
* object: "opportunity",
* valueField: "amount",
* aggregate: "sum",
* dataset: "opportunity_metrics",
* values: ["amount_sum"],
* layout: { x: 0, y: 0, w: 3, h: 2 }
* },
* {
* title: "Revenue by Region",
* type: "bar",
* object: "order",
* categoryField: "region",
* valueField: "total",
* aggregate: "sum",
* dataset: "order_metrics",
* dimensions: ["region"],
* values: ["total_sum"],
* layout: { x: 3, y: 0, w: 6, h: 4 }
* }
* ]
Expand Down
76 changes: 65 additions & 11 deletions skills/objectstack-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,8 +531,9 @@ export const CrmApp = App.create({
## Dashboards

Dashboards are a grid of widgets (`columns` × `rowHeight`) sharing a
`dateRange` scrubber and `globalFilters`. Each widget declares an `object`,
an `aggregate` measure or chart spec, and a `layout: {x,y,w,h}`.
`dateRange` scrubber and `globalFilters`. Each widget **binds a `dataset`** and
selects named `dimensions` + `values`, picks a chart `type`, and sets a
`layout: {x,y,w,h}` (ADR-0021).

### Widget Types

Expand All @@ -552,12 +553,15 @@ modifier; date bucketing comes from the bound dataset dimension's

### Dataset-Bound Widgets

For shared metrics, prefer the ADR-0021 dataset shape over per-widget inline
queries. A widget binds to `dataset` and selects named `dimensions` and
`values`; the dataset owns the base object, allowed joins, intrinsic filter,
dimensions, and certified measures. Reports bind the same way (`dataset` +
`rows` + `values` + `runtimeFilter`). Full guide: **Guides → Analytics Datasets**
(`content/docs/data-modeling/analytics.mdx`).
Every persisted chart is **dataset-backed** (ADR-0021 single-form cutover): a
dashboard widget, a report, and a list `type:'chart'` view all **bind a `dataset`
and select named `dimensions` + `values`**; the dataset owns the base object,
allowed joins, intrinsic filter, dimensions, and certified measures. The legacy
per-widget inline query (`object` + `categoryField` + `valueField` + `aggregate`)
**was removed** — a widget now requires `dataset` + `values`; the inline fields are
dropped and a widget lacking `dataset` fails `os validate`. Reports bind the same
way (`dataset` + `rows` + `values` + `runtimeFilter`). Full guide: **Guides →
Analytics Datasets** (`content/docs/data-modeling/analytics.mdx`).

A widget's presentation-scope `filter` flows into the query as the runtime
filter; keep `filter` on the widget when binding a dataset.
Expand All @@ -574,9 +578,59 @@ filter; keep `filter` on the widget when binding a dataset.
}
```

- Dataset-bound widgets need at least one `values` entry.
- Do not mix `dataset` with inline `object` / `valueField` / `aggregate`
unless you are intentionally keeping a legacy inline widget shape.
**The real decision is not "inline vs dataset" — it is "can a dataset express
this?"** The shape is already fixed (always a dataset), so what you decide is
whether the *data need fits the dataset envelope*, and if not, which lower layer to
escalate to. Decide on **expressibility**; reuse/governance is Level B.

**Level A — the dataset envelope:**

| Fits a dataset → author one | Beyond the envelope → escalate |
|:--|:--|
| one base object + **to-one** joins (`include`, ≤3 hops) | a join that **changes grain** / a **to-many** rollup onto the parent |
| 0..N dimensions; date-bucket `day/week/month/quarter/year` | a **computed dimension** / CASE bucket / numeric bin |
| measures `count/sum/avg/min/max/count_distinct` | `array_agg`/`string_agg` or any custom-SQL metric |
| **derived measures** — `ratio/sum/difference/product` of other measures | scalar math on raw fields (`amount*0.8`), aggregate-of-aggregate |
| WHERE (`$and/$or/$not` on the base object) + measure-scoped filters | **HAVING** (filtering the aggregate result) |
| `compareTo` (previous period/year) + `totals` (matrix subtotals) | **window** (rank, running total, lag/lead, %-of-total); **union**; reshaping params |

> **The iron rule:** a dataset is a governed, *narrow* semantic layer — NOT a
> general analytics escape hatch (no raw SQL, no hand-authored joins, no
> window/having). If the need is in the right column, a dataset **cannot** express
> it — escalate to a hand-authored **Cube** (raw SQL / explicit joins), a **stored
> rollup or formula field** on the object (to-many rollups, computed columns), or
> app code. Do not force it into a dataset: it fails to compile or renders an empty
> series.

Standardized answers to the recurring ambiguous cases:

- **"Count of child tasks per project."** Base the dataset on the **child** (`task`)
and group by the parent lookup (`project`) — grain = child. A rollup onto the
**parent** grain ("on `project`, count related tasks") is a to-many rollup:
**not** dataset-expressible — use a **stored rollup field** on `project`.
- **To-one enrichment** ("revenue by `account.industry`") is fine and does **not**
change grain — put `account` in `include`, add `account.industry` as a dimension.
- **Computed column.** Formatting/currency → a measure's `format`/`currency`;
arithmetic over declared measures (`margin = difference(revenue, cost)`) → a
**derived measure**; CASE / bins / `revenue*0.8` / computed dimensions → **not** a
dataset.
- **Filter by a parent's attribute** → model it as a **dimension** (guaranteed to
join); a lookup-path *filter* is not a reliable analytics-path construct.
- **A dashboard filter driving several charts** (date/region) → **not** a dataset:
a dashboard variable + per-chart `filterBindings` broadcast into each chart's
WHERE (#2501). A dataset is implied only when a parameter **reshapes** the query
(grain/window/join) — and those are beyond the envelope anyway.

**Level B — naming is governance, not expressibility.** An inline dataset draft
(Studio Live Canvas) and a saved named dataset have **identical** expressibility;
naming one is a reuse/governance call (canonical/shared metric, RLS, shared
labels/formats → `defineDataset`). Persisted widgets already require a named
dataset, so Level B only surfaces in Studio previews and hand-coded react-page
`<ObjectChart>` blocks (a single-object inline `aggregate`, no dataset binding).

- Dataset-bound widgets need at least one `values` entry, and every
`dataset`/`dimensions`/`values` name must resolve to its `defineDataset` —
`os validate` fails on an unresolved name (an empty chart otherwise).
- Studio's Dashboard Widget Inspector can author per-widget `dataset`,
`dimensions`, and `values`; curated metadata-admin forms merge
server-only fields back into the payload, so saving through Studio should
Expand Down
56 changes: 56 additions & 0 deletions skills/objectstack-ui/evals/analytics-inline-vs-dataset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"skill_name": "objectstack-ui",
"description": "Inline-vs-dataset / dataset-envelope-ceiling decisions (issue #2502). Every persisted chart is dataset-backed (ADR-0021); the real decision is whether the data need fits the dataset envelope, and if not, which lower layer to escalate to. Naming an inline draft vs a saved dataset is governance, not expressibility.",
"evals": [
{
"id": 1,
"prompt": "On the sales dashboard, add several widgets that all report the same certified 'revenue' metric (sum of amount) — one grouped by region, one by month, one as a KPI total. Emit the ObjectStack metadata.",
"expected_output": "Defines the metric ONCE in a dataset (defineDataset with an amount_sum measure + region and a month-granularity date dimension), then binds each widget to that dataset and selects dimensions/values BY NAME. Does not restate the aggregate inline per widget (the ADR-0021 inline object/valueField/aggregate shape was removed).",
"files": [],
"assertions": {
"must_contain": ["defineDataset", "dataset", "dimensions", "values"],
"must_not_contain": ["valueField", "categoryField"]
}
},
{
"id": 2,
"prompt": "Add a dashboard chart showing 30-day rolling revenue (a moving average / running total over time). Model the data need.",
"expected_output": "Recognizes that a window/rolling calculation is BEYOND the dataset envelope (a dataset has no window/having grammar and compareTo only does previous-period/year). Escalates to a hand-authored Cube (raw SQL) or a stored/materialized rolling field — it does not pretend a dataset date-bucket or a plain measure expresses a moving window.",
"files": [],
"assertions": {
"must_contain": ["window", "Cube"],
"must_not_contain": ["dateGranularity"]
}
},
{
"id": 3,
"prompt": "On a projects dashboard I want each project row to show its count of related child tasks (task has a lookup to project). Model it.",
"expected_output": "Explains that a to-many rollup ONTO the parent grain is not expressible in a dataset (datasets only do to-one joins; to-many traversal is out of scope). Recommends a stored rollup/summary field on project, OR basing the dataset on the child task and grouping by the project lookup (grain = child). Does not author a to-many join in include.",
"files": [],
"assertions": {
"must_contain": ["rollup", "to-many"],
"must_not_contain": []
}
},
{
"id": 4,
"prompt": "A dashboard has a top-level date-range filter that must re-scope six charts built over different objects. How should each chart pick up the filter? Emit the metadata.",
"expected_output": "Uses a dashboard-level variable/filter plus per-chart filterBindings mapping the filter to each widget's own date field (e.g. invoice→created_at, account→signed_at), broadcast into each chart's WHERE (#2501). Charts stay self-contained; it does NOT invent a shared dataset for the filter (a dataset is only implied when a parameter reshapes the query grain/window/join).",
"files": [],
"assertions": {
"must_contain": ["filterBindings"],
"must_not_contain": []
}
},
{
"id": 5,
"prompt": "In a kind:'react' page, drop a quick one-off bar chart of tasks grouped by status. Write the block.",
"expected_output": "Uses the inline <ObjectChart objectName=\"task\" aggregate={{ field: '_id', function: 'count', groupBy: 'status' }} /> block directly — an ad-hoc in-page chart does not require a dataset, and the react ObjectChart block has no dataset prop. Naming a dataset would only be for reuse/governance (Level B), not needed here.",
"files": [],
"assertions": {
"must_contain": ["ObjectChart", "aggregate", "groupBy"],
"must_not_contain": ["defineDataset"]
}
}
]
}