groceries: stats page with nightly snapshot (battles, basket, promos, CPI, range)
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
All checks were successful
Deploy production / deploy (push) Successful in 1m19s
This commit is contained in:
@@ -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;
|
||||
|
||||
114
src/routes/trackers/groceries/stats/+page.server.ts
Normal file
114
src/routes/trackers/groceries/stats/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
2069
src/routes/trackers/groceries/stats/+page.svelte
Normal file
2069
src/routes/trackers/groceries/stats/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user