@@ -349,8 +349,8 @@ an `aggregate` measure or chart spec, and a `layout: {x,y,w,h}`.
349349See the ** Production Pattern** section below for the full
350350` Dashboard ` shape with ` refreshInterval ` , header actions, date range,
351351global filters, widget options, and the period-over-period (` compareTo ` )
352- / date- bucketing ( ` categoryGranularity ` ) modifiers available on every
353- data-bound widget .
352+ modifier; date bucketing comes from the bound dataset dimension's
353+ ` dateGranularity ` (ADR-0021) .
354354
355355### Dataset-Bound Widgets
356356
@@ -403,27 +403,29 @@ filter; keep `filter` on the widget when binding a dataset.
403403``` typescript
404404import type { ReportInput } from ' @objectstack/spec/ui' ;
405405
406+ // ADR-0021: a report binds a `dataset` and selects `rows` (dimensions) +
407+ // `values` (measures) BY NAME. The `opportunity_metrics` dataset defines the
408+ // object, the `amount_sum` measure, and the `forecast_category` + `close_date`
409+ // (dateGranularity: 'quarter') dimensions — see Guides → Analytics Datasets.
406410export const PipelineCoverageReport: ReportInput = {
407411 name: ' pipeline_coverage_by_quarter' ,
408412 label: ' Pipeline Coverage (Quarter)' ,
409- objectName: ' opportunity' ,
410413 type: ' matrix' ,
411- columns: [
412- { field: ' name' , label: ' Opportunity' },
413- { field: ' amount' , label: ' Amount' , aggregate: ' sum' },
414- ],
415- groupingsDown: [{ field: ' forecast_category' , sortOrder: ' asc' }],
416- groupingsAcross: [{ field: ' close_date' , dateGranularity: ' quarter' }],
417- filter: { stage: { $ne: ' closed_lost' } },
418- chart: { type: ' bar' , xAxis: ' forecast_category' , yAxis: ' amount' },
414+ dataset: ' opportunity_metrics' ,
415+ rows: [' forecast_category' , ' close_date' ],
416+ values: [' amount_sum' ],
417+ runtimeFilter: { stage: { $ne: ' closed_lost' } },
418+ chart: { type: ' bar' , xAxis: ' forecast_category' , yAxis: ' amount_sum' },
419419};
420420```
421421
422- > ** ` dateGranularity ` ** on a grouping (` day | week | month | quarter | year ` )
423- > tells the server to bucket date fields in a single aggregate query — do
424- > ** not** pre-compute virtual columns for this.
425- > ** ` groupingsDown ` ** drives row groupings (summary). Add ` groupingsAcross `
426- > to upgrade to a matrix. Multi-level grouping = multiple entries in the array.
422+ > ** ` dateGranularity ` ** lives on the dataset's date ** dimension**
423+ > (` day | week | month | quarter | year ` ); selecting that dimension buckets the
424+ > field server-side in a single aggregate query — do ** not** pre-compute virtual
425+ > columns for this.
426+ > ** ` rows ` ** are the report's grouping dimensions (selected from the dataset by
427+ > name). A ` summary ` groups by them; a ` matrix ` cross-tabs them. Multi-level
428+ > grouping = multiple dimension names in the array.
427429
428430---
429431
@@ -537,7 +539,7 @@ Use this CRM-style structure as the canonical UI assembly reference:
537539| ** Public / anonymous form** | ` src/views/*.view.ts ` (formView with ` sharing.allowAnonymous: true ` ) | Web-to-Lead / Web-to-Case. Auto-exposed at ` GET/POST /api/v1/forms/:slug ` . See ` guides/public-forms.mdx ` |
538540| App navigation | ` src/apps/*.app.ts ` | Use grouped nav trees, ` viewName ` shortcuts, and ` requiresObject ` for capability-aware visibility |
539541| Dashboards | ` src/dashboards/*.dashboard.ts ` | Combine KPI + chart + table widgets with shared ` dateRange ` and ` globalFilters ` |
540- | Reports | ` src/reports/*.report.ts ` | Prefer ` groupingsDown ` + ` groupingsAcross ` + ` dateGranularity ` for matrix /summary analytics |
542+ | Reports | ` src/reports/*.report.ts ` | Bind a ` dataset ` + ` rows ` (dimensions) + ` values ` (measures) for tabular /summary/matrix/joined analytics |
541543| Record pages | ` src/pages/*.page.ts ` | Compose ` regions ` + components (` page:header ` , ` record:highlights ` , related lists, tabs) |
542544| User actions | ` src/actions/*.actions.ts ` | Use ` flow ` for orchestration and ` modal ` for parameterized bulk mutations |
543545
@@ -559,8 +561,8 @@ ObjectUI capabilities exist and prefer the protocol-native shape:
559561| Line-item grids | Spreadsheet editing, computed cells, ghost row, lookup auto-fill, duplicate, drag reorder, subtotal/tax/total | Keep line fields on the child object; use ` position ` /` sort_order ` and summary fields |
560562| Record detail | Derived related lists, action slots, system/audit sections, record-page assignment, optional reference rail | Let relationships derive related lists unless a record page needs bespoke placement |
561563| Pages | Page create flows, block canvas, slotted record pages, block property inspectors, nested container blocks | Use Page metadata for layout; use full Action objects in ` page:header.properties.actions ` |
562- | Dashboards | Metric/chart/list/pivot/funnel/table widgets, drill-downs, type-aware cells, date bucketing, dataset-bound widgets | Prefer dataset-bound widgets for governed metrics; inline object widgets remain valid |
563- | Reports | Spec-native tabular/summary/matrix/joined reports, chart/KPI blocks, drill-downs, dataset-bound reports | Prefer ` groupingsDown ` / ` groupingsAcross ` / ` dataset ` over ad hoc client shaping |
564+ | Dashboards | Metric/chart/list/pivot/funnel/table widgets, drill-downs, type-aware cells, date bucketing, dataset-bound widgets | Bind every widget to a ` dataset ` + ` values ` (+ ` dimensions ` ); the inline object/valueField/aggregate form was removed (ADR-0021) |
565+ | Reports | Spec-native tabular/summary/matrix/joined reports, chart/KPI blocks, drill-downs, dataset-bound reports | Bind a ` dataset ` + ` rows ` + ` values ` ; joined reports carry dataset-bound ` blocks ` |
564566| Actions | Row/global/header actions, modal parameter collection, visible CEL, popup-safe opens, nested action runner sharing | Define actions as metadata; use row context/defaultFromRow instead of custom code |
565567| Flow designer | Typed node config panels, trigger/decision forms, reference pickers, simulator/debug runner | Author flows with typed config, not advanced JSON fallbacks |
566568| Console utilities | Integrations & APIs, public forms, flow runs, approvals inbox, settings, marketplace/package management, AI draft review/publish | Link app navigation to these surfaces with capability gates where appropriate |
@@ -603,15 +605,18 @@ export const SalesDashboard: Dashboard = {
603605 optionsFrom: { object: ' user' , valueField: ' id' , labelField: ' name' } },
604606 ],
605607
608+ // ADR-0021: widgets bind a semantic `dataset` and select dimensions/measures
609+ // BY NAME (the `opportunity_metrics` / `order_metrics` datasets define the base
610+ // object, measures, and date dimensions — see Guides → Analytics Datasets). The
611+ // widget `filter` is the presentation-scope runtimeFilter.
606612 widgets: [
607613 {
608614 id: ' total_pipeline_value' , type: ' metric' ,
609615 title: ' Total Pipeline' ,
610- object : ' opportunity ' ,
616+ dataset : ' opportunity_metrics ' , values: [ ' total_amount ' ] ,
611617 filter: { stage: { $nin: [' closed_won' , ' closed_lost' ] } },
612- valueField: ' amount' , aggregate: ' sum' ,
613618 layout: { x: 0 , y: 0 , w: 3 , h: 2 },
614- options: { icon: ' DollarSign' , format: ' 0,0 ' },
619+ options: { icon: ' DollarSign' }, // the measure's own `format` drives the number
615620 // Period-over-period: renderer fetches the prior quarter and
616621 // surfaces a secondary value + delta arrow automatically.
617622 compareTo: ' previousPeriod' ,
@@ -620,13 +625,13 @@ export const SalesDashboard: Dashboard = {
620625
621626 // Chart widget with comparison overlay (M2). The renderer issues a
622627 // second query with the time window shifted by `compareTo` and
623- // overlays it as a muted/dashed series.
628+ // overlays it as a muted/dashed series. The date axis is a dataset
629+ // dimension whose monthly bucketing lives on the dataset (`dateGranularity`).
624630 {
625631 id: ' revenue_vs_last_year' , type: ' line' ,
626632 title: ' Revenue — This Year vs Last' ,
627- object : ' order ' ,
633+ dataset : ' order_metrics ' , dimensions: [ ' closed_at ' ], values: [ ' total_sum ' ] ,
628634 filter: { closed_at: { $gte: ' {current_year_start}' , $lte: ' {current_year_end}' } },
629- categoryField: ' closed_at' , valueField: ' total' , aggregate: ' sum' ,
630635 compareTo: ' previousYear' ,
631636 layout: { x: 3 , y: 0 , w: 9 , h: 4 },
632637 },
@@ -670,63 +675,63 @@ no second `filter` is required.
670675 date-bound clauses.
671676
672677``` typescript
673- // Metric — WoW delta
674- { id : ' done_this_week' , type : ' metric' , object : ' task ' ,
678+ // Metric — WoW delta (binds the task_metrics dataset; filter = runtimeFilter)
679+ { id : ' done_this_week' , type : ' metric' , dataset : ' task_metrics ' , values : [ ' task_count ' ] ,
675680 filter : { assignee : ' {current_user_id}' , status : ' done' ,
676681 completed_at : { $gte : ' {week_start}' } },
677- aggregate : ' count' ,
678682 compareTo : ' previousPeriod' }
679683
680684// Bar — YoY overlay on a stable category set
681- { id : ' headcount_by_dept' , type : ' bar' , object : ' employee' ,
685+ { id : ' headcount_by_dept' , type : ' bar' , dataset : ' employee_metrics' ,
686+ dimensions : [' department' ], values : [' headcount' ],
682687 filter : { status : { $ne : ' terminated' } },
683- aggregate : ' count' , categoryField : ' department' ,
684688 compareTo : ' previousYear' }
685689```
686690
687- ### Server-side date bucketing — ` categoryGranularity `
688-
689- For any chart with ` categoryField ` pointing at a date/datetime field, set
690- ` categoryGranularity ` to bucket values server-side. Without it every
691- distinct timestamp becomes its own category, which collapses a 12-row
692- seed dataset into a 12-point flat-line chart.
691+ ### Server-side date bucketing — ` dateGranularity ` (ADR-0021)
693692
694- | Value | Bucket key |
695- | :--| :--|
696- | ` 'day' ` | Calendar day (` YYYY-MM-DD ` ) |
697- | ` 'week' ` | ISO week (` YYYY-Www ` ) |
698- | ` 'month' ` | Calendar month (` YYYY-MM ` ) |
699- | ` 'quarter' ` | Calendar quarter (` YYYY-Qn ` ) |
700- | ` 'year' ` | Calendar year (` YYYY ` ) |
701-
702- * ** Engine support** — Postgres ` date_trunc ` , MySQL ` date_format ` , SQLite
703- ` strftime ` , MongoDB ` $dateTrunc ` , in-memory ` bucketDateValue ` fallback.
704- All emitted by the analytics service, not the client.
705- * ** Rule of thumb** — ` day ` for ≤30d windows, ` week ` for ~ 90d, ` month `
706- for 6–12 months, ` quarter ` for multi-year, ` year ` for retention /
707- compliance scopes.
708- * ** Combines with ` compareTo ` ** — the comparison query is issued with the
709- same granularity, so the muted overlay aligns bucket-for-bucket.
710- * ** xAxis format** — pair with a matching ` chartConfig.xAxis.format `
711- (` %b %d ` for day, ` %b %Y ` for month, etc.) so the rendered labels
712- match the bucket grain.
693+ Date bucketing lives on the ** dataset dimension** , not the widget. Give a date
694+ dimension a ` dateGranularity ` and any presentation that selects it groups by that
695+ bucket server-side — without it every distinct timestamp becomes its own
696+ category, collapsing a 12-row seed into a 12-point flat line. (The old widget-level
697+ ` categoryGranularity ` was removed in the single-form cutover.)
713698
714699``` typescript
715- // Line chart — monthly trend with YoY overlay
716- { id : ' signed_by_month' , type : ' line' , object : ' contract' ,
700+ // In the dataset (Guides → Analytics Datasets):
701+ defineDataset ({
702+ name: ' contract_metrics' , object: ' contract' ,
703+ dimensions: [{ name: ' signed_date' , field: ' signed_date' , type: ' date' , dateGranularity: ' month' }],
704+ measures: [{ name: ' signed_count' , aggregate: ' count' }],
705+ });
706+
707+ // The widget just selects the dimension by name:
708+ { id : ' signed_by_month' , type : ' line' ,
709+ dataset : ' contract_metrics' , dimensions : [' signed_date' ], values : [' signed_count' ],
717710 filter : { signed_date : { $gte : ' {12_months_ago}' } },
718- aggregate : ' count' ,
719- categoryField : ' signed_date' ,
720- categoryGranularity : ' month' ,
721- compareTo : ' previousYear' ,
722- chartConfig : {
723- type : ' line' ,
724- xAxis : { field : ' signed_date' , format : ' %b %Y' },
725- yAxis : [{ field: ' value' , format: ' 0,0' }],
726- },
727- }
711+ compareTo : ' previousYear' }
728712```
729713
714+ | ` dateGranularity ` | Rendered bucket label |
715+ | :--| :--|
716+ | ` 'day' ` | ` YYYY-MM-DD ` |
717+ | ` 'week' ` | ISO date of the bucket (` YYYY-MM-DD ` ) |
718+ | ` 'month' ` | ` YYYY-MM ` |
719+ | ` 'quarter' ` | ` YYYY-Qn ` |
720+ | ` 'year' ` | ` YYYY ` |
721+
722+ * ** Engine support** — Postgres ` date_trunc ` , MySQL ` date_format ` , SQLite
723+ ` strftime ` , MongoDB ` $dateTrunc ` , in-memory fallback. All emitted by the
724+ analytics service, not the client.
725+ * ** Human labels are automatic** — the analytics layer formats the bucket value
726+ to the label above, and resolves ` select ` /` lookup ` dimension values to their
727+ option label / related-record name. Measures carry their ` label ` + ` format `
728+ (e.g. ` $0,0 ` ) so KPIs and legends read "Total Spent / $616,000", not
729+ "spent_sum / 616000". Authors do not format dimension/measure values by hand.
730+ * ** Combines with ` compareTo ` ** — the comparison query is issued with the same
731+ granularity, so the muted overlay aligns bucket-for-bucket.
732+ * ** Rule of thumb** — ` day ` for ≤30d windows, ` week ` for ~ 90d, ` month ` for
733+ 6–12 months, ` quarter ` for multi-year, ` year ` for retention / compliance.
734+
730735---
731736
732737## Date Macros — Filter Placeholders
0 commit comments