On this page
Every analytical surface exposes an optional comparison window so an operator can read "this kỳ vs last kỳ" without re-querying. The UX target is Google Analytics: one checkbox flips on a secondary date range, every KPI gets a Δ%, and the time-series chart overlays the comparison line.
Where it shows up#
| Surface | UI control | Behaviour |
|---|---|---|
/explore |
<DateRangeCompare> in-page picker |
Primary + compare ranges; overlay chart series; KPI label flips to "Kỳ so sánh" |
/orders, /products, /inventory, /creators, /reports/* |
FilterBar compare row |
Writes ?compare=1&comparePreset=…&compareFrom=…&compareTo=… to URL |
/reports/settlement, /reports/commerce-performance, /reports/live-commerce, /reports/cohort-rfm-kol |
server-side fetch + KPI Δ% | Parallel fetch of primary + compare; KPI cards show "vs <prev> · Δ%" with the typographic minus (U+2212) |
URL contract#
The FilterBar emits these search params:
?from=YYYY-MM-DD primary start
&to=YYYY-MM-DD primary end
&compare=1 enables comparison
&comparePreset=previous 'previous' | 'year-ago' | 'custom'
&compareFrom=YYYY-MM-DD only honoured when comparePreset=custom
&compareTo=YYYY-MM-DD only honoured when comparePreset=custom
Servers read these via parseReportFilters() (apps/web/src/app/(dashboard)/reports/_lib/report-filters.ts) which returns a typed ReportFilters.compare?: { from: Date; to: Date; fromLabel: string; toLabel: string; preset: ... }.
Presets#
- previous (default) — calendar-aware. When the primary range is a whole calendar month, several whole months, a quarter or a year, the comparison shifts back by that many whole months and keeps calendar lengths:
2026-02-01 → 2026-02-28compares against2026-01-01 → 2026-01-31. Otherwise it is an equal-length window ending the day before the primary range starts: primary2026-04-12 → 2026-05-11(30 days) → compare2026-03-13 → 2026-04-11. - year-ago — same calendar window 1 year earlier.
- custom — user picks both compareFrom and compareTo.
The math lives in @yng/ui/previousPeriod and @yng/ui/yearAgoPeriod — both pure functions taking ISO date strings, both used in both client (FilterBar) and server (reports filter parser).
Server-side wiring (per report)#
// apps/web/src/app/(dashboard)/reports/settlement/page.tsx
const filters = parseReportFilters(await searchParams, 45);
const compareFilters = filters.compare
? { ...filters, from: filters.compare.from, to: filters.compare.to }
: null;
const [report, compareReport] = await Promise.all([
caller.reports.settlement(filters),
compareFilters ? caller.reports.settlement(compareFilters) : Promise.resolve(null),
]);
The client component receives both payloads and renders deltas:
<KpiCard
label="Payout kỳ vọng"
value={formatVnd(data.summary.expectedPayoutVnd)}
caption={compare ? `vs ${formatVnd(compare.data.summary.expectedPayoutVnd)}` : undefined}
delta={
compare
? formatDeltaPct(data.summary.expectedPayoutVnd, compare.data.summary.expectedPayoutVnd)
: undefined
}
deltaTone={compare ? (current >= prev ? 'up' : 'down') : undefined}
/>
formatDeltaPct() is duplicated per report (each handles a different number unit). Convention: returns '—' for non-finite or zero-baseline, signed + or U+2212 minus otherwise.
Explore page wiring (client-side)#
/explore owns its own primary + compare state via <DateRangeCompare>. The picker emits a DateRangeCompareValue:
interface DateRangeCompareValue {
from: string;
to: string;
compareEnabled: boolean;
comparePreset: 'previous' | 'year-ago' | 'custom';
compareFrom: string;
compareTo: string;
}
The Explore workbench passes compareFrom / compareTo directly to trpc.explore.query which returns both summary.currentValue and summary.previousValue plus the previous-period time series for chart overlay.
tRPC contract change#
trpc.explore.query accepts optional compareFrom / compareTo (defaults to previous-period auto-detection). Returns previousTimeSeries for overlay.
trpc.explore.query.input; // metricSlug + from + to + platforms? + compareFrom? + compareTo?
trpc.explore.query.output; // + previousTimeSeries: TimeSeriesPoint[]
Sign conventions#
- KPI Δ is (current − previous) / |previous|. We absolute the denominator so a negative previous value still produces a sane sign on the delta.
- Sign rendered with U+2212 (typographic minus), not ASCII hyphen — matches the
formatDeltahelper in@yng/chartsso columns of deltas line up monospaced.
Caveats#
- Year-ago against sample data — the seed scripts only generate ~120 days of sample data. Asking for "cùng kỳ năm trước" returns zeroes. Real customer data with historical depth produces real deltas.
- Cube parity — explore.query uses the in-house metrics runtime, not Cube. If/when reports migrate to Cube, the compare window must travel through the Cube query too (see
infra/cube/schema/andscripts/cube/verify-metric-parity.ts). - AI briefing skip —
caller.reports.aiCubeBriefingis not re-called for the compare period; it's a primary-period summary only. If we surface compare-aware insights, that's a follow-up.
Tests#
apps/web/e2e/compare-period.spec.ts— 4 Playwright tests:/explore: toggling compare flips KPI label to "Kỳ so sánh"/explore: switching preset (previous → year-ago) updates the compare KPI/reports/commerce-performance?compare=1: meta chip + KPI deltas renderFilterBar: toggle writes?compare=1to the URL
- Plus the existing smoke tests prove no regression on uncompared rendering.
Run:
pnpm --filter @yng/web test:e2e