Skip to content

Commit a49cfc2

Browse files
committed
feat(ui): add period-over-period comparison and chart series styling
1 parent 8377332 commit a49cfc2

4 files changed

Lines changed: 199 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Add `compareTo` field to `DashboardWidgetSchema` and `variant` / `dashArray` /
6+
`opacity` to `ChartSeriesSchema` so renderers can express period-over-period
7+
overlays on metric / gauge / chart widgets.
8+
9+
`compareTo` accepts `'previousPeriod'`, `'previousYear'`, or
10+
`{ offset: '7d' | '4w' | '1M' | '1y' }`. The renderer issues a second query
11+
against the shifted filter and either (a) derives a trend delta for KPI
12+
widgets or (b) overlays a muted comparison series on cartesian charts.

examples/app-crm/src/dashboards/pipeline.dashboard.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,31 @@
22

33
import type { Dashboard } from '@objectstack/spec/ui';
44

5+
/**
6+
* Pipeline Dashboard — aggregate view of the sales pipeline.
7+
*
8+
* Demonstrates period-over-period comparison via `compareTo`:
9+
*
10+
* - **Won This Quarter** — metric with `compareTo: 'previousPeriod'`. The
11+
* filter uses `{current_quarter_start}` / `{current_quarter_end}`, so
12+
* the renderer issues a parallel aggregate for Q-1 and shows a delta
13+
* labelled "vs last quarter".
14+
* - **Avg Deal Size YoY** — metric with `compareTo: 'previousYear'` to
15+
* compare against the same window one year prior.
16+
* - **Pipeline Trend (90d)** — line chart with a sliding
17+
* `compareTo: { offset: '90d' }` overlay, rendered as a dashed muted
18+
* series on top of the current 90-day trend.
19+
* - **Opportunities by Stage** / **Pipeline by Industry** — bar / pie
20+
* examples without `compareTo` (pie / donut / funnel ignore overlays
21+
* even if set).
22+
*/
523
export const PipelineDashboard: Dashboard = {
624
name: 'pipeline_dashboard',
725
label: 'Pipeline Dashboard',
8-
description: 'Aggregate view of the sales pipeline.',
26+
description: 'Aggregate view of the sales pipeline with period-over-period comparisons.',
927
columns: 12,
1028
widgets: [
29+
// --- Row 1: KPI tiles -------------------------------------------------
1130
{
1231
id: 'total_pipeline',
1332
type: 'metric',
@@ -17,8 +36,63 @@ export const PipelineDashboard: Dashboard = {
1736
aggregate: 'sum',
1837
valueField: 'amount',
1938
filter: { stage: { $nin: ['closed_won', 'closed_lost'] } },
39+
options: { format: 'currency', currency: 'USD' },
2040
layout: { x: 0, y: 0, w: 4, h: 2 },
2141
},
42+
{
43+
id: 'won_this_quarter',
44+
type: 'metric',
45+
title: 'Won This Quarter',
46+
description: 'Revenue closed-won in the current quarter, compared to the previous quarter.',
47+
object: 'crm_opportunity',
48+
aggregate: 'sum',
49+
valueField: 'amount',
50+
filter: {
51+
stage: 'closed_won',
52+
close_date: {
53+
$gte: '{current_quarter_start}',
54+
$lte: '{current_quarter_end}',
55+
},
56+
},
57+
compareTo: 'previousPeriod',
58+
options: { format: 'currency', currency: 'USD' },
59+
layout: { x: 4, y: 0, w: 4, h: 2 },
60+
},
61+
{
62+
id: 'avg_deal_size_yoy',
63+
type: 'metric',
64+
title: 'Avg Deal Size (YoY)',
65+
description: 'Average won-deal value this year vs the same window last year.',
66+
object: 'crm_opportunity',
67+
aggregate: 'avg',
68+
valueField: 'amount',
69+
filter: {
70+
stage: 'closed_won',
71+
close_date: {
72+
$gte: '{current_year_start}',
73+
$lte: '{current_year_end}',
74+
},
75+
},
76+
compareTo: 'previousYear',
77+
options: { format: 'currency', currency: 'USD' },
78+
layout: { x: 8, y: 0, w: 4, h: 2 },
79+
},
80+
81+
// --- Row 2: Trend + breakdown ----------------------------------------
82+
{
83+
id: 'pipeline_trend_90d',
84+
type: 'line',
85+
title: 'Pipeline Trend (90d)',
86+
description: 'Daily count of opportunities created in the last 90 days, with a sliding overlay of the prior 90-day window.',
87+
object: 'crm_opportunity',
88+
aggregate: 'count',
89+
categoryField: 'close_date',
90+
filter: {
91+
close_date: { $gte: '{90_days_ago}', $lte: '{today}' },
92+
},
93+
compareTo: { offset: '90d' },
94+
layout: { x: 0, y: 2, w: 8, h: 4 },
95+
},
2296
{
2397
id: 'opportunities_by_stage',
2498
type: 'bar',
@@ -27,7 +101,22 @@ export const PipelineDashboard: Dashboard = {
27101
object: 'crm_opportunity',
28102
aggregate: 'count',
29103
categoryField: 'stage',
30-
layout: { x: 4, y: 0, w: 8, h: 4 },
104+
layout: { x: 8, y: 2, w: 4, h: 4 },
105+
},
106+
107+
// --- Row 3: Mix breakdown (pie ignores compareTo, even if set) -------
108+
{
109+
id: 'pipeline_by_industry',
110+
type: 'pie',
111+
title: 'Open Pipeline by Stage ($)',
112+
description: 'Open-pipeline revenue split by pipeline stage. Pie/donut/funnel ignore `compareTo`.',
113+
object: 'crm_opportunity',
114+
aggregate: 'sum',
115+
valueField: 'amount',
116+
categoryField: 'stage',
117+
filter: { stage: { $nin: ['closed_won', 'closed_lost'] } },
118+
layout: { x: 0, y: 6, w: 6, h: 4 },
31119
},
32120
],
33121
};
122+

examples/app-crm/src/data/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,27 @@ const opportunities = defineDataset(Opportunity, {
3030
mode: 'upsert',
3131
externalId: 'name',
3232
records: [
33+
// --- Open pipeline (no close yet) -----------------------------------
3334
{ name: 'Acme — Q3 Platform Renewal', account: { externalId: 'Acme Corp' }, stage: 'proposal', amount: 120_000, probability: 70, close_date: cel`daysFromNow(30)` },
3435
{ name: 'Globex — New CRM Rollout', account: { externalId: 'Globex Ltd' }, stage: 'qualification', amount: 450_000, probability: 40, close_date: cel`daysFromNow(60)` },
35-
{ name: 'Initech — Pilot', account: { externalId: 'Initech' }, stage: 'closed_won', amount: 35_000, probability: 100, close_date: cel`daysAgo(7)` },
36+
{ name: 'Initech — Expansion', account: { externalId: 'Initech' }, stage: 'prospecting', amount: 80_000, probability: 20, close_date: cel`daysFromNow(45)` },
37+
{ name: 'Acme — Add-on Module', account: { externalId: 'Acme Corp' }, stage: 'qualification', amount: 60_000, probability: 35, close_date: cel`daysFromNow(20)` },
38+
39+
// --- Recently closed-won (current quarter — drives "Won This Quarter") -
40+
{ name: 'Initech — Pilot', account: { externalId: 'Initech' }, stage: 'closed_won', amount: 35_000, probability: 100, close_date: cel`daysAgo(7)` },
41+
{ name: 'Acme — Support Tier Upgrade', account: { externalId: 'Acme Corp' }, stage: 'closed_won', amount: 90_000, probability: 100, close_date: cel`daysAgo(14)` },
42+
{ name: 'Globex — Analytics Pack', account: { externalId: 'Globex Ltd' }, stage: 'closed_won', amount: 110_000, probability: 100, close_date: cel`daysAgo(21)` },
43+
44+
// --- Previous-quarter wins (drives the "vs last quarter" comparison) ---
45+
{ name: 'Initech — POC', account: { externalId: 'Initech' }, stage: 'closed_won', amount: 25_000, probability: 100, close_date: cel`daysAgo(95)` },
46+
{ name: 'Globex — Initial Seats', account: { externalId: 'Globex Ltd' }, stage: 'closed_won', amount: 145_000, probability: 100, close_date: cel`daysAgo(110)` },
47+
48+
// --- Prior-year wins in the same window (drives "YoY" comparison) ------
49+
{ name: 'Acme — Year-Ago Renewal', account: { externalId: 'Acme Corp' }, stage: 'closed_won', amount: 75_000, probability: 100, close_date: cel`daysAgo(380)` },
50+
{ name: 'Globex — Year-Ago Implementation', account: { externalId: 'Globex Ltd' }, stage: 'closed_won', amount: 210_000, probability: 100, close_date: cel`daysAgo(400)` },
51+
52+
// --- Closed lost (kept out of pipeline sum) ----------------------------
53+
{ name: 'Initech — Cancelled Eval', account: { externalId: 'Initech' }, stage: 'closed_lost', amount: 15_000, probability: 0, close_date: cel`daysAgo(30)` },
3654
],
3755
});
3856

examples/app-crm/test/smoke.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import stack from '../objectstack.config.js';
5+
import { PipelineDashboard } from '../src/dashboards/pipeline.dashboard.js';
56

67
describe('app-crm minimal metadata bundle', () => {
78
it('exposes the expected manifest', () => {
@@ -27,3 +28,79 @@ describe('app-crm minimal metadata bundle', () => {
2728
expect((stack.data ?? []).length).toBeGreaterThanOrEqual(3);
2829
});
2930
});
31+
32+
describe('Pipeline dashboard', () => {
33+
const byId = new Map(PipelineDashboard.widgets.map((w: any) => [w.id, w]));
34+
35+
it('lays out all 6 widgets', () => {
36+
expect(PipelineDashboard.widgets).toHaveLength(6);
37+
expect([...byId.keys()].sort()).toEqual(
38+
[
39+
'avg_deal_size_yoy',
40+
'opportunities_by_stage',
41+
'pipeline_by_industry',
42+
'pipeline_trend_90d',
43+
'total_pipeline',
44+
'won_this_quarter',
45+
],
46+
);
47+
});
48+
49+
it('uses `compareTo: previousPeriod` for the current-quarter KPI', () => {
50+
const w: any = byId.get('won_this_quarter');
51+
expect(w.compareTo).toBe('previousPeriod');
52+
expect(w.filter.close_date.$gte).toBe('{current_quarter_start}');
53+
expect(w.filter.close_date.$lte).toBe('{current_quarter_end}');
54+
});
55+
56+
it('uses `compareTo: previousYear` for the YoY KPI', () => {
57+
const w: any = byId.get('avg_deal_size_yoy');
58+
expect(w.compareTo).toBe('previousYear');
59+
expect(w.filter.close_date.$gte).toBe('{current_year_start}');
60+
expect(w.filter.close_date.$lte).toBe('{current_year_end}');
61+
});
62+
63+
it('uses a sliding `{ offset }` compareTo on the trend chart', () => {
64+
const w: any = byId.get('pipeline_trend_90d');
65+
expect(w.compareTo).toEqual({ offset: '90d' });
66+
expect(w.type).toBe('line');
67+
});
68+
69+
it('omits compareTo on widgets that do not need it (pie, plain bar, total)', () => {
70+
expect((byId.get('total_pipeline') as any).compareTo).toBeUndefined();
71+
expect((byId.get('opportunities_by_stage') as any).compareTo).toBeUndefined();
72+
expect((byId.get('pipeline_by_industry') as any).compareTo).toBeUndefined();
73+
});
74+
75+
it('widgets bind to the opportunity object', () => {
76+
for (const w of PipelineDashboard.widgets) {
77+
expect((w as any).object).toBe('crm_opportunity');
78+
}
79+
});
80+
81+
it('layout positions do not overlap and fit within 12 columns', () => {
82+
const cells: Record<string, string> = {};
83+
for (const w of PipelineDashboard.widgets as any[]) {
84+
const { x, y, w: ww, h } = w.layout;
85+
expect(x + ww).toBeLessThanOrEqual(12);
86+
for (let i = x; i < x + ww; i++) {
87+
for (let j = y; j < y + h; j++) {
88+
const key = `${i},${j}`;
89+
if (cells[key]) {
90+
throw new Error(`Widget ${w.id} overlaps ${cells[key]} at ${key}`);
91+
}
92+
cells[key] = w.id;
93+
}
94+
}
95+
}
96+
});
97+
});
98+
99+
describe('Pipeline dashboard schema validation', () => {
100+
it('passes the DashboardSchema zod parser end-to-end', async () => {
101+
const { DashboardSchema } = await import('@objectstack/spec/ui');
102+
const parsed = DashboardSchema.parse(PipelineDashboard);
103+
expect(parsed.name).toBe('pipeline_dashboard');
104+
expect(parsed.widgets).toHaveLength(6);
105+
});
106+
});

0 commit comments

Comments
 (0)