feat(reports): prior-period comparison toggle on the Sales report
Adds a "Compare to prior period" toggle to the Sales report header. When on, the API recomputes the KPI window for the equal-length window immediately preceding the selected range (previousPeriodBounds) behind `?compare=1`, and the five window-derived KPI tiles (Won, Lost, Win rate, Avg time-to-close, New leads) render colour-correct "vs prior" deltas. Point-in-time tiles (Active interests, Pipeline value) have no prior-window analogue and intentionally show no delta. The prior-window query runs in parallel with the main batch and resolves to null when the toggle is off (zero cost). Toggle state persists in the saved-template config. Closes the spec's "period comparison on every report" gap for Sales; Operational already rendered period-start deltas. Pure helpers TDD'd: previousPeriodBounds (range.ts) + computeSalesKpiComparison (sales-comparison.ts), 7 unit tests. tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
tests/unit/analytics/range.test.ts
Normal file
35
tests/unit/analytics/range.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { previousPeriodBounds } from '@/lib/analytics/range';
|
||||
|
||||
describe('previousPeriodBounds', () => {
|
||||
it('returns the equal-length window immediately preceding the given bounds', () => {
|
||||
const from = new Date('2026-05-01T00:00:00.000Z');
|
||||
const to = new Date('2026-05-31T00:00:00.000Z'); // 30-day span
|
||||
const prior = previousPeriodBounds({ from, to });
|
||||
|
||||
// Prior window is contiguous (prior.to === current.from) and equal length.
|
||||
expect(prior.to.toISOString()).toBe('2026-05-01T00:00:00.000Z');
|
||||
expect(prior.from.toISOString()).toBe('2026-04-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('preserves sub-day spans (e.g. a single-day window)', () => {
|
||||
const from = new Date('2026-05-10T00:00:00.000Z');
|
||||
const to = new Date('2026-05-10T23:59:59.999Z');
|
||||
const prior = previousPeriodBounds({ from, to });
|
||||
|
||||
const lenMs = to.getTime() - from.getTime();
|
||||
expect(prior.to.getTime()).toBe(from.getTime());
|
||||
expect(prior.from.getTime()).toBe(from.getTime() - lenMs);
|
||||
});
|
||||
|
||||
it('does not mutate the input bounds', () => {
|
||||
const from = new Date('2026-05-01T00:00:00.000Z');
|
||||
const to = new Date('2026-05-31T00:00:00.000Z');
|
||||
const fromCopy = from.getTime();
|
||||
const toCopy = to.getTime();
|
||||
previousPeriodBounds({ from, to });
|
||||
expect(from.getTime()).toBe(fromCopy);
|
||||
expect(to.getTime()).toBe(toCopy);
|
||||
});
|
||||
});
|
||||
67
tests/unit/services/reports/sales-comparison.test.ts
Normal file
67
tests/unit/services/reports/sales-comparison.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { computeSalesKpiComparison } from '@/lib/services/reports/sales-comparison';
|
||||
import type { SalesKpis } from '@/lib/services/reports/sales.service';
|
||||
|
||||
function makeKpis(overrides: Partial<SalesKpis>): SalesKpis {
|
||||
return {
|
||||
activeInterests: 0,
|
||||
wonInWindow: 0,
|
||||
lostInWindow: 0,
|
||||
lossBreakdown: [],
|
||||
winRate: null,
|
||||
pipelineValue: 0,
|
||||
pipelineValueCurrency: 'EUR',
|
||||
pipelineValueExcludedCount: 0,
|
||||
pipelineValueTotalActiveCount: 0,
|
||||
medianTimeToCloseDays: null,
|
||||
timeToCloseSampleSize: 0,
|
||||
newLeadsInWindow: 0,
|
||||
newLeadsBySource: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeSalesKpiComparison', () => {
|
||||
it('diffs the window-derived count fields (current - prior)', () => {
|
||||
const current = makeKpis({ wonInWindow: 10, lostInWindow: 4, newLeadsInWindow: 20 });
|
||||
const prior = makeKpis({ wonInWindow: 6, lostInWindow: 5, newLeadsInWindow: 12 });
|
||||
|
||||
const deltas = computeSalesKpiComparison(current, prior);
|
||||
|
||||
expect(deltas.wonInWindow).toBe(4);
|
||||
expect(deltas.lostInWindow).toBe(-1);
|
||||
expect(deltas.newLeadsInWindow).toBe(8);
|
||||
});
|
||||
|
||||
it('diffs winRate as a fraction when both periods have a rate', () => {
|
||||
const current = makeKpis({ winRate: 0.5 });
|
||||
const prior = makeKpis({ winRate: 0.4 });
|
||||
const deltas = computeSalesKpiComparison(current, prior);
|
||||
expect(deltas.winRate).toBeCloseTo(0.1, 10);
|
||||
});
|
||||
|
||||
it('returns null winRate delta when either period has no rate', () => {
|
||||
expect(
|
||||
computeSalesKpiComparison(makeKpis({ winRate: 0.5 }), makeKpis({ winRate: null })).winRate,
|
||||
).toBeNull();
|
||||
expect(
|
||||
computeSalesKpiComparison(makeKpis({ winRate: null }), makeKpis({ winRate: 0.5 })).winRate,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('diffs medianTimeToCloseDays only when both periods have a median', () => {
|
||||
expect(
|
||||
computeSalesKpiComparison(
|
||||
makeKpis({ medianTimeToCloseDays: 30 }),
|
||||
makeKpis({ medianTimeToCloseDays: 45 }),
|
||||
).medianTimeToCloseDays,
|
||||
).toBe(-15);
|
||||
expect(
|
||||
computeSalesKpiComparison(
|
||||
makeKpis({ medianTimeToCloseDays: null }),
|
||||
makeKpis({ medianTimeToCloseDays: 45 }),
|
||||
).medianTimeToCloseDays,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user