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:
@@ -100,3 +100,23 @@ export function rangeSpanDays(range: DateRange): number {
|
||||
}
|
||||
return rangeToDays(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a concrete {from, to} window, return the equal-length window
|
||||
* immediately preceding it — used for "this period vs prior period"
|
||||
* comparison on report KPIs. The prior window is contiguous (its `to`
|
||||
* equals the current window's `from`) and exactly the same duration, so
|
||||
* a 30-day current window compares against the prior 30 days.
|
||||
*
|
||||
* Pure: takes Dates in, returns fresh Dates, never mutates the input.
|
||||
*/
|
||||
export function previousPeriodBounds(bounds: { from: Date; to: Date }): {
|
||||
from: Date;
|
||||
to: Date;
|
||||
} {
|
||||
const lengthMs = bounds.to.getTime() - bounds.from.getTime();
|
||||
return {
|
||||
from: new Date(bounds.from.getTime() - lengthMs),
|
||||
to: new Date(bounds.from.getTime()),
|
||||
};
|
||||
}
|
||||
|
||||
47
src/lib/services/reports/sales-comparison.ts
Normal file
47
src/lib/services/reports/sales-comparison.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user