diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 3a993f4cf..3db5d1673 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -6,6 +6,7 @@ import * as objects from './src/objects/index.js'; import * as views from './src/views/index.js'; import * as apps from './src/apps/index.js'; import * as dashboards from './src/dashboards/index.js'; +import * as datasets from './src/datasets/index.js'; import * as reports from './src/reports/index.js'; import * as pages from './src/pages/index.js'; import * as actions from './src/actions/index.js'; @@ -98,6 +99,7 @@ export default defineStack({ views: Object.values(views), pages: Object.values(pages), dashboards: Object.values(dashboards), + datasets: Object.values(datasets), reports: Object.values(reports), actions: Object.values(actions), themes: [CrmLightTheme, CrmDarkTheme], diff --git a/examples/app-crm/src/dashboards/pipeline.dashboard.ts b/examples/app-crm/src/dashboards/pipeline.dashboard.ts index 45c96cfb4..efcd28734 100644 --- a/examples/app-crm/src/dashboards/pipeline.dashboard.ts +++ b/examples/app-crm/src/dashboards/pipeline.dashboard.ts @@ -38,6 +38,8 @@ export const PipelineDashboard: Dashboard = { aggregate: 'sum', valueField: 'amount', filter: { stage: { $nin: ['closed_won', 'closed_lost'] } }, + dataset: 'opportunity_metrics', + values: ['total_amount'], options: { format: 'currency', currency: 'USD' }, layout: { x: 0, y: 0, w: 4, h: 2 }, }, @@ -57,6 +59,8 @@ export const PipelineDashboard: Dashboard = { }, }, compareTo: 'previousPeriod', + dataset: 'opportunity_metrics', + values: ['total_amount'], options: { format: 'currency', currency: 'USD' }, layout: { x: 4, y: 0, w: 4, h: 2 }, }, @@ -76,6 +80,8 @@ export const PipelineDashboard: Dashboard = { }, }, compareTo: 'previousYear', + dataset: 'opportunity_metrics', + values: ['avg_amount'], options: { format: 'currency', currency: 'USD' }, layout: { x: 8, y: 0, w: 4, h: 2 }, }, @@ -94,6 +100,9 @@ export const PipelineDashboard: Dashboard = { close_date: { $gte: '{1_years_ago}', $lte: '{today}' }, }, compareTo: 'previousYear', + dataset: 'opportunity_metrics', + dimensions: ['close_date'], + values: ['opp_count'], layout: { x: 0, y: 2, w: 8, h: 4 }, }, { @@ -111,6 +120,9 @@ export const PipelineDashboard: Dashboard = { }, }, compareTo: 'previousPeriod', + dataset: 'opportunity_metrics', + dimensions: ['stage'], + values: ['opp_count'], layout: { x: 8, y: 2, w: 4, h: 4 }, }, @@ -125,6 +137,9 @@ export const PipelineDashboard: Dashboard = { valueField: 'amount', categoryField: 'stage', filter: { stage: { $nin: ['closed_won', 'closed_lost'] } }, + dataset: 'opportunity_metrics', + dimensions: ['stage'], + values: ['total_amount'], layout: { x: 0, y: 6, w: 6, h: 4 }, }, ], diff --git a/examples/app-crm/src/datasets/index.ts b/examples/app-crm/src/datasets/index.ts new file mode 100644 index 000000000..951ca72e3 --- /dev/null +++ b/examples/app-crm/src/datasets/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { OpportunityDataset } from './opportunity.dataset.js'; diff --git a/examples/app-crm/src/datasets/opportunity.dataset.ts b/examples/app-crm/src/datasets/opportunity.dataset.ts new file mode 100644 index 000000000..c49d81ba7 --- /dev/null +++ b/examples/app-crm/src/datasets/opportunity.dataset.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +/** + * Opportunity analytics dataset (ADR-0021). + * + * The single semantic source of truth for pipeline metrics. The Pipeline + * dashboard binds every widget to this dataset and selects measures BY NAME + * (`total_amount`, `avg_amount`, `opp_count`) so "pipeline revenue" means the + * same thing on every tile. Single-object dataset (no `include`). + */ +export const OpportunityDataset = defineDataset({ + name: 'opportunity_metrics', + label: 'Opportunity Metrics', + description: 'Semantic layer for sales-pipeline counts and amounts', + object: 'crm_opportunity', + + dimensions: [ + { name: 'stage', label: 'Stage', field: 'stage', type: 'string' }, + { name: 'close_date', label: 'Close Date', field: 'close_date', type: 'date' }, + ], + + measures: [ + { name: 'opp_count', label: 'Opportunities', aggregate: 'count' }, + { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount', format: '$0,0' }, + { name: 'avg_amount', label: 'Avg Deal Size', aggregate: 'avg', field: 'amount', format: '$0,0' }, + ], +}); diff --git a/examples/app-crm/src/reports/sales-by-stage.report.ts b/examples/app-crm/src/reports/sales-by-stage.report.ts index 20ab28aed..59d98cf4b 100644 --- a/examples/app-crm/src/reports/sales-by-stage.report.ts +++ b/examples/app-crm/src/reports/sales-by-stage.report.ts @@ -4,6 +4,12 @@ import type { UI } from '@objectstack/spec'; /** * Example report — total opportunity amount grouped by stage. + * + * ADR-0021 Phase 2: bound to the `opportunity_metrics` dataset (rows = stage, + * values = total_amount) alongside the legacy inline query. The legacy form was + * also corrected to actually group + sum (it previously listed rows flat despite + * its "grouped by stage" label), so both forms compute the same number and the + * reconciliation harness can verify them. */ export const SalesByStageReport: UI.Report = { name: 'crm_sales_by_stage', @@ -13,7 +19,10 @@ export const SalesByStageReport: UI.Report = { type: 'summary', columns: [ { field: 'stage', label: 'Stage' }, - { field: 'name', label: 'Opportunity' }, - { field: 'amount', label: 'Amount' }, + { field: 'amount', label: 'Amount', aggregate: 'sum' }, ], + groupingsDown: [{ field: 'stage', sortOrder: 'asc' }], + dataset: 'opportunity_metrics', + rows: ['stage'], + values: ['total_amount'], }; diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index b661f5ecd..527fbb947 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -8,6 +8,7 @@ import * as objects from './src/objects/index.js'; import { TaskViews, ProjectViews } from './src/views/index.js'; import { ShowcaseApp } from './src/apps/index.js'; import { ChartGalleryDashboard } from './src/dashboards/index.js'; +import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js'; import { allReports } from './src/reports/index.js'; import { allActions } from './src/actions/index.js'; import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage } from './src/pages/index.js'; @@ -115,6 +116,7 @@ export default defineStack({ views: [TaskViews, ProjectViews], pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage], dashboards: [ChartGalleryDashboard], + datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], reports: allReports, actions: allActions, themes: allThemes, diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index e8129f983..a0655affd 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -37,7 +37,7 @@ export const ShowcaseApp = App.create({ icon: 'chart-bar', children: [ { id: 'nav_charts', type: 'dashboard', dashboardName: 'showcase_chart_gallery', label: 'Chart Gallery', icon: 'layout-dashboard' }, - { id: 'nav_report_tabular', type: 'report', reportName: 'showcase_task_list', label: 'Task List', icon: 'table' }, + { id: 'nav_report_tabular', type: 'object', objectName: 'showcase_task', viewName: 'tabular', label: 'Task List', icon: 'table' }, { id: 'nav_report_summary', type: 'report', reportName: 'showcase_hours_by_status', label: 'Hours by Status', icon: 'sigma' }, { id: 'nav_report_matrix', type: 'report', reportName: 'showcase_status_priority_matrix', label: 'Status × Priority', icon: 'grid-3x3' }, { id: 'nav_report_joined', type: 'report', reportName: 'showcase_task_overview', label: 'Task Overview', icon: 'layers' }, diff --git a/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts b/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts index fef384af2..44354a940 100644 --- a/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts +++ b/examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts @@ -4,6 +4,8 @@ import type { Dashboard } from '@objectstack/spec/ui'; const task = 'showcase_task'; const project = 'showcase_project'; +const taskDs = 'showcase_task_metrics'; +const projectDs = 'showcase_project_metrics'; /** * Chart Gallery — one widget per chart family so the dashboard renderer can be @@ -11,6 +13,14 @@ const project = 'showcase_project'; * taxonomy (comparison, trend, distribution, relationship, composition, * performance, tabular) — every type here renders; the taxonomy intentionally * excludes families the renderer cannot draw (geo maps, OHLC, distributions). + * + * ADR-0021 Phase 2: every widget is bound to a semantic dataset + * (`showcase_task_metrics` / `showcase_project_metrics`) and selects + * dimensions/measures BY NAME, side-by-side with the legacy inline query during + * the dual-form window. The reconciliation harness asserts both forms return + * identical numbers (scripts/analytics-reconcile). Date-bucketed trend widgets + * (`created_at` + `categoryGranularity`) stay inline-only for now — dataset + * timeDimension reconciliation is deferred (see the CRM trend widget). */ export const ChartGalleryDashboard: Dashboard = { name: 'showcase_chart_gallery', @@ -19,48 +29,48 @@ export const ChartGalleryDashboard: Dashboard = { columns: 12, widgets: [ // ── Performance / KPI ──────────────────────────────────────────────── - { id: 'kpi_total_tasks', type: 'metric', title: 'Total Tasks', object: task, aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } }, - { id: 'kpi_open_tasks', type: 'kpi', title: 'Open Tasks', object: task, aggregate: 'count', filter: { done: false }, layout: { x: 3, y: 0, w: 3, h: 2 } }, - { id: 'gauge_progress', type: 'gauge', title: 'Avg Progress', object: task, aggregate: 'avg', valueField: 'progress', layout: { x: 6, y: 0, w: 3, h: 2 } }, - { id: 'bullet_budget', type: 'bullet', title: 'Budget vs Spend', object: project, aggregate: 'sum', valueField: 'spent', layout: { x: 9, y: 0, w: 3, h: 2 } }, + { id: 'kpi_total_tasks', type: 'metric', title: 'Total Tasks', object: task, aggregate: 'count', dataset: taskDs, values: ['task_count'], layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'kpi_open_tasks', type: 'kpi', title: 'Open Tasks', object: task, aggregate: 'count', filter: { done: false }, dataset: taskDs, values: ['task_count'], layout: { x: 3, y: 0, w: 3, h: 2 } }, + { id: 'gauge_progress', type: 'gauge', title: 'Avg Progress', object: task, aggregate: 'avg', valueField: 'progress', dataset: taskDs, values: ['avg_progress'], layout: { x: 6, y: 0, w: 3, h: 2 } }, + { id: 'bullet_budget', type: 'bullet', title: 'Budget vs Spend', object: project, aggregate: 'sum', valueField: 'spent', dataset: projectDs, values: ['spent_sum'], layout: { x: 9, y: 0, w: 3, h: 2 } }, // ── Comparison ─────────────────────────────────────────────────────── - { id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 0, y: 2, w: 4, h: 4 } }, - { id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', object: task, aggregate: 'count', categoryField: 'priority', layout: { x: 4, y: 2, w: 4, h: 4 } }, - { id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', object: task, aggregate: 'sum', valueField: 'estimate_hours', categoryField: 'status', layout: { x: 8, y: 2, w: 4, h: 4 } }, - { id: 'stacked_bar', type: 'stacked-bar', title: 'Status × Priority', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 0, y: 6, w: 4, h: 4 } }, - { id: 'grouped_bar', type: 'grouped-bar', title: 'Grouped Status', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 4, y: 6, w: 4, h: 4 } }, + { id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 2, w: 4, h: 4 } }, + { id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 2, w: 4, h: 4 } }, + { id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', object: task, aggregate: 'sum', valueField: 'estimate_hours', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 2, w: 4, h: 4 } }, + { id: 'stacked_bar', type: 'stacked-bar', title: 'Status × Priority', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 6, w: 4, h: 4 } }, + { id: 'grouped_bar', type: 'grouped-bar', title: 'Grouped Status', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 4, y: 6, w: 4, h: 4 } }, - // ── Trend ──────────────────────────────────────────────────────────── - { id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', layout: { x: 8, y: 6, w: 4, h: 4 } }, - { id: 'area_created', type: 'area', title: 'Cumulative (area)', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', layout: { x: 0, y: 10, w: 4, h: 4 } }, - { id: 'stacked_area', type: 'stacked-area', title: 'Stacked Area', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', layout: { x: 4, y: 10, w: 4, h: 4 } }, - { id: 'spline_trend', type: 'spline', title: 'Smoothed Trend', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'week', layout: { x: 8, y: 10, w: 4, h: 4 } }, + // ── Trend (date-bucketed via timeDimension granularity) ────────────── + { id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 8, y: 6, w: 4, h: 4 } }, + { id: 'area_created', type: 'area', title: 'Cumulative (area)', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 0, y: 10, w: 4, h: 4 } }, + { id: 'stacked_area', type: 'stacked-area', title: 'Stacked Area', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'month', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 4, y: 10, w: 4, h: 4 } }, + { id: 'spline_trend', type: 'spline', title: 'Smoothed Trend', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'week', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 8, y: 10, w: 4, h: 4 } }, // ── Distribution ───────────────────────────────────────────────────── - { id: 'pie_status', type: 'pie', title: 'Status Split', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 0, y: 14, w: 3, h: 4 } }, - { id: 'donut_priority', type: 'donut', title: 'Priority Split', object: task, aggregate: 'count', categoryField: 'priority', layout: { x: 3, y: 14, w: 3, h: 4 } }, - { id: 'funnel_pipeline', type: 'funnel', title: 'Task Funnel', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 6, y: 14, w: 3, h: 4 } }, - { id: 'pyramid_priority', type: 'pyramid', title: 'Priority Pyramid', object: task, aggregate: 'count', categoryField: 'priority', layout: { x: 9, y: 14, w: 3, h: 4 } }, + { id: 'pie_status', type: 'pie', title: 'Status Split', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 14, w: 3, h: 4 } }, + { id: 'donut_priority', type: 'donut', title: 'Priority Split', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 3, y: 14, w: 3, h: 4 } }, + { id: 'funnel_pipeline', type: 'funnel', title: 'Task Funnel', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 14, w: 3, h: 4 } }, + { id: 'pyramid_priority', type: 'pyramid', title: 'Priority Pyramid', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 9, y: 14, w: 3, h: 4 } }, // ── Relationship ───────────────────────────────────────────────────── - { id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', object: task, aggregate: 'avg', valueField: 'estimate_hours', categoryField: 'progress', layout: { x: 0, y: 18, w: 4, h: 4 } }, - { id: 'bubble_budget', type: 'bubble', title: 'Budget Bubble', object: project, aggregate: 'sum', valueField: 'budget', categoryField: 'account', layout: { x: 4, y: 18, w: 4, h: 4 } }, + { id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', object: task, aggregate: 'avg', valueField: 'estimate_hours', categoryField: 'progress', dataset: taskDs, dimensions: ['progress'], values: ['avg_estimate'], layout: { x: 0, y: 18, w: 4, h: 4 } }, + { id: 'bubble_budget', type: 'bubble', title: 'Budget Bubble', object: project, aggregate: 'sum', valueField: 'budget', categoryField: 'account', dataset: projectDs, dimensions: ['account'], values: ['budget_sum'], layout: { x: 4, y: 18, w: 4, h: 4 } }, // ── Composition ────────────────────────────────────────────────────── - { id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', object: task, aggregate: 'sum', valueField: 'estimate_hours', categoryField: 'status', layout: { x: 8, y: 18, w: 4, h: 4 } }, - { id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 0, y: 22, w: 4, h: 4 } }, - { id: 'radar_priority', type: 'radar', title: 'Priority Radar', object: task, aggregate: 'count', categoryField: 'priority', layout: { x: 4, y: 22, w: 4, h: 4 } }, + { id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', object: task, aggregate: 'sum', valueField: 'estimate_hours', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 18, w: 4, h: 4 } }, + { id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 22, w: 4, h: 4 } }, + { id: 'radar_priority', type: 'radar', title: 'Priority Radar', object: task, aggregate: 'count', categoryField: 'priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 22, w: 4, h: 4 } }, // ── Performance ────────────────────────────────────────────────────── - { id: 'solid_gauge', type: 'solid-gauge', title: 'Solid Gauge', object: task, aggregate: 'avg', valueField: 'progress', layout: { x: 8, y: 22, w: 4, h: 4 } }, + { id: 'solid_gauge', type: 'solid-gauge', title: 'Solid Gauge', object: task, aggregate: 'avg', valueField: 'progress', dataset: taskDs, values: ['avg_progress'], layout: { x: 8, y: 22, w: 4, h: 4 } }, // ── Comparison / trend variants ────────────────────────────────────── - { id: 'bipolar_bar', type: 'bi-polar-bar', title: 'Bi-polar Bar', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 0, y: 26, w: 6, h: 4 } }, - { id: 'step_line', type: 'step-line', title: 'Step Line', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'week', layout: { x: 6, y: 26, w: 6, h: 4 } }, + { id: 'bipolar_bar', type: 'bi-polar-bar', title: 'Bi-polar Bar', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 26, w: 6, h: 4 } }, + { id: 'step_line', type: 'step-line', title: 'Step Line', object: task, aggregate: 'count', categoryField: 'created_at', categoryGranularity: 'week', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 6, y: 26, w: 6, h: 4 } }, // ── Tabular ────────────────────────────────────────────────────────── - { id: 'table_projects', type: 'table', title: 'Projects Table', object: project, aggregate: 'count', layout: { x: 0, y: 30, w: 6, h: 4 } }, - { id: 'pivot_tasks', type: 'pivot', title: 'Tasks Pivot', object: task, aggregate: 'count', categoryField: 'status', layout: { x: 6, y: 30, w: 6, h: 4 } }, + { id: 'table_projects', type: 'table', title: 'Projects Table', object: project, aggregate: 'count', dataset: projectDs, values: ['project_count'], layout: { x: 0, y: 30, w: 6, h: 4 } }, + { id: 'pivot_tasks', type: 'pivot', title: 'Tasks Pivot', object: task, aggregate: 'count', categoryField: 'status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 30, w: 6, h: 4 } }, ], }; diff --git a/examples/app-showcase/src/datasets/chart-gallery.dataset.ts b/examples/app-showcase/src/datasets/chart-gallery.dataset.ts new file mode 100644 index 000000000..bf2a8ce6c --- /dev/null +++ b/examples/app-showcase/src/datasets/chart-gallery.dataset.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +/** + * Datasets backing the Chart Gallery dashboard (ADR-0021). Every gallery widget + * binds to one of these and selects dimensions/measures BY NAME, so the same + * metric ("task count", "hours", "budget") is defined once. + */ + +/** Task analytics — counts, hours, progress over showcase_task. */ +export const ShowcaseTaskDataset = defineDataset({ + name: 'showcase_task_metrics', + label: 'Task Metrics', + object: 'showcase_task', + dimensions: [ + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'priority', label: 'Priority', field: 'priority', type: 'string' }, + { name: 'progress', label: 'Progress', field: 'progress', type: 'number' }, + { name: 'created_at', label: 'Created', field: 'created_at', type: 'date' }, + ], + measures: [ + { name: 'task_count', label: 'Tasks', aggregate: 'count' }, + { name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimate_hours', format: '0.0' }, + { name: 'avg_estimate', label: 'Avg Estimate', aggregate: 'avg', field: 'estimate_hours', format: '0.0' }, + { name: 'avg_progress', label: 'Avg Progress', aggregate: 'avg', field: 'progress', format: '0.0' }, + ], +}); + +/** Project analytics — budget / spend over showcase_project. */ +export const ShowcaseProjectDataset = defineDataset({ + name: 'showcase_project_metrics', + label: 'Project Metrics', + object: 'showcase_project', + dimensions: [ + { name: 'account', label: 'Account', field: 'account', type: 'lookup' }, + ], + measures: [ + { name: 'project_count', label: 'Projects', aggregate: 'count' }, + { name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '$0,0' }, + { name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '$0,0' }, + ], +}); diff --git a/examples/app-showcase/src/datasets/index.ts b/examples/app-showcase/src/datasets/index.ts new file mode 100644 index 000000000..b8eb9c657 --- /dev/null +++ b/examples/app-showcase/src/datasets/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { ShowcaseTaskDataset, ShowcaseProjectDataset } from './chart-gallery.dataset.js'; diff --git a/examples/app-showcase/src/reports/index.ts b/examples/app-showcase/src/reports/index.ts index 28ae58b8a..0052ebe56 100644 --- a/examples/app-showcase/src/reports/index.ts +++ b/examples/app-showcase/src/reports/index.ts @@ -4,21 +4,10 @@ import type { Report } from '@objectstack/spec/ui'; const task = 'showcase_task'; -/** 1 ── Tabular: a flat list of records. */ -export const TaskListReport: Report = { - name: 'showcase_task_list', - label: 'Task List (Tabular)', - description: 'Flat list of all tasks.', - objectName: task, - type: 'tabular', - columns: [ - { field: 'title', label: 'Title' }, - { field: 'project', label: 'Project' }, - { field: 'assignee', label: 'Assignee' }, - { field: 'status', label: 'Status' }, - { field: 'estimate_hours', label: 'Estimate' }, - ], -}; +// ADR-0021 Phase 2: the former `TaskListReport` (showcase_task_list) — a flat +// record list — was converted to the `tabular` ListView on showcase_task +// (src/views/task.view.ts). A flat list is an object-bound row lens (ADR-0017), +// not analytics, so it is no longer a report. /** 2 ── Summary: grouped down by status with a sum. */ export const HoursByStatusReport: Report = { @@ -32,6 +21,10 @@ export const HoursByStatusReport: Report = { { field: 'estimate_hours', label: 'Hours', aggregate: 'sum' }, ], groupingsDown: [{ field: 'status', sortOrder: 'asc' }], + // ADR-0021 Phase 2 — dataset binding (dual-form). + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], }; /** 3 ── Matrix: status (down) × priority (across) cross-tab. */ @@ -44,6 +37,11 @@ export const StatusPriorityMatrixReport: Report = { columns: [{ field: 'estimate_hours', label: 'Hours', aggregate: 'sum' }], groupingsDown: [{ field: 'status', sortOrder: 'asc' }], groupingsAcross: [{ field: 'priority', sortOrder: 'asc' }], + // ADR-0021 Phase 2 — dataset binding (dual-form). Matrix flattens rows+across + // into `rows` for now (cell values identical); across-dimension is a follow-up. + dataset: 'showcase_task_metrics', + rows: ['status', 'priority'], + values: ['est_hours'], }; /** 4 ── Joined: multiple stacked blocks in one report. */ @@ -56,6 +54,7 @@ export const TaskOverviewReport: Report = { columns: [], blocks: [ { + // Analytics block → dataset-bound (dual-form); reconciled by the harness. name: 'open_block', label: 'Open Tasks', type: 'summary', @@ -63,8 +62,13 @@ export const TaskOverviewReport: Report = { columns: [{ field: 'estimate_hours', label: 'Hours', aggregate: 'sum' }], groupingsDown: [{ field: 'status', sortOrder: 'asc' }], filter: { done: false }, + dataset: 'showcase_task_metrics', + rows: ['status'], + values: ['est_hours'], + runtimeFilter: { done: false }, }, { + // Record-list block → stays inline (a ListView/embed in the terminal form). name: 'done_block', label: 'Completed Tasks', type: 'tabular', @@ -79,7 +83,6 @@ export const TaskOverviewReport: Report = { }; export const allReports = [ - TaskListReport, HoursByStatusReport, StatusPriorityMatrixReport, TaskOverviewReport, diff --git a/examples/app-showcase/src/views/task.view.ts b/examples/app-showcase/src/views/task.view.ts index 90c2a64e6..7cbfdeed7 100644 --- a/examples/app-showcase/src/views/task.view.ts +++ b/examples/app-showcase/src/views/task.view.ts @@ -28,6 +28,22 @@ export const TaskViews = defineView({ }, listViews: { + // 0 ── Tabular ─────────────────────────────────────────────────────── + // ADR-0021 Phase 2: replaces the former `showcase_task_list` report + // (a flat record list — a ListView concern, not analytics). + tabular: { + label: 'Task List', + type: 'grid', + data, + columns: [ + { field: 'title' }, + { field: 'project' }, + { field: 'assignee' }, + { field: 'status' }, + { field: 'estimate_hours' }, + ], + }, + // 1 ── Grid ───────────────────────────────────────────────────────── grid: { label: 'Grid', diff --git a/examples/app-todo/objectstack.config.ts b/examples/app-todo/objectstack.config.ts index 4118a54f4..66d0c57b9 100644 --- a/examples/app-todo/objectstack.config.ts +++ b/examples/app-todo/objectstack.config.ts @@ -6,7 +6,9 @@ import { defineStack } from '@objectstack/spec'; import * as objects from './src/objects/index.js'; import * as actions from './src/actions/index.js'; import * as dashboards from './src/dashboards/index.js'; +import * as datasets from './src/datasets/index.js'; import * as reports from './src/reports/index.js'; +import * as views from './src/views/index.js'; import { allFlows } from './src/flows/index.js'; import * as apps from './src/apps/index.js'; import { TodoSeedData } from './src/data/index.js'; @@ -41,8 +43,10 @@ export default defineStack({ // Auto-collected from barrel index files via Object.values() objects: Object.values(objects), + views: Object.values(views), actions: Object.values(actions), dashboards: Object.values(dashboards), + datasets: Object.values(datasets), reports: Object.values(reports), flows: allFlows, apps: Object.values(apps), diff --git a/examples/app-todo/src/dashboards/task.dashboard.ts b/examples/app-todo/src/dashboards/task.dashboard.ts index e7434599d..e0a739610 100644 --- a/examples/app-todo/src/dashboards/task.dashboard.ts +++ b/examples/app-todo/src/dashboards/task.dashboard.ts @@ -2,11 +2,23 @@ import type { Dashboard } from '@objectstack/spec/ui'; +/** + * Task Overview dashboard. + * + * ADR-0021 Phase 2: every widget is bound to the `task_metrics` dataset + * (`dataset` + `dimensions` + `values`, measures/dimensions referenced BY NAME) + * so the numbers stay consistent with every other surface. The legacy inline + * query (`object` + `categoryField` + `valueField` + `aggregate` + `filter`) is + * retained side-by-side during the dual-form window — the reconciliation + * harness asserts the two forms return identical numbers before the inline + * form is removed (see scripts/analytics-reconcile). The widget `filter` + * doubles as the dataset-bound `runtimeFilter` (presentation scope). + */ export const TaskDashboard: Dashboard = { name: 'task_dashboard', label: 'Task Overview', description: 'Key task metrics and productivity overview', - + widgets: [ // Row 1: Key Metrics { @@ -15,6 +27,8 @@ export const TaskDashboard: Dashboard = { type: 'metric', object: 'todo_task', aggregate: 'count', + dataset: 'task_metrics', + values: ['task_count'], layout: { x: 0, y: 0, w: 3, h: 2 }, options: { color: '#3B82F6' } }, @@ -23,8 +37,10 @@ export const TaskDashboard: Dashboard = { title: 'Completed Today', type: 'metric', object: 'todo_task', - filter: { is_completed: true, completed_date: { $gte: '{today_start}' } }, + filter: { is_completed: true, completed_date: { $gte: '{today}' } }, aggregate: 'count', + dataset: 'task_metrics', + values: ['task_count'], layout: { x: 3, y: 0, w: 3, h: 2 }, options: { color: '#10B981' } }, @@ -35,6 +51,8 @@ export const TaskDashboard: Dashboard = { object: 'todo_task', filter: { is_overdue: true, is_completed: false }, aggregate: 'count', + dataset: 'task_metrics', + values: ['task_count'], layout: { x: 6, y: 0, w: 3, h: 2 }, options: { color: '#EF4444' } }, @@ -43,13 +61,15 @@ export const TaskDashboard: Dashboard = { title: 'Completion Rate', type: 'metric', object: 'todo_task', - filter: { created_date: { $gte: '{current_week_start}' } }, + filter: { created_at: { $gte: '{current_week_start}' } }, valueField: 'is_completed', aggregate: 'count', + dataset: 'task_metrics', + values: ['task_count'], layout: { x: 9, y: 0, w: 3, h: 2 }, options: { suffix: '%', color: '#8B5CF6' } }, - + // Row 2: Task Distribution { id: 'tasks_by_status', @@ -59,6 +79,9 @@ export const TaskDashboard: Dashboard = { filter: { is_completed: false }, categoryField: 'status', aggregate: 'count', + dataset: 'task_metrics', + dimensions: ['status'], + values: ['task_count'], layout: { x: 0, y: 2, w: 6, h: 4 }, options: { showLegend: true } }, @@ -70,19 +93,25 @@ export const TaskDashboard: Dashboard = { filter: { is_completed: false }, categoryField: 'priority', aggregate: 'count', + dataset: 'task_metrics', + dimensions: ['priority'], + values: ['task_count'], layout: { x: 6, y: 2, w: 6, h: 4 }, options: { horizontal: true } }, - + // Row 3: Trends { id: 'weekly_task_completion', title: 'Weekly Task Completion', type: 'line', object: 'todo_task', - filter: { is_completed: true, completed_date: { $gte: '{last_4_weeks}' } }, + filter: { is_completed: true, completed_date: { $gte: '{4_weeks_ago}' } }, categoryField: 'completed_date', aggregate: 'count', + dataset: 'task_metrics', + dimensions: ['completed_date'], + values: ['task_count'], layout: { x: 0, y: 6, w: 8, h: 4 }, options: { showDataLabels: true } }, @@ -94,10 +123,13 @@ export const TaskDashboard: Dashboard = { filter: { is_completed: false }, categoryField: 'category', aggregate: 'count', + dataset: 'task_metrics', + dimensions: ['category'], + values: ['task_count'], layout: { x: 8, y: 6, w: 4, h: 4 }, options: { showLegend: true } }, - + // Row 4: Tables { id: 'overdue_tasks_table', @@ -106,6 +138,8 @@ export const TaskDashboard: Dashboard = { object: 'todo_task', filter: { is_overdue: true, is_completed: false }, aggregate: 'count', + dataset: 'task_metrics', + values: ['task_count'], layout: { x: 0, y: 10, w: 6, h: 4 }, }, { @@ -115,6 +149,8 @@ export const TaskDashboard: Dashboard = { object: 'todo_task', filter: { due_date: '{today}', is_completed: false }, aggregate: 'count', + dataset: 'task_metrics', + values: ['task_count'], layout: { x: 6, y: 10, w: 6, h: 4 }, }, ], diff --git a/examples/app-todo/src/datasets/index.ts b/examples/app-todo/src/datasets/index.ts new file mode 100644 index 000000000..d7f8cfec4 --- /dev/null +++ b/examples/app-todo/src/datasets/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { TaskDataset } from './task.dataset.js'; diff --git a/examples/app-todo/src/datasets/task.dataset.ts b/examples/app-todo/src/datasets/task.dataset.ts new file mode 100644 index 000000000..8ca499110 --- /dev/null +++ b/examples/app-todo/src/datasets/task.dataset.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +/** + * Task analytics dataset (ADR-0021). + * + * The single semantic source of truth for every task metric surfaced on the + * Task Overview dashboard and reports. `report` / `dashboard` widgets bind to + * this dataset and select dimensions / measures BY NAME — no inline + * `object` + `categoryField` + `valueField` + `aggregate` query — so the + * numbers stay consistent across every surface. + * + * Single-object dataset (no `include`): all dimensions/measures live on + * `todo_task` itself, so it compiles to a plain `groupBy` + `aggregations` + * query with no joins. + */ +export const TaskDataset = defineDataset({ + name: 'task_metrics', + label: 'Task Metrics', + description: 'Semantic layer for task counts and time-tracking measures', + object: 'todo_task', + + // Groupable axes — referenced by report rows/columns and widget dimensions. + dimensions: [ + { name: 'status', label: 'Status', field: 'status', type: 'string' }, + { name: 'priority', label: 'Priority', field: 'priority', type: 'string' }, + { name: 'category', label: 'Category', field: 'category', type: 'string' }, + { name: 'owner', label: 'Assigned To', field: 'owner', type: 'lookup' }, + { name: 'due_date', label: 'Due Date', field: 'due_date', type: 'date' }, + { name: 'completed_date', label: 'Completed Date', field: 'completed_date', type: 'date' }, + ], + + // Aggregatable values — defined ONCE here; every presentation references by name. + measures: [ + { name: 'task_count', label: 'Tasks', aggregate: 'count' }, + { name: 'est_hours', label: 'Estimated Hours', aggregate: 'sum', field: 'estimated_hours', format: '0.0' }, + { name: 'actual_hours', label: 'Actual Hours', aggregate: 'sum', field: 'actual_hours', format: '0.0' }, + ], +}); diff --git a/examples/app-todo/src/reports/index.ts b/examples/app-todo/src/reports/index.ts index fada4ada1..3d33fbdb4 100644 --- a/examples/app-todo/src/reports/index.ts +++ b/examples/app-todo/src/reports/index.ts @@ -3,11 +3,12 @@ /** * Report Definitions Barrel */ +// ADR-0021 Phase 2: `OverdueTasksReport` (a flat record list) was converted to +// an `overdue` ListView on todo_task — see src/views/task.view.ts. export { TasksByStatusReport, TasksByPriorityReport, TasksByOwnerReport, - OverdueTasksReport, CompletedTasksReport, TimeTrackingReport, } from './task.report'; diff --git a/examples/app-todo/src/reports/task.report.ts b/examples/app-todo/src/reports/task.report.ts index 694854da1..3566d7173 100644 --- a/examples/app-todo/src/reports/task.report.ts +++ b/examples/app-todo/src/reports/task.report.ts @@ -2,6 +2,13 @@ import type { ReportInput } from '@objectstack/spec/ui'; +// ADR-0021 Phase 2: each report below carries a `task_metrics` dataset binding +// (`dataset` + `rows` + `values`, measures referenced BY NAME) alongside the +// legacy inline query during the dual-form window. The reconciliation harness +// asserts both forms return identical numbers (scripts/analytics-reconcile). +// When the inline form is removed, the detail columns move to a click-through +// drilldown (per the migration decision); `overdue_tasks` becomes a ListView. + /** Tasks by Status Report */ export const TasksByStatusReport: ReportInput = { name: 'tasks_by_status', @@ -16,6 +23,9 @@ export const TasksByStatusReport: ReportInput = { { field: 'owner', label: 'Assigned To' }, ], groupingsDown: [{ field: 'status', sortOrder: 'asc' }], + dataset: 'task_metrics', + rows: ['status'], + values: ['task_count'], }; /** Tasks by Priority Report */ @@ -33,6 +43,10 @@ export const TasksByPriorityReport: ReportInput = { ], groupingsDown: [{ field: 'priority', sortOrder: 'desc' }], filter: { is_completed: false }, + dataset: 'task_metrics', + rows: ['priority'], + values: ['task_count'], + runtimeFilter: { is_completed: false }, }; /** Tasks by Owner Report */ @@ -52,24 +66,16 @@ export const TasksByOwnerReport: ReportInput = { ], groupingsDown: [{ field: 'owner', sortOrder: 'asc' }], filter: { is_completed: false }, + dataset: 'task_metrics', + rows: ['owner'], + values: ['est_hours', 'actual_hours'], + runtimeFilter: { is_completed: false }, }; -/** Overdue Tasks Report */ -export const OverdueTasksReport: ReportInput = { - name: 'overdue_tasks', - label: 'Overdue Tasks', - description: 'All overdue tasks that need attention', - objectName: 'todo_task', - type: 'tabular', - columns: [ - { field: 'subject', label: 'Subject' }, - { field: 'due_date', label: 'Due Date' }, - { field: 'priority', label: 'Priority' }, - { field: 'owner', label: 'Assigned To' }, - { field: 'category', label: 'Category' }, - ], - filter: { is_overdue: true, is_completed: false }, -}; +// ADR-0021 Phase 2: the former `OverdueTasksReport` (a flat record list, no +// grouping/aggregation) is now the `overdue` ListView on todo_task — see +// src/views/task.view.ts. A flat record list is an object-bound row lens +// (ADR-0017), not a dataset report. /** Completed Tasks Report */ export const CompletedTasksReport: ReportInput = { @@ -86,6 +92,10 @@ export const CompletedTasksReport: ReportInput = { ], groupingsDown: [{ field: 'category', sortOrder: 'asc' }], filter: { is_completed: true }, + dataset: 'task_metrics', + rows: ['category'], + values: ['est_hours', 'actual_hours'], + runtimeFilter: { is_completed: true }, }; /** Time Tracking Report */ @@ -102,4 +112,11 @@ export const TimeTrackingReport: ReportInput = { groupingsDown: [{ field: 'owner', sortOrder: 'asc' }], groupingsAcross: [{ field: 'category', sortOrder: 'asc' }], filter: { is_completed: true }, + // Matrix: the dataset form flattens rows+across into `rows` for now (cell + // values are identical); a dataset-bound `columns`/across dimension is a + // follow-up before single-form convergence. + dataset: 'task_metrics', + rows: ['owner', 'category'], + values: ['est_hours', 'actual_hours'], + runtimeFilter: { is_completed: true }, }; diff --git a/examples/app-todo/src/views/index.ts b/examples/app-todo/src/views/index.ts new file mode 100644 index 000000000..783a09b4a --- /dev/null +++ b/examples/app-todo/src/views/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +export { TaskViews } from './task.view.js'; diff --git a/examples/app-todo/src/views/task.view.ts b/examples/app-todo/src/views/task.view.ts new file mode 100644 index 000000000..2c17738e0 --- /dev/null +++ b/examples/app-todo/src/views/task.view.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'todo_task' }; + +/** + * Task list views (ADR-0017 object-bound row lenses). + * + * ADR-0021 Phase 2: the former `overdue_tasks` *report* was a flat record list + * (no grouping / aggregation), which is a ListView concern, not analytics. It + * is converted here to an `overdue` grid view filtered to incomplete, overdue + * tasks — replacing the report entirely. + */ +export const TaskViews = defineView({ + list: { + label: 'All Tasks', + type: 'grid', + data, + columns: [ + { field: 'subject' }, + { field: 'status' }, + { field: 'priority' }, + { field: 'due_date' }, + { field: 'owner' }, + { field: 'category' }, + ], + }, + + listViews: { + // Replaces the legacy `overdue_tasks` report. + overdue: { + label: 'Overdue Tasks', + type: 'grid', + data, + columns: [ + { field: 'subject' }, + { field: 'due_date' }, + { field: 'priority' }, + { field: 'owner' }, + { field: 'category' }, + ], + filter: [ + { field: 'is_overdue', operator: 'equals', value: true }, + { field: 'is_completed', operator: 'equals', value: false }, + ], + sort: [{ field: 'due_date', order: 'asc' }], + }, + }, +}); diff --git a/packages/services/service-analytics/src/__tests__/objectql-strategy-range-filter.test.ts b/packages/services/service-analytics/src/__tests__/objectql-strategy-range-filter.test.ts new file mode 100644 index 000000000..247085ea7 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-strategy-range-filter.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression (ADR-0021 Phase 2): a dataset runtimeFilter that carries a RANGE +// on one field (`{ close_date: { $gte, $lte } }`) must reach the ObjectQL +// aggregate path with BOTH bounds. Previously the strategy built its filter via +// `filter[field] = convertFilter(...)` in a loop, so the second operator +// overwrote the first and a range silently lost a bound — producing wrong +// numbers vs the legacy inline-query path. Surfaced by the reconciliation gate. + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; + +const dataset = DatasetSchema.parse({ + name: 'pipeline', + label: 'Pipeline', + object: 'opportunity', + dimensions: [ + { name: 'stage', field: 'stage', type: 'string' }, + { name: 'close_date', field: 'close_date', type: 'date' }, + ], + measures: [{ name: 'opp_count', aggregate: 'count' }], +}); + +describe('ObjectQLStrategy range runtimeFilter (regression)', () => { + it('passes BOTH bounds of a $gte/$lte range to engine.aggregate', async () => { + const captured: Array<{ filter?: Record }> = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object, options) => { + captured.push({ filter: options.filter }); + return [{ stage: 'qualification', opp_count: 1 }]; + }, + }); + + await svc.queryDataset!(dataset, { + dimensions: ['stage'], + measures: ['opp_count'], + runtimeFilter: { close_date: { $gte: 1_774_983_600_000, $lte: 1_782_759_600_000 } }, + }); + + expect(captured).toHaveLength(1); + expect(captured[0].filter?.close_date).toEqual({ $gte: 1_774_983_600_000, $lte: 1_782_759_600_000 }); + }); +}); + +describe('ObjectQLStrategy timeDimension granularity (regression)', () => { + it('lowers a timeDimension granularity to a structured {field, dateGranularity} groupBy', async () => { + const captured: Array<{ groupBy?: unknown[] }> = []; + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object, options) => { + captured.push({ groupBy: options.groupBy as unknown[] }); + return [{ close_date: '2026-06', opp_count: 1 }]; + }, + }); + + await svc.queryDataset!(dataset, { + dimensions: ['close_date'], + measures: ['opp_count'], + timeDimensions: [{ dimension: 'close_date', granularity: 'month' }], + }); + + expect(captured).toHaveLength(1); + // The date dimension buckets by month instead of grouping raw timestamps. + expect(captured[0].groupBy).toEqual([{ field: 'close_date', dateGranularity: 'month' }]); + }); +}); diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 7430edb28..d00be3bb1 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -26,13 +26,30 @@ export class ObjectQLStrategy implements AnalyticsStrategy { const cube = ctx.getCube(query.cube!)!; const objectName = this.extractObjectName(cube); - // Build groupBy from dimensions - const groupBy: string[] = []; + // Build groupBy from dimensions, honouring `timeDimensions` granularity. + // A date dimension with a granularity becomes a STRUCTURED groupBy item + // `{ field, dateGranularity }` — which `engine.aggregate()` buckets (driver + // date_trunc or in-memory). Without this the ObjectQL path grouped raw + // timestamps (one bucket per row) and date-bucketed dataset widgets never + // matched their legacy `categoryGranularity` counterpart. + type GroupByItem = string | { field: string; dateGranularity: string }; + const granByDim = new Map(); + for (const td of query.timeDimensions ?? []) { + if (td.granularity) granByDim.set(td.dimension, td.granularity); + } + const groupBy: GroupByItem[] = []; if (query.dimensions && query.dimensions.length > 0) { for (const dim of query.dimensions) { - groupBy.push(this.resolveFieldName(cube, dim, 'dimension')); + const field = this.resolveFieldName(cube, dim, 'dimension'); + const gran = granByDim.get(dim); + groupBy.push(gran ? { field, dateGranularity: gran } : field); + granByDim.delete(dim); } } + // Time dimensions not also listed in `dimensions` still bucket + group. + for (const [dim, gran] of granByDim) { + groupBy.push({ field: this.resolveFieldName(cube, dim, 'dimension'), dateGranularity: gran }); + } // Build aggregations from measures const aggregations: Array<{ field: string; method: string; alias: string }> = []; @@ -43,18 +60,30 @@ export class ObjectQLStrategy implements AnalyticsStrategy { } } - // Build filter from query filters + // Build filter from query filters. A single field may carry MULTIPLE + // operators (e.g. a range `{$gte, $lte}` from `close_date` between two + // bounds). Merge same-field operator objects instead of overwriting, or a + // range would silently lose a bound (only the last operator would survive). const filter: Record = {}; const normalizedFilters = normalizeAnalyticsFilters(query); if (normalizedFilters.length > 0) { for (const f of normalizedFilters) { const fieldName = this.resolveFieldName(cube, f.member, 'any'); - filter[fieldName] = this.convertFilter(f.operator, f.values); + const converted = this.convertFilter(f.operator, f.values); + const existing = filter[fieldName]; + const mergeable = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); + filter[fieldName] = mergeable(existing) && mergeable(converted) + ? { ...existing, ...converted } + : converted; } } const rows = await ctx.executeAggregate!(objectName, { - groupBy: groupBy.length > 0 ? groupBy : undefined, + // Structured groupBy items ({field, dateGranularity}) pass through the + // executeAggregate bridge to engine.aggregate, which buckets them. The + // contract types groupBy as string[]; the cast carries the richer shape. + groupBy: groupBy.length > 0 ? (groupBy as unknown as string[]) : undefined, aggregations: aggregations.length > 0 ? aggregations : undefined, filter: Object.keys(filter).length > 0 ? filter : undefined, }); diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 9a98ca4a8..9a6ee6ca0 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -92,6 +92,21 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => z.object({ filter: FilterConditionSchema.optional(), /** Optional inline chart configuration. */ chart: ReportChartSchema.optional(), + + /** + * ADR-0021 — bind this block to a semantic-layer `dataset` (the governed + * alternative to the block's inline `objectName` + `columns` query), mirroring + * the top-level report dual-form. An analytics block (summary/matrix) selects + * dataset measures by name; a record-list block stays inline (it becomes a + * ListView/embed in the terminal form). Additive / dual-form. + */ + dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'), + /** Dimension names (from the dataset) to group rows by. Dataset-bound only. */ + rows: z.array(z.string()).optional().describe('Dimension names down (dataset-bound)'), + /** Measure names (from the dataset) to display. Dataset-bound only. */ + values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)'), + /** Render-time scope filter, ANDed at query time. Dataset-bound only. */ + runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)'), })); /** diff --git a/scripts/analytics-reconcile/app-crm.ts b/scripts/analytics-reconcile/app-crm.ts new file mode 100644 index 000000000..c668f7df0 --- /dev/null +++ b/scripts/analytics-reconcile/app-crm.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0021 Phase 2 — reconciliation runner for examples/app-crm. +// pnpm tsx scripts/analytics-reconcile/app-crm.ts + +import CrmApp from '../../examples/app-crm/objectstack.config.js'; +import { PipelineDashboard } from '../../examples/app-crm/src/dashboards/pipeline.dashboard.js'; +import { OpportunityDataset } from '../../examples/app-crm/src/datasets/opportunity.dataset.js'; +import { SalesByStageReport } from '../../examples/app-crm/src/reports/sales-by-stage.report.js'; +import { reconcileApp, runAsMain } from './boot.js'; + +runAsMain(() => reconcileApp({ + appName: 'app-crm', + config: CrmApp, + dashboards: [PipelineDashboard], + reports: [SalesByStageReport], + datasets: [OpportunityDataset], +})); diff --git a/scripts/analytics-reconcile/app-showcase.ts b/scripts/analytics-reconcile/app-showcase.ts new file mode 100644 index 000000000..65e1b881b --- /dev/null +++ b/scripts/analytics-reconcile/app-showcase.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0021 Phase 2 — reconciliation runner for examples/app-showcase. +// pnpm tsx scripts/analytics-reconcile/app-showcase.ts + +import ShowcaseApp from '../../examples/app-showcase/objectstack.config.js'; +import { ChartGalleryDashboard } from '../../examples/app-showcase/src/dashboards/chart-gallery.dashboard.js'; +import { ShowcaseTaskDataset, ShowcaseProjectDataset } from '../../examples/app-showcase/src/datasets/chart-gallery.dataset.js'; +import { allReports } from '../../examples/app-showcase/src/reports/index.js'; +import { reconcileApp, runAsMain } from './boot.js'; + +runAsMain(() => reconcileApp({ + appName: 'app-showcase', + config: ShowcaseApp, + dashboards: [ChartGalleryDashboard], + reports: allReports, + datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], +})); diff --git a/scripts/analytics-reconcile/app-todo.ts b/scripts/analytics-reconcile/app-todo.ts new file mode 100644 index 000000000..b95738147 --- /dev/null +++ b/scripts/analytics-reconcile/app-todo.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0021 Phase 2 — reconciliation runner for examples/app-todo. +// pnpm tsx scripts/analytics-reconcile/app-todo.ts + +import TodoApp from '../../examples/app-todo/objectstack.config.js'; +import { TaskDashboard } from '../../examples/app-todo/src/dashboards/task.dashboard.js'; +import { TaskDataset } from '../../examples/app-todo/src/datasets/task.dataset.js'; +import * as reports from '../../examples/app-todo/src/reports/index.js'; +import type { Report } from '@objectstack/spec/ui'; +import { reconcileApp, runAsMain } from './boot.js'; + +runAsMain(() => reconcileApp({ + appName: 'app-todo', + config: TodoApp, + dashboards: [TaskDashboard], + reports: Object.values(reports) as Report[], + datasets: [TaskDataset], +})); diff --git a/scripts/analytics-reconcile/boot.ts b/scripts/analytics-reconcile/boot.ts new file mode 100644 index 000000000..5810dac84 --- /dev/null +++ b/scripts/analytics-reconcile/boot.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0021 Phase 2 — shared kernel-boot + reconciliation driver. +// +// Boots an example stack with a real in-memory engine + the analytics service, +// then reconciles every dual-form dashboard widget (legacy `aggregate()` vs +// dataset `queryDataset()`). Read-only: never writes metadata or data. + +import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { Dataset, DatasetInput, Dashboard, Report } from '@objectstack/spec/ui'; +import type { IAnalyticsService, DatasetSelection } from '@objectstack/spec/contracts'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { reconcileDashboard, reconcileReports, type ReconcileExecutors, type WidgetReconcileResult } from './reconcile.js'; +import { resolveDateMacros } from './macros.js'; + +interface DataEngineLike { + aggregate(object: string, options: { + where?: Record; + // Plain fields or structured date-bucket items ({ field, dateGranularity }). + groupBy?: Array; + aggregations?: Array<{ function: string; field: string; alias: string }>; + }): Promise[]>; +} + +export interface ReconcileAppOptions { + appName: string; + /** The defineStack() result for the example app. */ + config: unknown; + /** Dashboards to reconcile (each dual-form widget is checked). */ + dashboards: Dashboard[]; + /** Reports to reconcile (each dual-form report is checked). */ + reports?: Report[]; + /** Authored datasets (DatasetInput) the dashboards/reports reference by name. */ + datasets: DatasetInput[]; +} + +/** Boot, reconcile every dashboard, print a report, and return the mismatch count. */ +export async function reconcileApp(opts: ReconcileAppOptions): Promise { + process.env.OS_MULTI_ORG_ENABLED = 'false'; + + const kernel = new ObjectKernel(); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new DriverPlugin(new SqliteWasmDriver({ filename: ':memory:' }))); + await kernel.use(new AppPlugin(opts.config as ConstructorParameters[0])); + // Force the ObjectQL aggregate path (no raw SQL) so legacy and dataset paths + // run through the identical engine.aggregate() — the cleanest apples-to-apples. + await kernel.use(new AnalyticsServicePlugin({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + })); + await kernel.bootstrap(); + + const engine = + (kernel.getService('data') as DataEngineLike | undefined) ?? + (kernel.getService('objectql') as DataEngineLike | undefined); + if (!engine || typeof engine.aggregate !== 'function') { + throw new Error('No IDataEngine with aggregate() found (expected "data"/"objectql" service).'); + } + const analytics = kernel.getService('analytics') as IAnalyticsService | undefined; + if (!analytics || typeof analytics.queryDataset !== 'function') { + throw new Error('No analytics service with queryDataset() found.'); + } + + const datasets = new Map(); + for (const ds of opts.datasets) { + const parsed = DatasetSchema.parse(ds) as Dataset; + datasets.set(parsed.name, parsed); + } + + const exec: ReconcileExecutors = { + runAggregate: (spec) => engine.aggregate(spec.objectName, { + where: spec.filter as Record | undefined, + groupBy: spec.groupBy.length > 0 ? spec.groupBy : undefined, + aggregations: spec.aggregations.map((a) => ({ function: a.method, field: a.field, alias: a.alias })), + }), + runDataset: async (dataset: Dataset, selection: DatasetSelection) => { + const result = await analytics.queryDataset!(dataset, selection); + return result.rows as Record[]; + }, + resolveFilter: (filter?: FilterCondition) => (filter == null ? filter : resolveDateMacros(filter)), + }; + + let totalMismatch = 0; + for (const dashboard of opts.dashboards) { + const results = await reconcileDashboard(dashboard, datasets, exec); + totalMismatch += report(`${opts.appName} · ${dashboard.name}`, results); + } + if (opts.reports && opts.reports.length > 0) { + const results = await reconcileReports(opts.reports, datasets, exec); + totalMismatch += report(`${opts.appName} · reports`, results); + } + return totalMismatch; +} + +function report(surface: string, results: WidgetReconcileResult[]): number { + let mismatches = 0; + const icons: Record = { + ok: '✅', mismatch: '❌', skipped: '⏭️ ', pending: '🕗', + }; + console.log(`\n── Analytics reconciliation · ${surface} ──`); + for (const r of results) { + console.log(`${icons[r.status]} ${r.widgetId} [${r.status}]`); + if (r.status === 'mismatch') { + mismatches++; + for (const issue of r.issues) console.log(` · ${issue}`); + } else if ((r.status === 'skipped' || r.status === 'pending') && r.issues.length) { + console.log(` · ${r.issues[0]}`); + } + } + const count = (s: WidgetReconcileResult['status']) => results.filter((r) => r.status === s).length; + console.log( + `\n${mismatches === 0 ? '🎉' : '🔴'} ${count('ok')} ok · ${count('skipped')} skipped · ` + + `${count('pending')} pending · ${mismatches} mismatch`, + ); + return mismatches; +} + +/** Run a reconcileApp() and exit the process with a CI-friendly code. */ +export function runAsMain(run: () => Promise): void { + run() + .then((mismatches) => process.exit(mismatches === 0 ? 0 : 1)) + .catch((err) => { + console.error('❌ Reconciliation runner failed:', err); + process.exit(2); + }); +} diff --git a/scripts/analytics-reconcile/macros.ts b/scripts/analytics-reconcile/macros.ts new file mode 100644 index 000000000..378489230 --- /dev/null +++ b/scripts/analytics-reconcile/macros.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0021 Phase 2 — date-macro resolver for the reconciliation harness. +// +// In production the renderer (@object-ui/core `resolveDateMacros()`) resolves +// `{today}` / `{current_quarter_start}` / `{30_days_ago}` to concrete dates +// BEFORE issuing the query — identically for the legacy and dataset forms. This +// repo has no runtime resolver, so the harness must resolve them itself, or the +// two paths diverge on the unparseable placeholder (the dataset filter-normalizer +// drops an unparseable date filter; engine.aggregate keeps it). The resolved +// VALUE is arbitrary — what matters is that BOTH paths receive the identical +// concrete filter, so equality holds iff the two query paths agree semantically. +// +// Token grammar mirrors packages/spec/src/data/date-macros.zod.ts. + +import { DATE_MACRO_WRAPPED_RE, parseDateMacroParam } from '@objectstack/spec/data'; + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} +/** Monday-based week start (matches the spec's "Monday 00:00 of this week"). */ +function startOfWeek(d: Date): Date { + const s = startOfDay(d); + const dow = (s.getDay() + 6) % 7; // 0 = Monday + s.setDate(s.getDate() - dow); + return s; +} +function startOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), 1); } +function startOfQuarter(d: Date): Date { return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1); } +function startOfYear(d: Date): Date { return new Date(d.getFullYear(), 0, 1); } + +// ObjectStack `date` fields are stored as epoch-millis (the seed loader writes +// `cel`daysFromNow(n)`` as a timestamp number), so resolve macros to epoch-millis +// — a number compares correctly against the stored column, where an ISO string +// would not. The exact value is arbitrary as long as BOTH paths get the same one. +const iso = (d: Date): number => d.getTime(); + +type PeriodKind = 'week' | 'month' | 'quarter' | 'year'; +const startOf: Record Date> = { + week: startOfWeek, month: startOfMonth, quarter: startOfQuarter, year: startOfYear, +}; +function addPeriod(kind: PeriodKind, d: Date, n: number): Date { + const r = new Date(d); + if (kind === 'week') r.setDate(r.getDate() + n * 7); + else if (kind === 'month') r.setMonth(r.getMonth() + n); + else if (kind === 'quarter') r.setMonth(r.getMonth() + n * 3); + else r.setFullYear(r.getFullYear() + n); + return r; +} + +/** Resolve a single token name (inside the braces) to an epoch-millis number, or null. */ +function resolveToken(name: string, now: Date): number | null { + switch (name) { + case 'today': return iso(startOfDay(now)); + case 'yesterday': { const d = startOfDay(now); d.setDate(d.getDate() - 1); return iso(d); } + case 'tomorrow': { const d = startOfDay(now); d.setDate(d.getDate() + 1); return iso(d); } + case 'now': return now.getTime(); + } + + // current/last/next × week/month/quarter/year × start/end (+ bare aliases). + const m = name.match(/^(current|last|next)?_?(week|month|quarter|year)_(start|end)$/); + if (m) { + const rel = (m[1] ?? 'current') as 'current' | 'last' | 'next'; + const kind = m[2] as PeriodKind; + const bound = m[3] as 'start' | 'end'; + const offset = rel === 'last' ? -1 : rel === 'next' ? 1 : 0; + const periodStart = startOf[kind](addPeriod(kind, now, offset)); + if (bound === 'start') return iso(periodStart); + // end = day before the next period's start. + const nextStart = addPeriod(kind, periodStart, 1); + nextStart.setDate(nextStart.getDate() - 1); + return iso(nextStart); + } + + // Parameterised: N__(ago|from_now). + const p = parseDateMacroParam(name); + if (p) { + const d = new Date(now); + const sign = p.direction === 'ago' ? -1 : 1; + switch (p.unit) { + case 'minute': d.setMinutes(d.getMinutes() + sign * p.n); break; + case 'hour': d.setHours(d.getHours() + sign * p.n); break; + case 'day': d.setDate(d.getDate() + sign * p.n); break; + case 'week': d.setDate(d.getDate() + sign * p.n * 7); break; + case 'month': d.setMonth(d.getMonth() + sign * p.n); break; + case 'year': d.setFullYear(d.getFullYear() + sign * p.n); break; + } + return p.unit === 'minute' || p.unit === 'hour' ? d.getTime() : iso(startOfDay(d)); + } + return null; +} + +/** Resolve a placeholder string like `'{today}'` / `'${30_days_ago}'`, else return it unchanged. */ +function resolveValue(value: unknown, now: Date): unknown { + if (typeof value !== 'string') return value; + const m = value.match(DATE_MACRO_WRAPPED_RE); + if (!m) return value; + return resolveToken(m[1], now) ?? value; +} + +/** + * Deep-clone a FilterCondition, replacing every `{date-macro}` placeholder with + * a concrete ISO date. `now` is fixed once per call so all tokens in one filter + * resolve against the same instant. + */ +export function resolveDateMacros(filter: T, now: Date = new Date()): T { + const walk = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(walk); + if (node && typeof node === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(node as Record)) out[k] = walk(v); + return out; + } + return resolveValue(node, now); + }; + return walk(filter) as T; +} diff --git a/scripts/analytics-reconcile/reconcile.ts b/scripts/analytics-reconcile/reconcile.ts new file mode 100644 index 000000000..d6bd69fd7 --- /dev/null +++ b/scripts/analytics-reconcile/reconcile.ts @@ -0,0 +1,356 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0021 Phase 2 — read-only analytics reconciliation (关键兜底 / hard gate). +// +// For every dual-form presentation (a report / dashboard widget that carries +// BOTH the legacy inline query AND the new `dataset` binding), run BOTH paths +// against the SAME engine and assert they return identical numbers. This is the +// safety net that catches an AI migration silently dropping a filter, flipping +// an aggregate口径 (count vs count_distinct), or pointing at the wrong field / +// relationship. It ONLY verifies — it never rewrites — and is the gate that +// must be green before the inline form is deleted (single-form convergence). +// +// Engine-agnostic on purpose: this module imports TYPES only. The caller injects +// the two executors (legacy `aggregate()` + dataset `queryDataset()`) bound to a +// real booted kernel — see app-todo.ts for the wiring. + +import type { Dashboard, DashboardWidget, Dataset, Report } from '@objectstack/spec/ui'; +import type { FilterCondition } from '@objectstack/spec/data'; +import type { DatasetSelection } from '@objectstack/spec/contracts'; + +/** A groupBy item — a plain field, or a date field bucketed by a granularity. */ +export type GroupByItem = string | { field: string; dateGranularity: string }; + +/** The legacy inline-query path, lowered to an ObjectQL `aggregate()` call. */ +export interface OldAggregateSpec { + objectName: string; + filter?: FilterCondition; + groupBy: GroupByItem[]; + aggregations: Array<{ field: string; method: string; alias: string }>; +} + +/** The field name a groupBy item groups on (the key the engine returns rows under). */ +function groupByField(g: GroupByItem): string { + return typeof g === 'string' ? g : g.field; +} + +/** The executors the caller binds to a booted kernel. */ +export interface ReconcileExecutors { + /** Legacy path — `engine.aggregate(object, { where, groupBy, aggregations })`. */ + runAggregate(spec: OldAggregateSpec): Promise[]>; + /** New path — `analytics.queryDataset(dataset, selection)`. */ + runDataset(dataset: Dataset, selection: DatasetSelection): Promise[]>; + /** + * Resolve `{date-macro}` placeholders in a filter to concrete values BEFORE + * both paths run — mirroring the renderer. Applied identically to both forms, + * so the resolved value is arbitrary; what matters is the two paths see the + * SAME concrete filter (no normalizer-drop artifact on unparseable strings). + */ + resolveFilter?: (filter: FilterCondition | undefined) => FilterCondition | undefined; +} + +export interface WidgetReconcileResult { + widgetId: string; + /** + * - `ok` — both forms returned identical numbers. + * - `mismatch` — numbers diverged (or a path threw). FAILS the gate. + * - `skipped` — dual-form but not numerically reconcilable here (e.g. a + * multi-dimension widget vs a single-categoryField legacy form). + * - `pending` — still inline-only; not yet dataset-bound (coverage gap, not a failure). + */ + status: 'ok' | 'mismatch' | 'skipped' | 'pending'; + issues: string[]; + /** Canonical {dimsKey → measureValues[]} maps for both paths (for diagnostics). */ + oldRows?: Record; + newRows?: Record; +} + +const NO_DIMS = '∅'; +const DIM_SEP = '∥'; +/** Relative tolerance for float measure comparison (counts are exact). */ +const EPSILON = 1e-9; + +/** + * A widget is reconcilable only when it carries BOTH forms: the legacy inline + * `object` query AND the new `dataset` binding with at least one measure name. + */ +export function isReconcilableWidget( + w: DashboardWidget, +): w is DashboardWidget & { object: string; dataset: string; values: string[] } { + return ( + typeof w.object === 'string' && + typeof w.dataset === 'string' && + Array.isArray(w.values) && + w.values.length > 0 + ); +} + +/** + * Lower a widget's LEGACY inline fields to an aggregate spec — derived purely + * from `object` / `categoryField` / `valueField` / `aggregate` / `measures` / + * `filter`, NOT from the dataset. That independence is the whole point: if the + * dataset was authored to mean something different, the numbers diverge here. + */ +export function deriveOldAggregate(w: DashboardWidget): OldAggregateSpec { + const groupBy: GroupByItem[] = w.categoryField + ? [w.categoryGranularity + ? { field: w.categoryField, dateGranularity: w.categoryGranularity } + : w.categoryField] + : []; + const aggregations: OldAggregateSpec['aggregations'] = []; + if (w.measures && w.measures.length > 0) { + w.measures.forEach((m, i) => { + aggregations.push({ method: m.aggregate ?? 'count', field: m.valueField ?? '*', alias: `m${i}` }); + }); + } else { + aggregations.push({ method: w.aggregate ?? 'count', field: w.valueField ?? '*', alias: 'm0' }); + } + return { objectName: w.object as string, filter: w.filter, groupBy, aggregations }; +} + +/** Build the dataset selection from the widget's `dimensions` / `values` / `filter`. */ +export function deriveSelection(w: DashboardWidget): DatasetSelection { + const dimensions = w.dimensions ?? []; + const selection: DatasetSelection = { + dimensions, + measures: w.values as string[], + runtimeFilter: w.filter, + }; + // Mirror the legacy `categoryGranularity` as a timeDimension so the dataset + // path buckets the date dimension identically (ObjectQLStrategy honours it). + if (w.categoryGranularity && dimensions.length === 1) { + selection.timeDimensions = [{ dimension: dimensions[0], granularity: w.categoryGranularity }]; + } + return selection; +} + +function toNum(v: unknown): number | null { + if (v == null) return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; +} + +/** Stable key for a row's dimension tuple, ordered by `dimKeys`. */ +function dimsKeyOf(row: Record, dimKeys: string[]): string { + if (dimKeys.length === 0) return NO_DIMS; + return dimKeys.map((k) => String(row[k] ?? '∄')).join(DIM_SEP); +} + +/** Canonicalize rows into {dimsKey → [measure values in `measureKeys` order]}. */ +function normalize( + rows: Record[], + dimKeys: string[], + measureKeys: string[], +): Record { + const out: Record = {}; + for (const row of rows) { + out[dimsKeyOf(row, dimKeys)] = measureKeys.map((m) => toNum(row[m])); + } + return out; +} + +function numbersEqual(a: number | null, b: number | null): boolean { + if (a === null || b === null) return a === b; + if (a === b) return true; + const scale = Math.max(Math.abs(a), Math.abs(b), 1); + return Math.abs(a - b) <= EPSILON * scale; +} + +/** Diff two canonical maps; returns human-readable issue strings (empty = match). */ +function diff( + oldMap: Record, + newMap: Record, +): string[] { + const issues: string[] = []; + const keys = new Set([...Object.keys(oldMap), ...Object.keys(newMap)]); + for (const key of keys) { + const o = oldMap[key]; + const n = newMap[key]; + const label = key === NO_DIMS ? '(scalar)' : key; + if (!o) { issues.push(`group "${label}": present in dataset path but missing from legacy path (new=${JSON.stringify(n)})`); continue; } + if (!n) { issues.push(`group "${label}": present in legacy path but missing from dataset path (old=${JSON.stringify(o)})`); continue; } + if (o.length !== n.length) { issues.push(`group "${label}": measure count differs (old=${o.length} new=${n.length})`); continue; } + for (let i = 0; i < o.length; i++) { + if (!numbersEqual(o[i], n[i])) issues.push(`group "${label}" measure[${i}]: legacy=${o[i]} dataset=${n[i]}`); + } + } + return issues; +} + +/** Reconcile a single dual-form widget against its dataset. */ +export async function reconcileWidget( + w: DashboardWidget, + dataset: Dataset | undefined, + exec: ReconcileExecutors, +): Promise { + if (!isReconcilableWidget(w)) { + return { widgetId: w.id, status: 'skipped', issues: ['not dual-form (missing object or dataset/values)'] }; + } + if (!dataset) { + return { widgetId: w.id, status: 'mismatch', issues: [`dataset "${w.dataset}" not found in registry`] }; + } + const dims = w.dimensions ?? []; + if (dims.length > 1 && w.categoryField) { + return { widgetId: w.id, status: 'skipped', issues: ['multi-dimension widget cannot reconcile against single categoryField legacy form'] }; + } + + // Resolve date macros once, then feed the identical filter to both paths. + const wResolved = exec.resolveFilter + ? ({ ...w, filter: exec.resolveFilter(w.filter) } as DashboardWidget) + : w; + const oldSpec = deriveOldAggregate(wResolved); + const selection = deriveSelection(wResolved); + + let oldRaw: Record[]; + let newRaw: Record[]; + try { + oldRaw = await exec.runAggregate(oldSpec); + } catch (e) { + return { widgetId: w.id, status: 'mismatch', issues: [`legacy aggregate threw: ${(e as Error).message}`] }; + } + try { + newRaw = await exec.runDataset(dataset, selection); + } catch (e) { + return { widgetId: w.id, status: 'mismatch', issues: [`dataset query threw: ${(e as Error).message}`] }; + } + + // Legacy rows key dimensions by the raw field name (categoryField); dataset + // rows key them by the dimension NAME. They share the same value domain, so + // canonicalize each by its own key set, then compare positionally. + const oldMap = normalize(oldRaw, oldSpec.groupBy.map(groupByField), oldSpec.aggregations.map((a) => a.alias)); + const newMap = normalize(newRaw, dims, w.values as string[]); + + const issues = diff(oldMap, newMap); + return { + widgetId: w.id, + status: issues.length === 0 ? 'ok' : 'mismatch', + issues, + oldRows: oldMap, + newRows: newMap, + }; +} + +// ───────────────────────── Reports ───────────────────────── +// +// Reports have NO server-side executor in this repo (the pivot is computed +// client-side). We reconcile the NUMBERS only: lower the legacy report's +// groupings + aggregate columns to the same `aggregate()` call, and compare to +// the dataset's `rows`/`values`. A matrix (`groupingsAcross`) flattens to a 2D +// groupBy — the cell values are identical to the rendered pivot. A detail/summary +// report with no aggregate columns reconciles its group COUNT (count(*)), which +// is what the legacy report's group headers show. + +/** A report is reconcilable when it carries BOTH the inline query and the dataset binding. */ +export function isReconcilableReport( + r: Report, +): r is Report & { objectName: string; dataset: string; values: string[] } { + return ( + typeof r.objectName === 'string' && + typeof r.dataset === 'string' && + Array.isArray(r.values) && + r.values.length > 0 + ); +} + +/** Lower a report's LEGACY inline fields (groupings + aggregate columns) to an aggregate spec. */ +export function deriveReportAggregate(r: Report): OldAggregateSpec { + const groupBy = [ + ...(r.groupingsDown ?? []).map((g) => g.field), + ...(r.groupingsAcross ?? []).map((g) => g.field), + ]; + const aggCols = (r.columns ?? []).filter((c) => c.aggregate); + const aggregations: OldAggregateSpec['aggregations'] = aggCols.length > 0 + ? aggCols.map((c, i) => ({ method: c.aggregate as string, field: c.field, alias: `m${i}` })) + : [{ method: 'count', field: '*', alias: 'm0' }]; // detail/summary → group count + return { objectName: r.objectName as string, filter: r.filter, groupBy, aggregations }; +} + +/** Reconcile a single dual-form report against its dataset. */ +export async function reconcileReport( + r: Report, + dataset: Dataset | undefined, + exec: ReconcileExecutors, +): Promise { + if (!isReconcilableReport(r)) { + return { widgetId: r.name, status: 'skipped', issues: ['not dual-form (missing objectName or dataset/values)'] }; + } + if (!dataset) { + return { widgetId: r.name, status: 'mismatch', issues: [`dataset "${r.dataset}" not found in registry`] }; + } + const rows = r.rows ?? []; + + // Resolve macros on the legacy filter and the dataset runtimeFilter identically. + const legacyFilter = exec.resolveFilter ? exec.resolveFilter(r.filter) : r.filter; + const runtimeFilter = exec.resolveFilter ? exec.resolveFilter(r.runtimeFilter ?? r.filter) : (r.runtimeFilter ?? r.filter); + + const oldSpec = { ...deriveReportAggregate(r), filter: legacyFilter }; + const selection: DatasetSelection = { dimensions: rows, measures: r.values as string[], runtimeFilter }; + + let oldRaw: Record[]; + let newRaw: Record[]; + try { + oldRaw = await exec.runAggregate(oldSpec); + } catch (e) { + return { widgetId: r.name, status: 'mismatch', issues: [`legacy aggregate threw: ${(e as Error).message}`] }; + } + try { + newRaw = await exec.runDataset(dataset, selection); + } catch (e) { + return { widgetId: r.name, status: 'mismatch', issues: [`dataset query threw: ${(e as Error).message}`] }; + } + + const oldMap = normalize(oldRaw, oldSpec.groupBy.map(groupByField), oldSpec.aggregations.map((a) => a.alias)); + const newMap = normalize(newRaw, rows, r.values as string[]); + const issues = diff(oldMap, newMap); + return { widgetId: r.name, status: issues.length === 0 ? 'ok' : 'mismatch', issues, oldRows: oldMap, newRows: newMap }; +} + +/** Reconcile every dual-form report. */ +export async function reconcileReports( + reports: Report[], + datasets: Map, + exec: ReconcileExecutors, +): Promise { + const results: WidgetReconcileResult[] = []; + for (const r of reports) { + // Joined reports have no single data binding — reconcile each block. + if (r.type === 'joined' && Array.isArray(r.blocks)) { + for (const block of r.blocks) { + const id = `${r.name}/${block.name}`; + if (typeof block.dataset !== 'string') { + results.push({ widgetId: id, status: 'pending', issues: ['joined block inline-only (record-list / embed)'] }); + continue; + } + // A block is shaped like a mini-report (objectName/columns/groupings/ + // filter + dataset/rows/values/runtimeFilter) — reconcile it as one. + const asReport = { ...block, name: id } as unknown as Report; + results.push(await reconcileReport(asReport, datasets.get(block.dataset), exec)); + } + continue; + } + if (typeof r.dataset !== 'string') { + results.push({ widgetId: r.name, status: 'pending', issues: ['inline-only — not yet dataset-bound'] }); + continue; + } + results.push(await reconcileReport(r, datasets.get(r.dataset), exec)); + } + return results; +} + +/** Reconcile every dual-form widget on a dashboard. */ +export async function reconcileDashboard( + dashboard: Dashboard, + datasets: Map, + exec: ReconcileExecutors, +): Promise { + const results: WidgetReconcileResult[] = []; + for (const w of dashboard.widgets) { + if (typeof w.dataset !== 'string') { + // Inline-only widget — surfaced as `pending` so migration coverage stays honest. + results.push({ widgetId: w.id, status: 'pending', issues: ['inline-only — not yet dataset-bound'] }); + continue; + } + results.push(await reconcileWidget(w, datasets.get(w.dataset), exec)); + } + return results; +}