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

254
src/lib/groceries-stats.ts Normal file
View File

@@ -0,0 +1,254 @@
// Shared (client + server) types and helpers for /trackers/groceries/stats. The snapshot is
// computed nightly by computeStats (src/lib/server/groceries/stats.ts); short keys keep it small.
export type StatsScope = 'food' | 'all';
export type StoreRef = { id: number; slug: string; name: string; color: string | null };
export type CategoryRef = {
slug: string;
name: string;
icon: string | null;
color: string | null;
isFood: boolean;
};
/** A store product referenced from a list (top movers, extremes, gaps…). */
export type ItemRef = {
id: number;
s: number; // store id
name: string;
size: string | null;
p: number | null; // pack price, cents
up: number | null; // unit price, cents per unit
unit: string | null;
url: string | null;
};
/** Price battle result per store: groups (comparable products) sold in 2+ stores. */
export type BattleRow = {
s: number;
n: number; // battles the store takes part in
w: number; // wins, ties split (1/k each)
o: number; // outright wins (sole cheapest)
t: number; // tied for cheapest
wr: number; // w / n
ix: number; // median of store / cheapest unit price × 100 (100 = always cheapest)
gx: number; // geometric mean of the same
wp: number; // wins incl. promo prices
wrp: number;
ixp: number;
gxp: number;
};
export type H2HRow = {
a: number;
b: number;
n: number;
aw: number;
bw: number;
t: number;
md: number;
};
export type PromoRow = {
s: number;
items: number;
promo: number; // on promo (excl. AH volume voordeel)
share: number;
volume: number; // AH multipack "volume voordeel"
disc: number | null; // average discount 0–1 (explicit or implied by the promo type)
cmp: number; // promos in a battle group with a regular price elsewhere
beats: number; // … of which the promo is cheaper than any other store's regular price
save: number | null; // median promo / cheapest regular elsewhere (below 1 = real saving)
};
export type RangeRow = {
s: number;
items: number;
groups: number;
excl: number; // groups only this store sells
house: number; // own-brand items
known: number; // items whose brand class is known (own brand or A-brand)
};
export type BrandGapRow = { s: number; n: number; ratio: number; cheaper: number };
export type GapRow = {
gtin: string;
cheap: ItemRef;
dear: ItemRef;
ratio: number;
diff: number; // cents
stores: number;
};
export type MoveRow = ItemRef & { from: number; to: number; pct: number; since: string };
export type VolRow = ItemRef & { changes: number; min: number; max: number; promoFlips: number };
export type ShrinkRow = ItemRef & {
day: string;
fromAmount: number;
toAmount: number;
fromPrice: number | null;
toPrice: number | null;
};
export type InflationRow = {
s: number; // 0 = all stores
days: number;
wow: number | null; // regular prices, week over week (fraction)
mom: number | null;
total: number | null; // since tracking started
wowE: number | null; // incl. promos
momE: number | null;
totalE: number | null;
changes: number; // price changes counted in the index
};
export type PpkgRow = { s: number; bins: number[]; median: number | null; n: number };
export type Section = {
kpi: {
items: number;
groups: number;
battles: number;
tied: number;
multi: number;
ean: number; // EAN-identical products sold by 2+ stores
eanSame: number; // … with exactly the same pack price everywhere
};
battles: BattleRow[];
h2h: H2HRow[];
promos: PromoRow[];
promoTypes: { s: number; type: string; n: number; disc: number | null }[];
range: RangeRow[];
buckets: { b: string; n: number }[];
brandGap: BrandGapRow[]; // s = 0: across stores
extremes: { unit: string | null; cheap: ItemRef[]; dear: ItemRef[] };
gaps: GapRow[];
ppkg: PpkgRow[];
moves: { rises: MoveRow[]; drops: MoveRow[] };
volatile: VolRow[];
inflation: InflationRow[];
};
export type Staple = { key: string; label: string; qty: number; unit: string; icon: string };
export type BasketPick = { reg: ItemRef & { cost: number }; eff: ItemRef & { cost: number } };
export type Overview = {
/** Store × top category matrices (from the per-category sections). */
heat: { cat: string; s: number; n: number; wr: number; ix: number }[];
promoHeat: { cat: string; s: number; share: number; items: number }[];
rangeHeat: { cat: string; s: number; items: number }[];
brandGapByCat: { cat: string; n: number; ratio: number; cheaper: number }[];
extremesByCat: {
cat: string;
unit: string | null;
cheap: ItemRef | null;
dear: ItemRef | null;
}[];
basket: { staples: Staple[]; picks: Record<string, Record<string, BasketPick>> };
weekday: { s: number; dow: number; n: number }[]; // promo starts seen in the history (1 = Mon)
promoEnds: { s: number; dow: number; n: number }[]; // "t/m 27-10" style end dates on labels
shrink: ShrinkRow[];
shrinkSince: string | null;
};
export type SnapshotData = {
meta: {
computedAt: string;
scope: StatsScope;
stores: StoreRef[];
categories: CategoryRef[];
firstDay: string | null;
lastDay: string | null;
historyDays: number;
durationMs?: number;
};
overall: Section;
overview: Overview;
byCategory: Record<string, Section>;
};
/** Daily chained price index (Jevons, base 100 on the first day) per category and store. */
export type SeriesData = {
days: string[];
cats: Record<string, Record<string, { r: number[]; e: number[]; c: number[] }>>;
};
/** Basket totals per store and the best 2-store split, from the per-staple picks. */
export function basketSummary(basket: Overview['basket'], storeIds: number[], mode: 'reg' | 'eff') {
const keys = basket.staples.map((s) => s.key);
const cost = (s: number, k: string) => basket.picks[k]?.[String(s)]?.[mode]?.cost ?? null;
const stores = storeIds.map((s) => {
let total = 0;
let covered = 0;
for (const k of keys) {
const c = cost(s, k);
if (c != null) {
total += c;
covered++;
}
}
return { s, total, covered, complete: covered === keys.length };
});
const complete = stores.filter((r) => r.complete).sort((a, b) => a.total - b.total);
let bestPair: { a: number; b: number; total: number; split: Record<string, number> } | null =
null;
for (let i = 0; i < storeIds.length; i++)
for (let j = i + 1; j < storeIds.length; j++) {
const a = storeIds[i];
const b = storeIds[j];
let total = 0;
const split: Record<string, number> = {};
let ok = true;
for (const k of keys) {
const ca = cost(a, k);
const cb = cost(b, k);
if (ca == null && cb == null) {
ok = false;
break;
}
if (cb == null || (ca != null && ca <= cb)) {
total += ca!;
split[k] = a;
} else {
total += cb;
split[k] = b;
}
}
if (ok && (!bestPair || total < bestPair.total)) bestPair = { a, b, total, split };
}
// Theoretical minimum: every staple at whichever store is cheapest.
let cherry = 0;
let cherryOk = true;
const cherryPick: Record<string, number> = {};
for (const k of keys) {
let best: number | null = null;
for (const s of storeIds) {
const c = cost(s, k);
if (c != null && (best == null || c < best)) {
best = c;
cherryPick[k] = s;
}
}
if (best == null) cherryOk = false;
else cherry += best;
}
// Most stores miss a staple or two, so also compare on the staples every "nearly complete"
// store (all but 3 at most) carries.
const near = stores.filter((r) => r.covered >= keys.length - 3);
const commonKeys = keys.filter((k) => near.every((r) => cost(r.s, k) != null));
const common = near
.map((r) => ({ s: r.s, total: commonKeys.reduce((a, k) => a + (cost(r.s, k) ?? 0), 0) }))
.sort((a, b) => a.total - b.total);
return {
stores,
complete,
bestPair,
cherry: cherryOk ? cherry : null,
cherryPick,
commonKeys,
common
};
}
export const WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'];
/** Price per kg histogram edges in euros (width_bucket in the snapshot query). */
export const PPKG_EDGES = [1, 2, 3, 4, 5, 7.5, 10, 15, 20, 30, 50, 100];
export const PPKG_LABELS = [
'<1',
...PPKG_EDGES.slice(0, -1).map((e, i) => `${e}–${PPKG_EDGES[i + 1]}`),
'100+'
];

View File

@@ -6,6 +6,7 @@ import {
date,
index,
integer,
jsonb,
numeric,
real,
text,
@@ -277,6 +278,51 @@ export const grocerySynonyms = groceriesSchema.table(
]
);
/**
* Nightly analytics for /trackers/groceries/stats (computeStats in
* src/lib/server/groceries/stats.ts, run after import + regroup + taxonomy under the import lock).
* One row per day per scope ('food' = edible only, 'all' = incl. drugstore/household). `data` holds
* the overall section plus one per top category; `series` (daily chained price index) is only kept
* on recent rows, older rows are pruned to the overall section so history stays small.
*/
export const groceryStatsSnapshots = groceriesSchema.table(
'stats_snapshots',
{
id: id(),
computedOn: date('computed_on').notNull(),
scope: text('scope').notNull(),
computedAt: timestamptz('computed_at').notNull().defaultNow(),
durationMs: integer('duration_ms'),
data: jsonb('data').notNull(),
series: jsonb('series')
},
(t) => [
uniqueIndex('stats_snapshots_day_scope_key').on(t.computedOn, t.scope),
check('stats_snapshots_scope_check', sql`${t.scope} IN ('food','all')`)
]
);
/**
* Pack size history (a row only when an item's size changes), written by computeStats so
* shrinkflation (smaller pack, same or higher price) can be detected. item_prices has no sizes.
*/
export const groceryItemSizes = groceriesSchema.table(
'item_sizes',
{
id: id(),
itemId: integer('item_id')
.notNull()
.references(() => groceryItems.id, { onDelete: 'cascade' }),
observedOn: date('observed_on').notNull(),
unit: text('unit').$type<GroceryUnit>(),
unitAmount: numeric('unit_amount', { precision: 12, scale: 4, mode: 'number' }),
sizeText: text('size_text'),
priceCents: integer('price_cents'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(t) => [uniqueIndex('item_sizes_item_day_key').on(t.itemId, t.observedOn)]
);
export type GroceryStore = typeof groceryStores.$inferSelect;
export type GroceryGroup = typeof groceryGroups.$inferSelect;
export type GroceryItem = typeof groceryItems.$inferSelect;

File diff suppressed because it is too large Load Diff