MSO Cloud · Documentation

Comparison period — GA-style period-over-period

Source: docs/product/comparison-period.md Updated 2026-09-21
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-28 compares against 2026-01-01 → 2026-01-31. Otherwise it is an equal-length window ending the day before the primary range starts: primary 2026-04-12 → 2026-05-11 (30 days) → compare 2026-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 formatDelta helper in @yng/charts so 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/ and scripts/cube/verify-metric-parity.ts).
  • AI briefing skipcaller.reports.aiCubeBriefing is 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 render
    • FilterBar: toggle writes ?compare=1 to the URL
  • Plus the existing smoke tests prove no regression on uncompared rendering.

Run:

pnpm --filter @yng/web test:e2e