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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/no-fabricated-dashboard-trends.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 13 additions & 14 deletions src/dashboards/crm.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -77,7 +81,6 @@ export const CrmOverviewDashboard: Dashboard = {
options: {
icon: 'Briefcase',
format: '0,0',
trend: { value: 2.1, direction: 'down', label: 'vs last month' },
},
},
{
Expand All @@ -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 ─────────────────────────────────────────────────
{
Expand Down
11 changes: 7 additions & 4 deletions src/dashboards/sales.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -71,7 +78,6 @@ export const SalesDashboard: Dashboard = {
options: {
icon: 'DollarSign',
format: '0,0',
trend: { value: 8.4, direction: 'up', label: 'vs last quarter' },
},
},
{
Expand All @@ -87,7 +93,6 @@ export const SalesDashboard: Dashboard = {
options: {
icon: 'Trophy',
format: '0,0',
trend: { value: 14.7, direction: 'up', label: 'vs last quarter' },
},
},
{
Expand All @@ -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 }, {
Expand All @@ -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' },
},
}),

Expand Down
11 changes: 7 additions & 4 deletions src/dashboards/service.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -102,7 +109,6 @@ export const ServiceDashboard: Dashboard = {
options: {
icon: 'Inbox',
format: '0,0',
trend: { value: 6.2, direction: 'down', label: 'vs last week' },
},
},
{
Expand All @@ -117,7 +123,6 @@ export const ServiceDashboard: Dashboard = {
options: {
icon: 'AlertTriangle',
format: '0,0',
trend: { value: 1.0, direction: 'up', label: 'vs last week' },
},
},
{
Expand All @@ -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' },
},
},
{
Expand All @@ -148,7 +152,6 @@ export const ServiceDashboard: Dashboard = {
options: {
icon: 'ShieldAlert',
format: '0,0',
trend: { value: 2.4, direction: 'down', label: 'vs last week' },
},
},

Expand Down
56 changes: 56 additions & 0 deletions test/analytics-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading