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:
2026-05-31 18:49:35 +02:00
parent 172af02f81
commit 681b94a8ef
7 changed files with 333 additions and 9 deletions

View File

@@ -0,0 +1,47 @@
/**
* Period-comparison diffing for the Sales report KPI strip.
*
* DB-free + pure so it unit-tests without a database. The route computes
* the current-window KPIs and the prior-window KPIs (via
* `getSalesKpis(portId, previousPeriodBounds(range))`) and hands both
* here to produce the per-tile deltas the KpiCard renders as
* "vs prior period" arrows.
*
* Only the window-derived KPIs get a delta — the point-in-time tiles
* (Active interests, Pipeline value) have no prior-window analogue, so
* they are intentionally absent here.
*/
// Type-only import: erased at compile time, so this module does NOT pull
// in the DB driver that `sales.service.ts` imports at runtime.
import type { SalesKpis } from '@/lib/services/reports/sales.service';
export interface SalesKpiDeltas {
/** current - prior */
wonInWindow: number;
/** current - prior */
lostInWindow: number;
/** current - prior */
newLeadsInWindow: number;
/** current - prior as a fraction [-1, 1]; null when either period has
* no rate (denominator was 0). */
winRate: number | null;
/** current - prior in days; null when either period has no median
* (sample too small). */
medianTimeToCloseDays: number | null;
}
function diffNullable(current: number | null, prior: number | null): number | null {
if (current === null || prior === null) return null;
return current - prior;
}
export function computeSalesKpiComparison(current: SalesKpis, prior: SalesKpis): SalesKpiDeltas {
return {
wonInWindow: current.wonInWindow - prior.wonInWindow,
lostInWindow: current.lostInWindow - prior.lostInWindow,
newLeadsInWindow: current.newLeadsInWindow - prior.newLeadsInWindow,
winRate: diffNullable(current.winRate, prior.winRate),
medianTimeToCloseDays: diffNullable(current.medianTimeToCloseDays, prior.medianTimeToCloseDays),
};
}