From faac034258e1663c72cfdd95452637fa6f574bd0 Mon Sep 17 00:00:00 2001 From: Roman Mazuryk Date: Sun, 15 Feb 2026 19:47:42 +0100 Subject: [PATCH] feat(analytics): add previous-period KPI deltas with safe baseline handling --- src/components/KpiTiles.jsx | 40 +++++++++++++++++++++--- src/pages/Dashboard.jsx | 26 ++++++++++++++- src/pages/dashboard/AnalyticsSection.jsx | 3 +- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/components/KpiTiles.jsx b/src/components/KpiTiles.jsx index 0269e86..e582ec4 100644 --- a/src/components/KpiTiles.jsx +++ b/src/components/KpiTiles.jsx @@ -1,14 +1,19 @@ -export default function KpiTiles({ kpis = {}, windowDays = 7 }) { +export default function KpiTiles({ kpis = {}, previousKpis = {}, windowDays = 7 }) { const suffix = `(${windowDays}d)` const accText = typeof kpis.acc === 'number' ? `${kpis.acc}%` : '0%' const volText = typeof kpis.volume === 'number' ? kpis.volume : 0 const bestText = kpis.bestZone ? `${toZoneAbbr(kpis.bestZone.label)} - ${kpis.bestZone.acc}%` : '-' + const hasPrevBaseline = Number(previousKpis.volume || 0) > 0 + + const accDelta = toDelta(kpis.acc, previousKpis.acc, '%', hasPrevBaseline) + const volDelta = toDelta(kpis.volume, previousKpis.volume, '', hasPrevBaseline) + const bestDelta = toBestZoneDelta(kpis.bestZone, previousKpis.bestZone, hasPrevBaseline) return (
- - - + + +
) } @@ -30,11 +35,36 @@ function toZoneAbbr(label) { } } -function KpiCard({ title, value }) { +function toDelta(current, previous, suffix = '', hasPrevBaseline = true) { + if (!hasPrevBaseline || typeof current !== 'number' || typeof previous !== 'number') { + return { text: 'vs prev n/a', tone: 'text-neutral-500' } + } + const diff = current - previous + if (diff === 0) return { text: 'vs prev 0', tone: 'text-neutral-500' } + const sign = diff > 0 ? '+' : '' + return { + text: `vs prev ${sign}${diff}${suffix}`, + tone: diff > 0 ? 'text-emerald-500' : 'text-rose-500', + } +} + +function toBestZoneDelta(current, previous, hasPrevBaseline = true) { + if (!hasPrevBaseline || !previous) return { text: 'vs prev n/a', tone: 'text-neutral-500' } + if (!current) return { text: `prev ${toZoneAbbr(previous.label)} ${previous.acc}%`, tone: 'text-neutral-500' } + if (current.key !== previous.key) { + return { text: `prev ${toZoneAbbr(previous.label)} ${previous.acc}%`, tone: 'text-neutral-500' } + } + return toDelta(current.acc, previous.acc, 'pp') +} + +function KpiCard({ title, value, delta }) { return (
{title}
{value}
+
+ {delta?.text || 'vs prev n/a'} +
) } diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 3475110..96b739c 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -213,18 +213,41 @@ export default function Dashboard() { () => filterSessions(rows, { from: filters.dateFrom, - to: addDays(filters.dateTo, 1), + to: filters.dateTo, types: filters.types && filters.types.length ? filters.types : undefined, }), [rows, filters.dateFrom, filters.dateTo, filters.types] ) + const periodDays = useMemo(() => { + const fromTs = new Date(`${filters.dateFrom}T00:00:00Z`).getTime() + const toTs = new Date(`${filters.dateTo}T00:00:00Z`).getTime() + if (Number.isFinite(fromTs) && Number.isFinite(toTs) && toTs >= fromTs) { + return Math.floor((toTs - fromTs) / 86400000) + 1 + } + return Number(filters.windowDays || 90) + }, [filters.dateFrom, filters.dateTo, filters.windowDays]) + + const previousTo = useMemo(() => addDays(filters.dateFrom, -1), [filters.dateFrom]) + const previousFrom = useMemo(() => addDays(filters.dateFrom, -periodDays), [filters.dateFrom, periodDays]) + + const previousFiltered = useMemo( + () => + filterSessions(rows, { + from: previousFrom, + to: previousTo, + types: filters.types && filters.types.length ? filters.types : undefined, + }), + [rows, previousFrom, previousTo, filters.types] + ) + const aggOpts = useMemo( () => ({ direction: normalizeDir(filters.direction), range: filters.range }), [filters.direction, filters.range] ) const kpis = useMemo(() => computeKpis(filtered, aggOpts), [filtered, aggOpts]) + const previousKpis = useMemo(() => computeKpis(previousFiltered, aggOpts), [previousFiltered, aggOpts]) const byPos = useMemo(() => aggregateByPosition(filtered, aggOpts), [filtered, aggOpts]) const trend = useMemo(() => aggregateAccuracyByDate(filtered, aggOpts), [filtered, aggOpts]) const byType = useMemo(() => aggregateByType(filtered, aggOpts), [filtered, aggOpts]) @@ -285,6 +308,7 @@ export default function Dashboard() { filters={filters} setFilters={setFilters} kpis={kpis} + previousKpis={previousKpis} zonesForUi={zonesForUi} trend={trend} byType={byType} diff --git a/src/pages/dashboard/AnalyticsSection.jsx b/src/pages/dashboard/AnalyticsSection.jsx index 20d5e25..6ca3244 100644 --- a/src/pages/dashboard/AnalyticsSection.jsx +++ b/src/pages/dashboard/AnalyticsSection.jsx @@ -14,6 +14,7 @@ export default function AnalyticsSection({ filters, setFilters, kpis, + previousKpis, zonesForUi, trend, byType, @@ -54,7 +55,7 @@ export default function AnalyticsSection({ ))} ) : ( - + )}