diff --git a/.changeset/no-fabricated-dashboard-trends.md b/.changeset/no-fabricated-dashboard-trends.md new file mode 100644 index 000000000..edfcc8a1b --- /dev/null +++ b/.changeset/no-fabricated-dashboard-trends.md @@ -0,0 +1,22 @@ +--- +'hotcrm': patch +--- + +Stop the CRM, Sales and Service dashboards showing invented period-over-period +trends. Twelve KPI tiles across those three dashboards carried a hardcoded +delta — `trend: { value: 12.5, direction: 'up', label: 'vs last month' }` and +eleven more like it — that no query ever produced and nothing ever recomputed. +They rendered the same "+12.5% vs last month" against every dataset, on every +tenant, including a freshly seeded database where the claim was provably false, +and they moved in the wrong direction as often as the right one. + +A period-over-period delta is a measurement: it can only come from comparing +this period's result against the previous period's. Until the console can run +that comparison for dataset metrics (widget `compareTo`), a tile now shows the +number it actually measured and nothing else. This is the rule the Executive +dashboard already followed — its own fabricated percentages were removed in +#500 — so all four dashboards are finally honest in the same way. + +A new guard in `test/analytics-integrity.test.ts` walks every dashboard widget +and fails on any `trend` carrying a literal number, at any nesting depth, so +hand-typed deltas cannot reappear in metadata. Fixes #587. diff --git a/src/dashboards/crm.dashboard.ts b/src/dashboards/crm.dashboard.ts index 861df3418..fe53b3573 100644 --- a/src/dashboards/crm.dashboard.ts +++ b/src/dashboards/crm.dashboard.ts @@ -9,12 +9,19 @@ import { avgDealSizeMetricWidget, pipelineByStageFunnelWidget } from './shared-w * Single-page snapshot of revenue, pipeline, and customer activity. Designed * to mirror the polished CRM dashboard reference at * https://github.com/objectstack-ai/objectui/tree/main/examples/crm/src/dashboards - * — KPI tiles with trend indicators and icons, an area-chart revenue trend, a - * lead-source donut, a pipeline funnel, and a recent-deals table. + * — KPI tiles with icons, an area-chart revenue trend, a lead-source donut, a + * pipeline funnel, and a recent-deals table. * * This dashboard intentionally uses the framework's first-class metadata fields * (colorVariant, chartConfig, header, dateRange, descriptions, action buttons) * rather than ad-hoc hex strings stuffed into `options.color`. + * + * No KPI tile declares a `trend`. A period-over-period delta is a measurement, + * so it has to come from a real comparison query (widget `compareTo`) once the + * renderer supports it for dataset metrics — the percentages this file used to + * carry were typed by hand and recomputed by nothing, so they kept asserting + * the same "+12.5% vs last month" on any data, including an empty database. + * Same rule as the executive dashboard (#500, #587). */ export const CrmOverviewDashboard: Dashboard = { name: 'crm_overview_dashboard', @@ -60,10 +67,7 @@ export const CrmOverviewDashboard: Dashboard = { colorVariant: 'success', dataset: 'opportunity_metrics', values: ['total_amount'], layout: { x: 0, y: 0, w: 3, h: 2 }, - options: { - icon: 'DollarSign', - trend: { value: 12.5, direction: 'up', label: 'vs last month' }, - }, + options: { icon: 'DollarSign' }, }, { id: 'active_deals', @@ -77,7 +81,6 @@ export const CrmOverviewDashboard: Dashboard = { options: { icon: 'Briefcase', format: '0,0', - trend: { value: 2.1, direction: 'down', label: 'vs last month' }, }, }, { @@ -92,15 +95,11 @@ export const CrmOverviewDashboard: Dashboard = { options: { icon: 'Trophy', format: '0,0', - trend: { value: 4.3, direction: 'up', label: 'vs last month' }, }, }, - avgDealSizeMetricWidget({ x: 9, y: 0, w: 3, h: 2 }, { - options: { - icon: 'bar-chart', - trend: { value: 1.2, direction: 'up', label: 'vs last month' }, - }, - }), + // No overrides left once the fabricated trend is gone: the factory already + // declares `options: { icon: 'bar-chart' }`, so re-passing it said nothing. + avgDealSizeMetricWidget({ x: 9, y: 0, w: 3, h: 2 }), // ─── Charts Row 1 ───────────────────────────────────────────────── { diff --git a/src/dashboards/sales.dashboard.ts b/src/dashboards/sales.dashboard.ts index 4f4c8b290..c0f14e634 100644 --- a/src/dashboards/sales.dashboard.ts +++ b/src/dashboards/sales.dashboard.ts @@ -9,6 +9,13 @@ import { avgDealSizeMetricWidget, pipelineByStageFunnelWidget } from './shared-w * Pipeline analytics, win rate trends, and rep performance for the sales team. * Modeled after the polished CRM dashboard reference at * https://github.com/objectstack-ai/objectui/tree/main/examples/crm. + * + * No KPI tile declares a `trend`. A period-over-period delta is a measurement, + * so it has to come from a real comparison query (widget `compareTo`) once the + * renderer supports it for dataset metrics — the percentages this file used to + * carry were typed by hand and recomputed by nothing, so they kept asserting + * the same "+8.4% vs last quarter" on any data, including an empty database. + * Same rule as the executive dashboard (#500, #587). */ export const SalesDashboard: Dashboard = { name: 'sales_dashboard', @@ -71,7 +78,6 @@ export const SalesDashboard: Dashboard = { options: { icon: 'DollarSign', format: '0,0', - trend: { value: 8.4, direction: 'up', label: 'vs last quarter' }, }, }, { @@ -87,7 +93,6 @@ export const SalesDashboard: Dashboard = { options: { icon: 'Trophy', format: '0,0', - trend: { value: 14.7, direction: 'up', label: 'vs last quarter' }, }, }, { @@ -102,7 +107,6 @@ export const SalesDashboard: Dashboard = { options: { icon: 'Briefcase', format: '0,0', - trend: { value: 2.1, direction: 'down', label: 'vs last quarter' }, }, }, avgDealSizeMetricWidget({ x: 9, y: 0, w: 3, h: 2 }, { @@ -113,7 +117,6 @@ export const SalesDashboard: Dashboard = { options: { icon: 'bar-chart', format: '0,0', - trend: { value: 4.3, direction: 'up', label: 'vs last quarter' }, }, }), diff --git a/src/dashboards/service.dashboard.ts b/src/dashboards/service.dashboard.ts index 0213bdcdb..b95215629 100644 --- a/src/dashboards/service.dashboard.ts +++ b/src/dashboards/service.dashboard.ts @@ -9,6 +9,13 @@ import type { Dashboard } from '@objectstack/spec/ui'; * Uses semantic colorVariant tokens (warning/danger/success) and chartConfig * palettes instead of raw hex values, mirroring the polished CRM dashboard * reference at https://github.com/objectstack-ai/objectui/tree/main/examples/crm. + * + * No KPI tile declares a `trend`. A period-over-period delta is a measurement, + * so it has to come from a real comparison query (widget `compareTo`) once the + * renderer supports it for dataset metrics — the percentages this file used to + * carry were typed by hand and recomputed by nothing, so they kept asserting + * the same "-6.2% vs last week" on any data, including an empty database. + * Same rule as the executive dashboard (#500, #587). */ export const ServiceDashboard: Dashboard = { name: 'service_dashboard', @@ -102,7 +109,6 @@ export const ServiceDashboard: Dashboard = { options: { icon: 'Inbox', format: '0,0', - trend: { value: 6.2, direction: 'down', label: 'vs last week' }, }, }, { @@ -117,7 +123,6 @@ export const ServiceDashboard: Dashboard = { options: { icon: 'AlertTriangle', format: '0,0', - trend: { value: 1.0, direction: 'up', label: 'vs last week' }, }, }, { @@ -133,7 +138,6 @@ export const ServiceDashboard: Dashboard = { icon: 'Clock', format: '0.0', suffix: 'h', - trend: { value: 9.8, direction: 'down', label: 'vs last week' }, }, }, { @@ -148,7 +152,6 @@ export const ServiceDashboard: Dashboard = { options: { icon: 'ShieldAlert', format: '0,0', - trend: { value: 2.4, direction: 'down', label: 'vs last week' }, }, }, diff --git a/test/analytics-integrity.test.ts b/test/analytics-integrity.test.ts index d3c4aa203..4f3f1fb39 100644 --- a/test/analytics-integrity.test.ts +++ b/test/analytics-integrity.test.ts @@ -111,6 +111,62 @@ describe('dashboard widget bindings resolve', () => { }); }); +describe('metric tiles carry no fabricated trend deltas', () => { + /** + * A period-over-period delta is a MEASUREMENT: it can only come from + * comparing this period's query result against the previous period's. A + * number sitting in static metadata was, by construction, typed by hand — no + * query produced it, nothing recomputes it, and it keeps asserting "+12.5% vs + * last month" forever, including on a freshly seeded database where it is + * provably false. The executive dashboard dropped its own on exactly this + * reasoning (#500); the CRM, Sales and Service tiles kept theirs until #587. + * + * The honest source of a delta is a real comparison query (widget + * `compareTo`) once the renderer supports it for dataset metrics. Until then + * a tile shows the number it actually measured and nothing else, so this + * guard rejects the literal — anywhere in a widget, under any nesting, since + * the console reads `options` as a free-form bag and a hand-written trend can + * reappear at any depth. + */ + + /** Every `[path, value]` pair whose key is `trend`, at any depth. */ + function* trendDeclarations(node: unknown, path: string): Generator<[string, unknown]> { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const [i, item] of node.entries()) yield* trendDeclarations(item, `${path}[${i}]`); + return; + } + for (const [k, v] of Object.entries(node)) { + if (k === 'trend') yield [`${path}.${k}`, v]; + yield* trendDeclarations(v, `${path}.${k}`); + } + } + + /** `trend: 12.5` and `trend: { value: 12.5, … }` are both hand-typed deltas. */ + const carriesLiteralNumber = (trend: unknown): boolean => + typeof trend === 'number' || + (!!trend && typeof trend === 'object' && + Object.values(trend as AnyRec).some((v) => typeof v === 'number')); + + it('no dashboard widget declares a literal trend value', () => { + const bad: string[] = []; + for (const d of dashboards) { + for (const w of d.widgets ?? []) { + for (const [path, trend] of trendDeclarations(w, `${d.name}/${w.id}`)) { + if (carriesLiteralNumber(trend)) { + bad.push(`${path} = ${JSON.stringify(trend)}`); + } + } + } + } + expect( + bad, + 'hardcoded period-over-period deltas — a trend must be measured by a ' + + `comparison query, not typed into metadata:\n ${bad.join('\n ')}`, + ).toEqual([]); + }); +}); + describe('time windows stay relative at runtime', () => { /** * A filter value like `2026-06-29` in the metadata means someone computed