groceries: stats page with nightly snapshot (battles, basket, promos, CPI, range)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s

This commit is contained in:
2026-10-25 20:38:55 +00:00
parent c4397e9d59
commit a6ef263c7e
11 changed files with 35731 additions and 2 deletions

View File

@@ -8,6 +8,7 @@
import { Textarea } from '#lib/components/ui/textarea/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import Lock from 'lucide-svelte/icons/lock';
import ChartColumn from 'lucide-svelte/icons/chart-column-increasing';
import Unlock from 'lucide-svelte/icons/lock-open';
import Star from 'lucide-svelte/icons/star';
import ExternalLink from 'lucide-svelte/icons/external-link';
@@ -486,6 +487,9 @@
<dd>{fmtNum(data.stats.promos)}</dd>
</div>
</dl>
<a href="/trackers/groceries/stats" class="stats-link">
<ChartColumn size={16} aria-hidden="true" class="text-violet-500" /> stats
</a>
</header>
<!-- search + sort -->
@@ -1181,6 +1185,24 @@
</Dialog.Root>
<style>
.stats-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-surface);
font-size: 0.875rem;
font-weight: 500;
transition:
background 0.15s,
transform 0.15s;
}
.stats-link:hover {
background: var(--site-hover);
transform: translateY(-1px);
}
.stats {
display: flex;
gap: 1.25rem;

View File

@@ -0,0 +1,114 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { computeBattles } from '#lib/server/groceries/stats.js';
import type {
BattleRow,
H2HRow,
Overview,
PromoRow,
Section,
SeriesData,
SnapshotData,
StatsScope
} from '#lib/groceries-stats.js';
import type { Sql } from 'postgres';
import type { PageServerLoad } from './$types';
const sql = () => db.$client as unknown as Sql;
const DAY = /^\d{4}-\d{2}-\d{2}$/;
type Live = { battles: BattleRow[]; h2h: H2HRow[]; total: number; tied: number };
// Store-subset battles are recomputed live (~1 s); cache per snapshot so filter clicks are instant.
const liveCache = new Map<string, Promise<Record<string, Live>>>();
export const load: PageServerLoad = async (event) => {
requireOwner(event);
const q = event.url.searchParams;
const scope: StatsScope = q.get('nonfood') === '1' ? 'all' : 'food';
const cat = (q.get('cat') ?? '').trim() || 'all';
const from = DAY.test(q.get('from') ?? '') ? q.get('from')! : null;
const to = DAY.test(q.get('to') ?? '') ? q.get('to')! : null;
const stores = [
...new Set(
(q.get('stores') ?? '')
.split(',')
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0)
)
].sort((a, b) => a - b);
const s = sql();
const [snap] = await s`
select computed_on::text as day, duration_ms, data->'meta' as meta, data->'overview' as overview,
coalesce(data->'byCategory'->${cat}, case when ${cat} = 'all' then data->'overall' end) as section
from groceries.stats_snapshots
where scope = ${scope} and (${to}::date is null or computed_on <= ${to}::date)
order by computed_on desc limit 1`;
const days = (
await s`select computed_on::text as day from groceries.stats_snapshots where scope = ${scope}
order by computed_on`
).map((r) => r.day as string);
const base = { scope, cat, from, to, stores, days };
if (!snap) return { ...base, empty: true as const };
// Daily index series: the newest snapshot that still carries it, cut to the category/range.
const [ser] = await s`
select series->'days' as days, series->'cats'->${cat} as cat
from groceries.stats_snapshots
where scope = ${scope} and series is not null and (${to}::date is null or computed_on <= ${to}::date)
order by computed_on desc limit 1`;
let series: { days: string[]; rows: SeriesData['cats'][string] } | null = null;
if (ser?.days && ser.cat) {
const all = ser.days as string[];
const keep = all
.map((d, i) => [d, i] as const)
.filter(([d]) => (!from || d >= from) && (!to || d <= to));
const idx = keep.map(([, i]) => i);
const rows: SeriesData['cats'][string] = {};
for (const [k, v] of Object.entries(ser.cat as SeriesData['cats'][string]))
rows[k] = { r: idx.map((i) => v.r[i]), e: idx.map((i) => v.e[i]), c: idx.map((i) => v.c[i]) };
series = { days: keep.map(([d]) => d), rows };
}
// Nightly history of the battle and promo tables (older snapshots keep only the overall section).
const trend = (await s`
select computed_on::text as day,
coalesce(data->'byCategory'->${cat}->'battles', case when ${cat} = 'all' then data->'overall'->'battles' end) as battles,
coalesce(data->'byCategory'->${cat}->'promos', case when ${cat} = 'all' then data->'overall'->'promos' end) as promos
from groceries.stats_snapshots
where scope = ${scope} and (${from}::date is null or computed_on >= ${from}::date)
and (${to}::date is null or computed_on <= ${to}::date)
order by computed_on`) as unknown as {
day: string;
battles: BattleRow[] | null;
promos: PromoRow[] | null;
}[];
const meta = snap.meta as SnapshotData['meta'];
let live: Live | null = null;
if (stores.length >= 2) {
const key = `${scope}|${snap.day}|${meta.computedAt}|${stores.join(',')}`;
let job = liveCache.get(key);
if (!job) {
if (liveCache.size > 50) liveCache.clear();
job = computeBattles(s, scope, stores);
liveCache.set(key, job);
job.catch(() => liveCache.delete(key));
}
const all = await job;
live = all[cat] ?? { battles: [], h2h: [], total: 0, tied: 0 };
}
return {
...base,
empty: false as const,
day: snap.day as string,
durationMs: snap.duration_ms as number | null,
meta,
overview: (snap.overview ?? null) as Overview | null,
section: (snap.section ?? null) as Section | null,
series,
trend,
live
};
};

File diff suppressed because it is too large Load Diff