groceries: mobile-first tracker redesign — compact rows per product group (cheapest store badge, unit price, spread, promo), tap to compare stores; category tree (23 tops, subcategories, icons/colours) with chips, store tiles, sort and non-food toggle; owner shelf prices (manual source with date, photo, note)
Some checks failed
Deploy production / deploy (push) Failing after 1m1s

This commit is contained in:
2026-10-25 19:02:44 +00:00
parent 67d74f39b8
commit 707f07efd4
13 changed files with 33141 additions and 447 deletions

View File

@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { cheapest, formatAmount, formatUnitPrice, sortByPrice, type PricedItem } from './groceries';
import {
bestPerStore,
cheapest,
formatAmount,
formatUnitPrice,
parseEuros,
sortByPrice,
toOffers,
type PricedItem
} from './groceries';
const it_ = (
id: number,
@@ -42,3 +51,69 @@ describe('grocery price helpers', () => {
expect(formatUnitPrice(null, 'kg')).toBeNull();
});
});
describe('parseEuros', () => {
it('reads Dutch and English prices', () => {
expect(parseEuros('1,29')).toBe(129);
expect(parseEuros('€ 2.5')).toBe(250);
expect(parseEuros('3')).toBe(300);
});
it('rejects nonsense', () => {
expect(parseEuros('')).toBeNull();
expect(parseEuros('0')).toBeNull();
expect(parseEuros('1,299')).toBeNull();
expect(parseEuros('abc')).toBeNull();
});
});
describe('bestPerStore', () => {
const item = (
id: number,
storeId: number,
price: number,
unit: number | null,
promo?: number
) => ({
id,
storeId,
name: `p${id}`,
sizeText: null,
unit: 'kg',
unitAmount: 1,
promoLabel: promo ? '1+1' : null,
url: null,
available: true,
priceCents: price,
promoPriceCents: promo ?? null,
unitPriceCents: unit,
promoUnitPriceCents: promo ?? null
});
it('keeps the cheapest offer per store, cheapest store first, with the spread', () => {
const offers = toOffers(
[item(1, 1, 300, 300), item(2, 1, 250, 250), item(3, 2, 400, 400, 200), item(4, 3, 500, 500)],
[
{
id: 9,
itemId: null,
storeId: 4,
priceCents: 180,
promo: false,
sizeText: '1 kg',
unit: 'kg',
unitPriceCents: 180,
observedOn: '2026-10-20',
photoUrl: null,
note: null
}
]
);
const r = bestPerStore(offers, 'unit');
expect(r.stores.map((o) => o.key)).toEqual(['m9', 'i3', 'i2', 'i4']);
expect(r.best?.manual?.id).toBe(9);
expect(r.stores[1].regularCents).toBe(400);
expect(r.spread).toEqual({ min: 180, max: 500, pct: 178 });
});
it('has no spread for one store', () => {
expect(bestPerStore(toOffers([item(1, 1, 100, 100)], []), 'pack').spread).toBeNull();
});
});

View File

@@ -62,3 +62,119 @@ export function sortByPrice<T extends PricedItem>(items: T[], mode: 'unit' | 'pa
((mode === 'unit' ? effectiveUnitPrice(i) : effectivePrice(i)) ?? 1e11);
return [...items].sort((a, b) => val(a) - val(b) || a.id - b.id);
}
/** Typed euro amount ("1,29", "€ 1.29", "2") → cents; null when not a sane price. */
export function parseEuros(v: string): number | null {
const t = v.replace(/[€\s]/g, '').replace(',', '.');
if (!/^\d{1,5}(\.\d{1,2})?$/.test(t)) return null;
const c = Math.round(Number(t) * 100);
return c > 0 && c < 1_000_000 ? c : null;
}
/** One price for a product at a store: an online item or a manual shelf price. */
export type Offer = {
key: string;
storeId: number;
name: string;
sizeText: string | null;
unit: string | null;
/** What you pay now (promo included). */
priceCents: number | null;
/** Regular price when a promo applies. */
regularCents: number | null;
unitPriceCents: number | null;
promoLabel: string | null;
url: string | null;
itemId: number | null;
/** Set for shelf prices entered by hand. */
manual: { id: number; observedOn: string; photoUrl: string | null; note: string | null } | null;
};
type OnlineItem = PricedItem & {
name: string;
sizeText: string | null;
unit: string | null;
unitAmount: number | null;
promoLabel: string | null;
url: string | null;
};
type ManualPrice = {
id: number;
itemId: number | null;
storeId: number;
priceCents: number;
promo: boolean;
sizeText: string | null;
unit: string | null;
unitPriceCents: number | null;
observedOn: string;
photoUrl: string | null;
note: string | null;
};
export function toOffers(items: OnlineItem[], manual: ManualPrice[], names?: Map<number, string>) {
const out: Offer[] = [];
for (const i of items) {
if (!i.available) continue;
const promo = i.promoPriceCents != null && i.priceCents != null;
out.push({
key: `i${i.id}`,
storeId: i.storeId,
name: i.name,
sizeText: i.sizeText ?? formatAmount(i.unitAmount, i.unit),
unit: i.unit,
priceCents: effectivePrice(i),
regularCents: promo ? i.priceCents : null,
unitPriceCents: effectiveUnitPrice(i),
promoLabel: i.promoLabel,
url: i.url,
itemId: i.id,
manual: null
});
}
for (const m of manual)
out.push({
key: `m${m.id}`,
storeId: m.storeId,
name: (m.itemId && names?.get(m.itemId)) || 'Shelf price',
sizeText: m.sizeText,
unit: m.unit,
priceCents: m.priceCents,
regularCents: null,
unitPriceCents: m.unitPriceCents,
promoLabel: m.promo ? 'promo' : null,
url: null,
itemId: m.itemId,
manual: { id: m.id, observedOn: m.observedOn, photoUrl: m.photoUrl, note: m.note }
});
return out;
}
/** Compared value of an offer; falls back to the pack price when no unit price is known. */
export const offerValue = (o: Offer, mode: 'unit' | 'pack') =>
mode === 'unit' ? (o.unitPriceCents ?? null) : o.priceCents;
/**
* Cheapest offer per store (by unit or pack price), cheapest store first, and the spread
* between the cheapest and dearest store. Offers without the compared value come last.
*/
export function bestPerStore(offers: Offer[], mode: 'unit' | 'pack') {
const v = (o: Offer) => offerValue(o, mode) ?? Infinity;
const per = new Map<number, Offer>();
for (const o of offers) {
const cur = per.get(o.storeId);
if (!cur || v(o) < v(cur) || (v(o) === v(cur) && !!cur.manual && !o.manual))
per.set(o.storeId, o);
}
const stores = [...per.values()].sort((a, b) => v(a) - v(b) || a.storeId - b.storeId);
const vals = stores.map((o) => offerValue(o, mode)).filter((x): x is number => x != null);
const spread =
vals.length >= 2
? {
min: vals[0],
max: vals[vals.length - 1],
pct: Math.round((vals[vals.length - 1] / vals[0] - 1) * 100)
}
: null;
return { best: stores[0] ?? null, stores, spread };
}

View File

@@ -46,6 +46,35 @@ export const groceryStores = groceriesSchema.table(
]
);
/**
* Shared category tree across stores (top level + subcategories), e.g. "Zuivel & eieren" >
* "Yoghurt & kwark". Rows are synced from TAXONOMY (src/lib/server/groceries/taxonomy.ts) by
* applyTaxonomy(), which also maps every item onto it from the store's own category path and
* name, so new stores land in the same tree without per-store work. `icon` is a lucide name.
*/
export const groceryCategories = groceriesSchema.table(
'categories',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
parentId: integer('parent_id').references((): AnyPgColumn => groceryCategories.id, {
onDelete: 'cascade'
}),
icon: text('icon'),
color: text('color'),
sortOrder: integer('sort_order').notNull().default(0),
isFood: boolean('is_food').notNull().default(true),
...timestamps
},
(t) => [
uniqueIndex('categories_slug_key').on(t.slug),
index('categories_parent_idx').on(t.parentId),
check('categories_slug_check', sql`${t.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
check('categories_name_check', sql`length(btrim(${t.name})) > 0`)
]
);
/**
* A comparable product ("Halfvolle melk", house brand) across stores and sizes. Stats columns
* (item_count … search_text) are denormalised by refreshGroupStats() so the list stays fast.
@@ -59,6 +88,10 @@ export const groceryGroups = groceriesSchema.table(
name: text('name').notNull(),
brand: text('brand'), // display brand; 'Huismerk' for AH/Jumbo/Plus own brands
category: text('category'),
/** Shared taxonomy leaf (or top) most of its items map to; set by applyTaxonomy(). */
categoryId: integer('category_id').references(() => groceryCategories.id, {
onDelete: 'set null'
}),
unit: text('unit').$type<GroceryUnit>(),
/** Name tokens without brand: groups sharing it are "similar" (e.g. house vs Campina melk). */
familyKey: text('family_key'),
@@ -87,6 +120,7 @@ export const groceryGroups = groceriesSchema.table(
uniqueIndex('groups_key_key').on(t.key),
index('groups_family_idx').on(t.familyKey),
index('groups_category_idx').on(t.category),
index('groups_category_id_idx').on(t.categoryId),
index('groups_store_ids_idx').using('gin', t.storeIds),
index('groups_search_trgm_idx').using('gin', sql`${t.searchText} gin_trgm_ops`),
index('groups_list_idx').on(t.storeCount, t.name),
@@ -119,6 +153,10 @@ export const groceryItems = groceriesSchema.table(
promoUnitPriceCents: integer('promo_unit_price_cents'),
category: text('category'), // canonical category (shared across stores)
categoryPath: text('category_path'), // the store's own path
/** Shared taxonomy node (subcategory, else top) from applyTaxonomy(). */
categoryId: integer('category_id').references(() => groceryCategories.id, {
onDelete: 'set null'
}),
gtin: text('gtin'),
gtinCheckedAt: timestamptz('gtin_checked_at'),
imageUrl: text('image_url'),
@@ -139,6 +177,7 @@ export const groceryItems = groceriesSchema.table(
uniqueIndex('items_store_product_key').on(t.storeId, t.storeProductId),
index('items_group_idx').on(t.groupId),
index('items_gtin_idx').on(t.gtin),
index('items_category_id_idx').on(t.categoryId),
index('items_name_trgm_idx').using('gin', sql`${t.name} gin_trgm_ops`),
check('items_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`),
check('items_price_check', sql`${t.priceCents} IS NULL OR ${t.priceCents} >= 0`)
@@ -162,6 +201,41 @@ export const groceryItemPrices = groceriesSchema.table(
(t) => [uniqueIndex('item_prices_item_day_key').on(t.itemId, t.observedOn)]
);
/**
* Shelf prices Stan enters by hand (local store visits, stores without a webshop). Linked to a
* store product (`item_id`) or just a group; shown next to the online prices with a "manual"
* badge and the date seen. `unit_price_cents` is derived from the pack size when known.
*/
export const groceryManualPrices = groceriesSchema.table(
'manual_prices',
{
id: id(),
storeId: integer('store_id')
.notNull()
.references(() => groceryStores.id, { onDelete: 'cascade' }),
groupId: integer('group_id').references(() => groceryGroups.id, { onDelete: 'cascade' }),
itemId: integer('item_id').references(() => groceryItems.id, { onDelete: 'set null' }),
priceCents: integer('price_cents').notNull(),
/** True when the shelf price was a promotion (bonus, sticker). */
promo: boolean('promo').notNull().default(false),
sizeText: text('size_text'),
unit: text('unit').$type<GroceryUnit>(),
unitAmount: numeric('unit_amount', { precision: 12, scale: 4, mode: 'number' }),
unitPriceCents: integer('unit_price_cents'),
observedOn: date('observed_on').notNull().defaultNow(),
photoUrl: text('photo_url'),
note: text('note'),
...timestamps
},
(t) => [
index('manual_prices_group_idx').on(t.groupId, t.observedOn),
index('manual_prices_item_idx').on(t.itemId),
check('manual_prices_price_check', sql`${t.priceCents} > 0 AND ${t.priceCents} < 1000000`),
check('manual_prices_target_check', sql`${t.groupId} IS NOT NULL OR ${t.itemId} IS NOT NULL`),
check('manual_prices_unit_check', sql`${t.unit} IS NULL OR ${t.unit} IN ('piece','kg','l')`)
]
);
/** One importer run per store (counts and errors), for the UI footer and debugging. */
export const groceryImportRuns = groceriesSchema.table(
'import_runs',
@@ -185,3 +259,5 @@ export const groceryImportRuns = groceriesSchema.table(
export type GroceryStore = typeof groceryStores.$inferSelect;
export type GroceryGroup = typeof groceryGroups.$inferSelect;
export type GroceryItem = typeof groceryItems.$inferSelect;
export type GroceryCategory = typeof groceryCategories.$inferSelect;
export type GroceryManualPrice = typeof groceryManualPrices.$inferSelect;

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { classify, subSlug, TAXONOMY } from './taxonomy';
const c = (category: string, path: string | null, name: string, isFood = true) => {
const r = classify({ category, path, name, isFood });
return r.sub ? `${r.top}/${r.sub}` : r.top;
};
describe('taxonomy', () => {
it('has unique, valid slugs', () => {
const slugs = TAXONOMY.flatMap((t) => [t.slug, ...t.subs.map((s) => subSlug(t.slug, s.slug))]);
expect(new Set(slugs).size).toBe(slugs.length);
for (const s of slugs) expect(s).toMatch(/^[a-z0-9][a-z0-9-]{0,62}$/);
});
it('refines importer categories from the store path', () => {
expect(
c('Zuivel & eieren', 'Zuivel, boter en eieren > Yoghurt en kwark', 'AH Griekse yoghurt')
).toBe('zuivel-eieren/yoghurt-kwark');
expect(c('Bier & wijn', 'Bier en wijn > Witte wijn > Fris droog', 'Pinot Grigio')).toBe(
'bier-wijn/wit'
);
expect(c('Koffie & thee', 'Koffie en thee > Koffiecups', 'L’OR Espresso')).toBe(
'koffie-thee/cups'
);
});
it('falls back to the product name, then the top', () => {
expect(c('Vlees, vis & vega', 'Vlees, kip, vis, vega', 'PLUS Kipfilet blokjes')).toBe(
'vlees-vis-vega/kip'
);
expect(c('Drogisterij', 'Gezondheid en sport > Anale verzorging', 'AH Zalf')).toBe(
'drogisterij'
);
});
it('uses the only segment of flat store trees', () => {
expect(c('Soepen, sauzen & conserven', 'Kruiden', 'Asli Kerrie Masala 40g')).toBe(
'soepen-sauzen/kruiden'
);
});
it('splits pets from household', () => {
expect(
c('Huishouden & dieren', 'Huisdier > Hond > Nat hondenvoer', 'Pedigree blik rund', false)
).toBe('dieren/hond');
expect(c('Huishouden & dieren', 'Huishouden en dieren > Afwasmiddel', 'Dreft', false)).toBe(
'huishouden/afwas'
);
});
it('spreads Overig over non-food tops', () => {
expect(c('Overig', 'Koken, tafelen, vrije tijd > Hoeslakens', 'Hoeslaken beige', false)).toBe(
'wonen-tuin/textiel-bed'
);
expect(
c('Overig', 'Koken en Tafelen > Servies > Serviessets', 'Serviesset 32-delig', false)
).toBe('keuken-tafel/servies');
expect(c('Overig', 'Kerst > Kerstkleding', 'Huispak kerst', false)).toBe('feest-seizoen/kerst');
expect(c('Overig', 'Huis, Beauty & Welzijn', 'Leung Tim Wok 35cm', false)).toBe(
'keuken-tafel/koken'
);
});
it('keeps food in seasonal shelves on food tops', () => {
expect(c('Overig', 'Glutenvrij > Glutenvrij Sinterklaas', 'AH Speculaasbrok', true)).toBe(
'snoep-snacks/koek'
);
expect(c('Overig', 'Online Only', 'Onbekend ding', true)).toBe('overig');
});
});

View File

@@ -0,0 +1,928 @@
// Shared grocery category tree and the rules that map every store's own categories onto it.
//
// Each store crawls its own tree ("Zuivel, boter en eieren > Yoghurt en kwark", "Koken en
// Tafelen > Servies", …). The importers already give a coarse shared `category` (CATEGORIES in
// normalize.ts); this refines it into TAXONOMY: ~20 top categories with subcategories, picked
// from the store path first and the product name second. Rules are plain Dutch keywords, not
// store ids, so new stores (Lidl, Dirk, Kruidvat, …) classify without extra work.
//
// applyTaxonomy() syncs groceries.categories with this file and (re)classifies items and groups.
// It runs after every nightly regroup; `bun scripts/groceries-taxonomy.ts` runs it on demand.
import type { Sql } from 'postgres';
import { fold } from './normalize';
export type SubDef = { slug: string; name: string; match: RegExp };
export type TopDef = {
slug: string;
name: string;
icon: string; // lucide icon name (kebab-case)
color: string;
food: boolean;
subs: SubDef[];
};
const sub = (slug: string, name: string, match: RegExp): SubDef => ({ slug, name, match });
/** The tree. Order is display order; within a top, the first matching subcategory wins. */
export const TAXONOMY: TopDef[] = [
{
slug: 'groente-fruit',
name: 'Groente & fruit',
icon: 'carrot',
color: '#16a34a',
food: true,
subs: [
sub('verse-sappen', 'Verse sappen & smoothies', /\bsap|sappen|smoothie|shots?\b|jus d/),
sub('aardappelen', 'Aardappelen', /aardappel|krieltjes|\bfriet/),
sub(
'voorgesneden',
'Voorgesneden & verspakketten',
/voorgesneden|verspakket|maaltijdpakket|soeppakket|wokgroente|roerbak|stamppot|groentemix|snoepgroente|snackgroente/
),
sub('sla', 'Sla & salade', /\bsla\b|salade|slaverrijk/),
sub(
'kruiden-ui',
'Verse kruiden, ui & knoflook',
/kruiden|gember|pepers?\b|knoflook|\buien?\b/
),
sub(
'peulvruchten',
'Peulvruchten & conserven',
/bonen|linzen|kikkererwt|erwten|kapucijn|conserv|passata|tomatenpuree|tomatenpulp|tomatenblokjes|gepelde|augurk|appelmoes|appelcompote|kappertjes|zongedroogd|gedroogd/
),
sub(
'fruit',
'Fruit',
/fruit|appel|\bpeer|peren|banan|druif|druiven|bessen|aardbei|sinaasappel|mandarijn|citroen|limoen|mango|ananas|kiwi|meloen|vijg|dadel|zuidvrucht|pitten|frambo|kers/
),
sub(
'groente',
'Groente',
/groente|tomaat|tomaten|paprika|wortel|peen|bloemkool|broccoli|champignon|paddenstoel|pompoen|komkommer|courgette|aubergine|prei|spinazie|kool|asperge|avocado|\bmais|bonen|witlof|andijvie|biet/
)
]
},
{
slug: 'vlees-vis-vega',
name: 'Vlees, vis & vega',
icon: 'beef',
color: '#dc2626',
food: true,
subs: [
sub(
'vega',
'Vega & plantaardig',
/vega|plantaardig|vleesvervang|tofu|tempeh|hummus|seitan|haverdrink|sojadrink|amandeldrink|cashewdrink|kokosdrink|rijstdrink/
),
sub(
'vis',
'Vis & zeevruchten',
/\bvis|zalm|tonijn|garnal|kabeljauw|pangasius|haring|makreel|sardine|ansjovis|mossel|schaaldier|schelpdier|inktvis|surimi|forel|zeebaars|roodbaars|dorade|lekkerbek|kibbeling|zeevrucht|kreeft/
),
sub('kip', 'Kip & gevogelte', /\bkip|kalkoen|gevogelte|drumstick|vleugel|kippen/),
sub(
'rund',
'Rundvlees',
/rund|biefstuk|sukade|stoofvlees|soepvlees|entrecote|ossenhaas|riblap|runder/
),
sub(
'varken',
'Varkensvlees',
/varken|spek|bacon|filetlap|hamlap|speklap|karbonade|schnitzel|slavink|boomstam|procureur/
),
sub(
'gehakt-worst',
'Gehakt, worst & burgers',
/gehakt|worst|burger|knak|frikandel|balletje|gehaktbal/
),
sub(
'bbq-overig',
'BBQ, wild & overig',
/bbq|grill|shoarma|gyros|kebab|ovenschotel|ragout|\bwild|lams?vlees|\blam\b|halal/
)
]
},
{
slug: 'kaas-vleeswaren',
name: 'Kaas, vleeswaren & tapas',
icon: 'ham',
color: '#ea580c',
food: true,
subs: [
sub(
'kaas',
'Kaas',
/kaas|brie|camembert|mozzarella|feta|burrata|ricotta|gorgonzola|parmez|grana|pecorino|cheddar|cottage|roomkaas|roodflora|gouda|edammer|mascarpone/
),
sub(
'tapas-salades',
'Tapas, salades & spreads',
/tapas|tapenade|\bdip|spread|salade|hummus|olijf|olijven|smeersel|chips|toastjes|borrel/
),
sub(
'vleeswaren',
'Vleeswaren',
/vleeswaren|\bham\b|ham |kipfilet|kalkoenfilet|salami|cervelaat|worst|rosbief|fricandeau|pate\b|filet americain|leverkaas|rollade|gebraden|gegrild|gehakt|ontbijtspek|bacon/
)
]
},
{
slug: 'zuivel-eieren',
name: 'Zuivel & eieren',
icon: 'milk',
color: '#0ea5e9',
food: true,
subs: [
sub('ijs', 'IJs', /\bijs|roomijs|handijs|waterijs|schepijs|pints/),
sub('eieren', 'Eieren', /\beieren|\beitjes|\bei\b|scharrel|uitloop/),
sub('boter', 'Boter & margarine', /boter|margarine|halvarine|smeeralternatief|bak- en braad/),
sub(
'toetjes',
'Vla, pap & toetjes',
/\bvla|vlaflip|\bpap\b|toetje|pudding|mousse|dessert|rijstebrij|griesmeel/
),
sub('yoghurt-kwark', 'Yoghurt & kwark', /yoghurt|kwark|skyr|kefir|probiotica|gut health/),
sub(
'room',
'Room & crème fraîche',
/slagroom|kookroom|creme fraiche|zure room|\broom\b|spuitbus|mascarpone/
),
sub(
'melk',
'Melk & zuiveldranken',
/melk|creamer|drink|ijskoffie|karnemelk|chocomel|protein shake/
),
sub('plantaardig', 'Plantaardige zuivel', /plantaardig|soja|haver|amandel|kokos/)
]
},
{
slug: 'brood-bakkerij',
name: 'Brood & bakkerij',
icon: 'croissant',
color: '#d97706',
food: true,
subs: [
sub(
'bakproducten',
'Bakproducten & mixen',
/bakproduct|bakmix|bakpoeder|meel\b|bakmeel|gist|gelatine|zoetstof|rozijn|garnering|bakdecoratie|cakemix|taartmix|koekjesmix|pannenkoekenmix|\bmix\b|appelstroop|suiker/
),
sub(
'crackers-beschuit',
'Crackers & beschuit',
/cracker|knackebrod|beschuit|toast|rijstwafel|roggebrood/
),
sub(
'gebak-koek',
'Gebak & koek',
/gebak|cake|taart|koek|croissant|donut|muffin|vlaai|patisserie|banket|worstenbrood|suikerbrood|vruchtenbrood|zoet brood|brownie/
),
sub(
'brood',
'Brood & broodjes',
/brood|broden|stokbrood|ciabatta|focaccia|desem|bolletje|broodje|puntje|pita|naan|bao|flatbread|bagel|wrap/
)
]
},
{
slug: 'ontbijt-beleg',
name: 'Ontbijt & beleg',
icon: 'egg',
color: '#f59e0b',
food: true,
subs: [
sub('pindakaas', 'Pindakaas & notenpasta', /pindakaas|notenboter|notenpasta|nut butter/),
sub(
'ontbijtgranen',
'Ontbijtgranen & muesli',
/ontbijtgranen|muesli|granola|havermout|cornflakes|flakes|vlokken|pops|loops|\bpap\b|ontbijtcup|cruesli/
),
sub('sportvoeding', 'Sport & proteïne', /sport|eiwit|protein|proteine|energie/),
sub(
'ontbijtkoek',
'Ontbijtkoek & repen',
/ontbijtkoek|mueslireep|repen|reep|biscuit|koek|tussendoor/
),
sub(
'crackers',
'Crackers & wafels',
/cracker|beschuit|rijstwafel|maiswafel|knackebrod|roggebrood|linzenwafel|wafels/
),
sub(
'zoet-beleg',
'Zoet beleg',
/hagelslag|hazelnoot|chocoladepasta|chocopasta|\bjam\b|jam |honing|stroop|speculoos|vruchtenhagel|fruitspread|muisjes|vlokken|zoet|pasta\b/
),
sub('bakken', 'Bakken', /\bbak|mix|meel|suiker|zoetjes|gist/),
sub('hartig-beleg', 'Hartig beleg', /hartig|beleg|salade|spread|smeerkaas/)
]
},
{
slug: 'maaltijden',
name: 'Maaltijden & salades',
icon: 'salad',
color: '#65a30d',
food: true,
subs: [
sub('salades', 'Salades', /salade|slaatje|salad|bowl/),
sub('verse-soep', 'Verse soep', /soep/),
sub('pasta-pizza', 'Verse pasta & pizza', /pasta|pizza|gnocchi|deeg|noedel|lasagne/),
sub(
'snacks-broodjes',
'Broodjes, wraps & snacks',
/broodje|sandwich|wrap|quiche|hartige taart|bapao|snack|pannenkoek|poffertje|sushi/
),
sub(
'maaltijden',
'Kant-en-klaar maaltijden',
/maaltijd|stamppot|ovenschotel|stoom|wereld|oosters|aziatisch|italiaans|hollands|curry|grill en gaar/
)
]
},
{
slug: 'pasta-rijst-wereld',
name: 'Pasta, rijst & wereldkeuken',
icon: 'cooking-pot',
color: '#b45309',
food: true,
subs: [
sub('pastasaus', 'Pastasaus & pesto', /pastasaus|tomatendip|pesto|tomatensaus/),
sub('noedels', 'Noedels & mie', /noedel|\bmie\b|ramen|udon|\bmi\b|glasnoedel/),
sub(
'pasta',
'Pasta',
/pasta|spaghetti|penne|fusilli|macaroni|lasagne|tagliatelle|orzo|schelpjes|gnocchi|farfalle|tortellini|ravioli|conchigl|casarecce/
),
sub('rijst', 'Rijst & granen', /rijst|couscous|quinoa|bulgur|risotto/),
sub('mexicaans', 'Mexicaans', /mexica|taco|tortilla|burrito|wrap|nacho|fajita/),
sub(
'aziatisch',
'Aziatisch',
/aziat|oosters|japan|chines|thai|korea|indonesi|indiase|indisch|sushi|sambal|ketjap|soja|boemboe|kroepoek|woksaus|curry|seroendeng|vietnam|kimchi|miso/
),
sub('kruiden-mixen', 'Kruiden & maaltijdmixen', /kruid|specerij|maaltijdpakket|mix/),
sub(
'wereld',
'Overige wereldkeukens',
/surinaam|turks|pools|midden-oost|amerikaans|mediterr|spaans|italiaans|antilliaans|antiliaans|wereld|grieks|afrikaans/
)
]
},
{
slug: 'soepen-sauzen',
name: 'Soepen, sauzen & conserven',
icon: 'soup',
color: '#c2410c',
food: true,
subs: [
sub('soep', 'Soep & bouillon', /soep|bouillon|\bfond\b|miso/),
sub(
'snacksauzen',
'Mayonaise, ketchup & snacksauzen',
/snacksaus|mayo|ketchup|fritessaus|mosterd|knoflooksaus|bbq saus|chilisaus|sriracha|tabasco|pepersaus|aioli|curry ?saus|joppiesaus|satesaus/
),
sub(
'olie-azijn',
'Olie, azijn & dressing',
/olie|azijn|frituur|dressing|balsamico|\bvet\b|slasaus/
),
sub(
'kruiden',
'Kruiden & smaakmakers',
/kruid|specerij|peper|\bzout|zeezout|aromat|paprikapoeder|smaakmaker|poeder|bouillonblok/
),
sub(
'conserven',
'Conserven',
/conserv|\bblik|augurk|uitjes|tonijn|pot\b|knakworst|sardine|makreel|bonen|mais/
),
sub('sauzen', 'Sauzen & jus', /saus|sauzen|jus\b|marinade|roerbak|woksaus/)
]
},
{
slug: 'snoep-snacks',
name: 'Snoep, koek & chips',
icon: 'candy',
color: '#db2777',
food: true,
subs: [
sub(
'noten',
'Noten & zaden',
/\bnoot|noten|pinda|cashew|amandel|pistache|macadamia|pecan|pijnboom|pitten|zaden|walnot/
),
sub(
'chips',
'Chips & zoutjes',
/chips|popcorn|zoutje|pretzel|toast|cracker|nacho|tortilla|rijstzout|kaaszout|dipstok|borrelmix/
),
sub(
'chocolade',
'Chocolade',
/chocola|bonbon|praline|candybar|letter|truffel|reep chocolade|melk ?choc|pure choc/
),
sub(
'snoep-drop',
'Snoep & drop',
/snoep|drop\b|\bdrop|gum|lolly|toffee|marsepein|kauwgom|pepermunt|\bmint|keelsnoep|pastille|strooigoed|winegum|spekjes|zuurtje|uitdeel/
),
sub(
'koek',
'Koek & biscuit',
/koek|biscuit|wafel|speculaas|stroopwafel|cake|kruidnot|pepernot|schuimpjes|bitterkoek|eierkoek/
),
sub('repen', 'Repen', /reep|repen|protein|proteine/),
sub(
'borrel',
'Borrelhapjes & dips',
/borrel|tapas|worst|salami|fuet|olijf|\bdip|hapje|bitterbal|kroket|loempia|kaassouffle|snack|salade|pesto|tapenade|aioli|gyoza|dimsum|bao/
)
]
},
{
slug: 'diepvries',
name: 'Diepvries',
icon: 'snowflake',
color: '#06b6d4',
food: true,
subs: [
sub('ijs', 'IJs', /\bijs|ijs\b|roomijs|waterijs|schepijs|magnum|cornetto/),
sub('pizza', 'Pizza', /pizza/),
sub(
'friet-aardappel',
'Friet & aardappel',
/friet|patat|aardappel|rosti|krieltje|partjes|kroketjes/
),
sub(
'snacks',
'Snacks',
/snack|kroket|frikandel|bitterbal|loempia|hapje|kaassouffle|bamischijf|nasischijf|gyoza|dimsum/
),
sub(
'groente-fruit',
'Groente & fruit',
/groente|fruit|spinazie|bessen|mango|erwten|bonen|broccoli|smoothie/
),
sub(
'vlees-vis',
'Vlees & vis',
/\bvis|vlees|\bkip|gevogelte|garnal|vissticks|zalm|lekkerbek|kibbeling|halal|sate|burger/
),
sub('brood-gebak', 'Brood & gebak', /brood|deeg|gebak|croissant|bladerdeeg|taart/),
sub('maaltijden', 'Maaltijden', /maaltijd|lasagne|nasi|bami|ovenschotel|wereld/),
sub('dier', 'Dierenvoeding', /dier|honden|katten/)
]
},
{
slug: 'koffie-thee',
name: 'Koffie & thee',
icon: 'coffee',
color: '#92400e',
food: true,
subs: [
sub('cups', 'Koffiecups', /cups?\b|capsule|nespresso|dolce gusto|tassimo/),
sub('pads', 'Koffiepads', /\bpads?\b|senseo/),
sub('bonen', 'Koffiebonen', /bonen/),
sub('filter', 'Filterkoffie', /filter|gemalen/),
sub('oploskoffie', 'Oploskoffie', /oplos|instant|concentraat|cappuccino/),
sub('thee', 'Thee', /thee|rooibos|matcha|chai|infusion/),
sub('cacao', 'Cacao & chocolademelk', /cacao|chocolademelk|chocomel/),
sub(
'koffiemelk-suiker',
'Koffiemelk & suiker',
/koffiemelk|creamer|suiker|zoetje|siroop|melk/
)
]
},
{
slug: 'frisdrank-sap',
name: 'Frisdrank, sap & water',
icon: 'cup-soda',
color: '#2563eb',
food: true,
subs: [
sub('energy-sport', 'Energy & sportdrank', /energ|sport|isoton|elektrolyt|red bull|monster/),
sub('ice-tea', 'Ice tea', /ice ?tea|ijsthee/),
sub('cola', 'Cola', /\bcola/),
sub('siroop', 'Siroop & limonade', /siroop|limonade|ranja/),
sub(
'sap',
'Sap & fruitdrank',
/\bsap\b|sappen|appelsap|sinaasappelsap|jus d|smoothie|fruitdrank|drinkpak|nectar|groentesap|cranberrysap/
),
sub('water', 'Water', /water/),
sub(
'fris',
'Frisdrank',
/fris|sinas|tonic|bitter lemon|ginger|lemon|rivella|cassis|kombucha|7up|seven up|fanta|sprite|\blime/
)
]
},
{
slug: 'bier-wijn',
name: 'Bier, wijn & sterk',
icon: 'wine',
color: '#7c3aed',
food: true,
subs: [
sub('alcoholvrij', 'Alcoholvrij', /alcoholvrij|\b0[.,]0\b|alcohol free/),
sub(
'bier',
'Bier',
/bier|pils|\bipa\b|weizen|radler|tripel|dubbel|quadrupel|blond|bock|saison|cider|lager|stout|porter/
),
sub(
'bubbels',
'Bubbels',
/bubbel|\bcava|champagne|prosecco|cremant|mousserend|sekt|spumante/
),
sub('rose', 'Rosé', /\brose\b|rose wijn/),
sub(
'rood',
'Rode wijn',
/rode wijn|rood|cabernet|merlot|tempranillo|shiraz|syrah|pinot noir|malbec|sangiovese|grenache|nebbiolo|carmenere|primitivo|rioja|chianti/
),
sub(
'wit',
'Witte wijn',
/witte wijn|\bwit\b|chardonnay|sauvignon|pinot grigio|verdejo|riesling|viognier|chenin|gruner|vermentino|moscato|pinot blanc|wijn/
),
sub(
'sterk',
'Sterke drank & aperitief',
/sterk|whisk|wodka|vodka|\brum\b|\bgin\b|likeur|jenever|cognac|tequila|\bport\b|sherry|vermouth|aperitie|cocktail|mixdrank|seltzer|likorette|hugo|brandy|advocaat|baileys/
)
]
},
{
slug: 'drogisterij',
name: 'Drogisterij',
icon: 'pill',
color: '#c026d3',
food: false,
subs: [
sub(
'vitamines',
'Vitamines & supplementen',
/vitamine|supplement|magnesium|omega|eiwit|protein|creatine|sportvoeding|elektrolyt|immuun|energie|fitness|drinkmaaltijd|multivit|ijzer|calcium|collageen/
),
sub(
'gezondheid',
'Gezondheid & EHBO',
/zelfzorg|geneesmiddel|paracetamol|ibuprofen|nurofen|pijnstill|hoest|keel|\bneus|maagzuur|laxeer|hooikoorts|ehbo|pleister|wond|condoom|glijmiddel|zwangerschap|zelftest|stoppen met roken|optiek|insect|natuurlijke|oogdruppel|lenzen|thermometer|seksualiteit|infectie|jeuk/
),
sub('mond', 'Mondverzorging', /mond|tand|gebit|floss|rager/),
sub('scheren', 'Scheren', /scheer|scheren|aftershave/),
sub('deo-parfum', 'Deodorant & parfum', /deo|parfum|eau de/),
sub(
'haar',
'Haarverzorging',
/haar|shampoo|conditioner|styling|\bgel\b|\bwax\b|mousse|droogshampoo/
),
sub('douche-bad', 'Douche, bad & zeep', /douche|\bbad|zeep|handcreme|handzeep|badschuim/),
sub(
'hygiene',
'Intieme hygiëne',
/intiem|maandverband|tampon|inlegkruis|dameshygi|menstruatie|incontinentie|zakdoek|tissue|watten/
),
sub(
'huid',
'Huid & make-up',
/huid|creme|body|gezicht|lotion|lippen|zonnebrand|\boog|masker|serum|voet|massage|make-up|mascara|nagel|lippenbalsem/
)
]
},
{
slug: 'baby-kind',
name: 'Baby & kind',
icon: 'baby',
color: '#f472b6',
food: true,
subs: [
sub(
'luiers',
'Luiers & doekjes',
/luier|billendoek|verschon|doekje|snoetenpoetser|washandje/
),
sub(
'babymelk',
'Baby- & peutermelk',
/flesvoeding|zuigeling|opvolgmelk|dreumesmelk|peutermelk|kindermelk/
),
sub(
'verzorging',
'Verzorging',
/verzorg|shampoo|wasgel|zalf|\bolie|creme|lotion|badschuim|wascreme|zelfzorg|tandpasta|tandenborstel/
),
sub(
'accessoires',
'Accessoires & speelgoed',
/babyfles|speen|fopspeen|bijtring|accessoire|speelgoed|onderweg|zwanger|kraam/
),
sub(
'babyvoeding',
'Babyvoeding & snacks',
/voeding|maaltijd|\bpap\b|fruitpotje|knijp|hapje|snack|tussendoor|toetje|vanaf|potje|koekje|biscuit/
)
]
},
{
slug: 'huishouden',
name: 'Huishouden',
icon: 'spray-can',
color: '#0891b2',
food: false,
subs: [
sub('afwas', 'Afwas & vaatwas', /afwas|vaatwas/),
sub(
'was',
'Was & wasverzachter',
/wasmiddel|wasverzachter|waspods|waspoeder|wassen|textielverfris|drogen|kledingonderhoud|schoenonderhoud|vlekverwijder|wascapsule/
),
sub(
'papier',
'Toiletpapier & keukenpapier',
/toiletpapier|keukenpapier|keukenrol|tissue|zakdoek|wc-papier/
),
sub('lucht', 'Luchtverfrissers', /lucht|toiletblok|geur/),
sub(
'schoonmaak',
'Schoonmaken',
/schoonma|reinig|bleek|schuur|ontstop|kalk|poets|spons|dweil|zeem|handschoen|doekjes|schimmel|wc-eend/
),
sub(
'zakken-folie',
'Zakken, folie & bewaren',
/zak|folie|afval|vershoud|bewaar|lunchbox|opberg/
),
sub(
'batterijen-lampen',
'Batterijen, lampen & kaarsen',
/batterij|lamp|\bled\b|lucifer|aansteker|kaars/
),
sub('ongedierte', 'Ongedierte', /insect|ongedierte|muizen|mieren|mug/)
]
},
{
slug: 'dieren',
name: 'Dieren',
icon: 'paw-print',
color: '#a16207',
food: false,
subs: [
sub('hond', 'Hond', /hond/),
sub('kat', 'Kat', /\bkat|katten/),
sub('overig-dier', 'Vogel, vis & knaagdier', /vis|vogel|knaagd|konijn|cavia|dier/)
]
},
{
slug: 'keuken-tafel',
name: 'Keuken & tafel',
icon: 'utensils-crossed',
color: '#475569',
food: false,
subs: [
sub(
'apparaten',
'Keukenapparaten',
/apparat|airfryer|frituurpan|blender|mixer|waterkoker|koffiezet|melkopschuim|contactgrill|tosti|koelkast|vriezer|machine/
),
sub('bbq', 'BBQ', /bbq|barbecue/),
sub(
'bewaren',
'Bewaren & meenemen',
/bewaren|meenemen|lunch|vershoud|drinkfles|thermos|beker to go/
),
sub(
'tafeltextiel',
'Keuken- & tafeltextiel',
/keukentextiel|tafeltextiel|tafelkleed|servet|theedoek|placemat/
),
sub('bakken', 'Bakken', /bakspullen|bakvorm|bakken|taartvers|cakevorm/),
sub(
'servies',
'Servies, glazen & bestek',
/servies|\bbord|\bkom\b|kommen|schaal|schaaltje|\bmok|beker|kopje|glazen|\bglas|bestek|serveren|tafelen|wegwerp/
),
sub(
'koken',
'Koken & keukengerei',
/koken|pannen|\bpan\b|\bwok\b|ovensch|keuken|spatel|lepel|opschep|chafing|eetstokje|rijstkoker|deegroller|rasp|schaar|dunschiller|knoflookpers|garde|snijplank|serveerplank|\bmes|messen|funcooking/
)
]
},
{
slug: 'wonen-tuin',
name: 'Wonen & tuin',
icon: 'lamp',
color: '#0d9488',
food: false,
subs: [
sub('tuin-bloemen', 'Tuin, bloemen & planten', /tuin|bloem|plant|boeket|rozen|buiten|tulp/),
sub('kaarsen-geur', 'Kaarsen & geur', /kaars|theelicht|geur|lantaarn/),
sub('verlichting', 'Verlichting', /lamp|verlichting|\bled/),
sub(
'textiel-bed',
'Woontextiel & bed',
/dekbed|kussen|laken|plaid|fleece|woontextiel|gordijn|\bbed|handdoek|badmat|kleed/
),
sub(
'meubels',
'Meubels',
/meubel|stoel|\bkast|tafeltje|bankje|poef|bureau|nachtkast|hoofdbord|rek\b/
),
sub(
'decoratie',
'Decoratie',
/decorati|woonaccessoire|fotolijst|spiegel|\bklok|vaas|ornament|manden|kunstbloem|tegeltje|haken|deurknop|beeld/
)
]
},
{
slug: 'feest-seizoen',
name: 'Feest & seizoen',
icon: 'party-popper',
color: '#e11d48',
food: false,
subs: [
sub('kerst', 'Kerst & oud en nieuw', /kerst|oud en nieuw|winter/),
sub('sinterklaas', 'Sinterklaas', /sinterklaas|\bsint\b|pepernot|kruidnot|chocoladeletter/),
sub('pasen', 'Pasen', /pasen|paas/),
sub('halloween', 'Halloween & herfst', /halloween|herfst/),
sub('cadeaus', 'Cadeaus & inpakken', /cadeau|inpak|gift/),
sub(
'feest',
'Feestartikelen',
/feest|verjaardag|babyshower|bruiloft|geslaagd|koningsdag|valentijn|\beid\b|verkle|ballon|traktatie|party/
)
]
},
{
slug: 'hobby-vrije-tijd',
name: 'Hobby & vrije tijd',
icon: 'palette',
color: '#059669',
food: false,
subs: [
sub(
'elektronica',
'Elektronica',
/elektronica|smart home|oplader|kabel|beltegoed|\bsim|koptelefoon|speaker/
),
sub('kleding', 'Kleding & accessoires', /kleding|\bsok|panty|regen|paraplu|tassen|\btas\b/),
sub('speelgoed', 'Speelgoed & spellen', /speelgoed|\bspel/),
sub(
'knutselen',
'Knutselen & hobby',
/knutsel|schilder|teken|kleuren|borduren|hobby|haak|brei/
),
sub('kantoor', 'Kantoor & school', /kantoor|schrijf|\bpen\b|pennen|schrift|school/),
sub(
'reizen',
'Reizen & buiten',
/reis|bagage|kamperen|strand|zwembad|fiets|dakdrager|dakkoffer|uitjes|vrije tijd|souvenir/
),
sub('media', 'Tijdschriften & boeken', /tijdschrift|boek|puzzel/)
]
},
{
slug: 'overig',
name: 'Overig',
icon: 'package',
color: '#737373',
food: false,
subs: []
}
];
/** Tops hidden behind the "Non-food" toggle (homeware, seasonal décor, hobby). */
export const HOMEWARE_TOPS = [
'keuken-tafel',
'wonen-tuin',
'feest-seizoen',
'hobby-vrije-tijd',
'overig'
] as const;
const TOP_BY_SLUG = new Map(TAXONOMY.map((t) => [t.slug, t]));
/** Coarse importer category (normalize.ts CATEGORIES) → top slug. */
const FROM_IMPORTER: Record<string, string> = {
'Groente, fruit & aardappelen': 'groente-fruit',
'Maaltijden & gemak': 'maaltijden',
'Vlees, vis & vega': 'vlees-vis-vega',
'Vleeswaren, kaas & tapas': 'kaas-vleeswaren',
'Zuivel & eieren': 'zuivel-eieren',
'Brood & bakkerij': 'brood-bakkerij',
'Ontbijt & beleg': 'ontbijt-beleg',
'Pasta, rijst & wereldkeuken': 'pasta-rijst-wereld',
'Soepen, sauzen & conserven': 'soepen-sauzen',
'Snoep, koek & chips': 'snoep-snacks',
Diepvries: 'diepvries',
'Koffie & thee': 'koffie-thee',
'Frisdrank & sappen': 'frisdrank-sap',
'Bier & wijn': 'bier-wijn',
Drogisterij: 'drogisterij',
'Huishouden & dieren': 'huishouden',
'Baby & kind': 'baby-kind',
Overig: 'overig'
};
/**
* Store paths the importer left in "Overig" (homeware, seasonal, Xenos, AH "Koken, tafelen,
* vrije tijd", …): the first rule that matches the sub-path, else the top segment, picks the top.
*/
const OVERIG_TOPS: [string, RegExp][] = [
[
'feest-seizoen',
/kerst|sinterklaas|\bsint\b|pasen|paas|halloween|feest|cadeau|valentijn|koningsdag|verjaardag|oud en nieuw|babyshower|bruiloft|geslaagd|eid mubarak|inpakken|herfstdecoratie|verklee?d/
],
[
'keuken-tafel',
/koken|tafelen|keuken|servies|bestek|glazen|serveren|bakspullen|bakvorm|bewaren en meenemen|barbecue|bbq|wegwerp/
],
[
'hobby-vrije-tijd',
/hobby|knutsel|schilder|tekenen|borduren|kantoor|speelgoed|reizen|reisaccessoire|vrije tijd|kamperen|strand|fiets|bagage|tassen|kleding|sokken|elektronica|smart home|tijdschrift|beltegoed|souvenir|dakdrager|uitjes/
],
['drogisterij', /beauty|welzijn|gezondheid|optiek|afslank/],
['huishouden', /huishoud|schoonmaak|batterij|lucifer|aansteker|kledingonderhoud|wassen|insect/],
[
'wonen-tuin',
/woon|wonen|meubel|kaars|lamp|verlichting|decoratie|geur in huis|dekbed|kussen|tuin|bloem|plant|spiegel|klok|fotolijst|stoel|kast|bed|textiel|poef|manden/
],
['huishouden', /non-food|servicebalie/]
];
/** Food products the stores filed under a cross-cutting shelf (Bewuste voeding, Glutenvrij, …). */
const CROSS_CUT =
/bewuste voeding|natuurlijke, biologische|superfood|glutenvrij|suikerbewust|sportvoeding|online only|coming soon|acties|nieuw|outlet/;
const DIEREN =
/huisdier|\bdieren?\b(?! en)|\bkat\b|katten|\bhond|knaagdier|vogel|kattenbak|dierenvoeding/;
export type Classified = { top: string; sub: string | null };
function pickSub(top: TopDef, sub: string, name: string): string | null {
if (sub) for (const s of top.subs) if (s.match.test(sub)) return s.slug;
for (const s of top.subs) if (s.match.test(name)) return s.slug;
return null;
}
/**
* Place one store product in the tree. `category` is the importer's coarse category,
* `path` the store's own path ("Top > Sub > …"), `name` the product title.
*/
export function classify(item: {
category: string | null;
path: string | null;
name: string;
isFood?: boolean;
}): Classified {
const segs = fold(item.path ?? '')
.split('>')
.map((s) => s.trim())
.filter(Boolean);
const topSeg = segs[0] ?? '';
const subPath = segs.slice(1).join(' > ');
const name = fold(item.name);
let top = FROM_IMPORTER[item.category ?? ''] ?? 'overig';
if (top === 'huishouden') {
const p = fold(item.path ?? '').replace(/huishouden (en|&) dieren/, '');
if (DIEREN.test(p) || /\b(katten|honden)(voer|snack|brok)/.test(name)) top = 'dieren';
}
if (top === 'overig') {
// Homeware/seasonal/mixed shelves. Food products only go to food tops (a speculaas in
// "Sinterklaas" is a koek, not a decoration).
const food = item.isFood ?? true;
const okTop = (slug: string) => food === TOP_BY_SLUG.get(slug)!.food;
const pool = TAXONOMY.filter((t) => t.subs.length && t.food === food);
const anySub = (text: string): Classified | null => {
if (!text) return null;
for (const t of pool) {
const s = t.subs.find((x) => x.match.test(text));
if (s) return { top: t.slug, sub: s.slug };
}
return null;
};
const rule = (text: string) =>
text ? OVERIG_TOPS.find(([slug, re]) => okTop(slug) && re.test(text)) : undefined;
const byPath = rule(subPath);
if (byPath) top = byPath[0];
else {
const hit = anySub(subPath) ?? anySub(name);
if (hit) return hit;
const byTop = CROSS_CUT.test(topSeg) ? undefined : rule(topSeg);
if (!byTop) return { top: 'overig', sub: null };
top = byTop[0];
}
}
const def = TOP_BY_SLUG.get(top)!;
// Single-level store trees ("Kruiden", "Sauzen") carry the detail in their only segment;
// broad ones ("Vlees, kip, vis, vega") do not.
const specific = !subPath && !/,|&| en /.test(topSeg) ? topSeg : '';
return { top, sub: pickSub(def, subPath || specific, name) };
}
/** Slug of a subcategory row: unique across the tree. */
export const subSlug = (top: string, sub: string) => `${top}--${sub}`;
type Log = (m: string) => void;
/** Upsert groceries.categories from TAXONOMY; returns slug → id. */
export async function syncCategories(sql: Sql): Promise<Map<string, number>> {
const tops = TAXONOMY.map((t, i) => ({
slug: t.slug,
name: t.name,
icon: t.icon,
color: t.color,
sort_order: (i + 1) * 10,
is_food: t.food
}));
await sql`
insert into groceries.categories ${sql(tops, 'slug', 'name', 'icon', 'color', 'sort_order', 'is_food')}
on conflict (slug) do update set name = excluded.name, icon = excluded.icon, color = excluded.color,
sort_order = excluded.sort_order, is_food = excluded.is_food, parent_id = null, updated_at = now()
where (categories.name, categories.icon, categories.color, categories.sort_order, categories.is_food, categories.parent_id)
is distinct from (excluded.name, excluded.icon, excluded.color, excluded.sort_order, excluded.is_food, null)`;
const topIds = new Map(
(
await sql<{ id: number; slug: string }[]>`
select id, slug from groceries.categories where slug in ${sql(tops.map((t) => t.slug))}`
).map((r) => [r.slug, r.id])
);
const subs = TAXONOMY.flatMap((t) =>
t.subs.map((s, i) => ({
slug: subSlug(t.slug, s.slug),
name: s.name,
parent_id: topIds.get(t.slug)!,
sort_order: (i + 1) * 10,
is_food: t.food
}))
);
if (subs.length)
await sql`
insert into groceries.categories ${sql(subs, 'slug', 'name', 'parent_id', 'sort_order', 'is_food')}
on conflict (slug) do update set name = excluded.name, parent_id = excluded.parent_id,
sort_order = excluded.sort_order, is_food = excluded.is_food, updated_at = now()
where (categories.name, categories.parent_id, categories.sort_order, categories.is_food)
is distinct from (excluded.name, excluded.parent_id, excluded.sort_order, excluded.is_food)`;
const all = await sql<{ id: number; slug: string }[]>`select id, slug from groceries.categories`;
return new Map(all.map((r) => [r.slug, r.id]));
}
/**
* Sync the tree, classify every item (only changed rows are written) and give each group the
* category most of its available items have. Categories no longer in TAXONOMY are removed.
*/
export async function applyTaxonomy(sql: Sql, log: Log = () => {}) {
const ids = await syncCategories(sql);
const items = await sql<
{
id: number;
category: string | null;
category_path: string | null;
name: string;
is_food: boolean;
category_id: number | null;
}[]
>`select id, category, category_path, name, is_food, category_id from groceries.items`;
const changes: { id: number; category_id: number }[] = [];
for (const i of items) {
const c = classify({
category: i.category,
path: i.category_path,
name: i.name,
isFood: i.is_food
});
const cid = ids.get(c.sub ? subSlug(c.top, c.sub) : c.top)!;
if (cid !== i.category_id) changes.push({ id: i.id, category_id: cid });
}
for (let k = 0; k < changes.length; k += 5000) {
const chunk = changes.slice(k, k + 5000);
await sql`
update groceries.items i set category_id = v.category_id
from (select unnest(${chunk.map((c) => c.id)}::int[]) as id,
unnest(${chunk.map((c) => c.category_id)}::int[]) as category_id) v
where i.id = v.id`;
}
const groups = await sql`
update groceries.groups g set category_id = m.category_id
from (
select distinct on (i.group_id) i.group_id, i.category_id
from groceries.items i
where i.group_id is not null and i.category_id is not null
group by i.group_id, i.category_id
order by i.group_id, count(*) filter (where i.available) desc, count(*) desc, i.category_id
) m
where g.id = m.group_id and g.category_id is distinct from m.category_id`;
const known = TAXONOMY.flatMap((t) => [t.slug, ...t.subs.map((s) => subSlug(t.slug, s.slug))]);
const removed = await sql`
delete from groceries.categories where slug not in ${sql(known)}
and not exists (select 1 from groceries.items where category_id = categories.id)`;
log(
`taxonomy: ${ids.size} categories, ${changes.length} items reclassified, ${groups.count} groups updated, ${removed.count} stale categories removed`
);
return { items: changes.length, groups: groups.count };
}

View File

@@ -52,7 +52,7 @@ const EXT: Record<AllowedMimeType, string> = {
/** Upload through the site server so browsers do not need R2 CORS access. */
export async function uploadImage(
file: File,
prefix: 'sightings' | 'lists' | 'locations' | 'setup' = 'sightings'
prefix: 'sightings' | 'lists' | 'locations' | 'setup' | 'groceries' = 'sightings'
): Promise<{ key: string; publicUrl: string }> {
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
const mimeType = file.type;
@@ -101,6 +101,14 @@ export function setupImageKey(url: string): string | null {
return /^setup\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) ? key : null;
}
/** R2 key of an uploaded grocery shelf photo (groceries/<uuid>.ext), or null for anything else. */
export function groceryImageKey(url: string): string | null {
const base = R2_PUBLIC_URL?.replace(/\/$/, '');
if (!base || !url.startsWith(`${base}/`)) return null;
const key = url.slice(base.length + 1);
return /^groceries\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) ? key : null;
}
/**
* Delete a photo from R2 by its key.
*/

View File

@@ -1,7 +1,10 @@
import { db } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { refreshGroupStats } from '#lib/server/groceries/import.js';
import { CATEGORIES, displayName, fold } from '#lib/server/groceries/normalize.js';
import { parseEuros } from '#lib/groceries.js';
import { displayName, fold, parseSize } from '#lib/server/groceries/normalize.js';
import { HOMEWARE_TOPS } from '#lib/server/groceries/taxonomy.js';
import { deleteObject, groceryImageKey, uploadImage } from '#lib/server/r2.js';
import { fail } from '@sveltejs/kit';
import type { Sql } from 'postgres';
import type { Actions, PageServerLoad } from './$types';
@@ -22,6 +25,34 @@ export type GroupRow = {
minUnitPriceCents: number | null;
imageUrl: string | null;
familyKey: string | null;
minPriceCents: number | null;
promoCount: number;
categoryId: number | null;
};
export type ManualRow = {
id: number;
groupId: number;
itemId: number | null;
storeId: number;
priceCents: number;
promo: boolean;
sizeText: string | null;
unit: string | null;
unitAmount: number | null;
unitPriceCents: number | null;
observedOn: string;
photoUrl: string | null;
note: string | null;
};
export type CategoryRow = {
id: number;
slug: string;
name: string;
parentId: number | null;
icon: string | null;
color: string | null;
isFood: boolean;
count: number;
};
export type ItemRow = {
id: number;
@@ -60,7 +91,21 @@ const ITEM_COLS = (s: Sql) => s`
const GROUP_COLS = (s: Sql) => s`
g.id, g.name, g.brand, g.category, g.unit, g.favorite, g.manual, g.item_count as "itemCount",
g.store_count as "storeCount", g.min_unit_price_cents as "minUnitPriceCents",
g.image_url as "imageUrl", g.family_key as "familyKey"`;
g.image_url as "imageUrl", g.family_key as "familyKey", g.min_price_cents as "minPriceCents",
g.promo_count as "promoCount", g.category_id as "categoryId"`;
/** Manual shelf prices with the group their item is in now (items can move between groups). */
const MANUAL_SELECT = (s: Sql) => s`
select m.id, coalesce(i.group_id, g0.merged_into_id, m.group_id) as "groupId", m.item_id as "itemId",
m.store_id as "storeId", m.price_cents as "priceCents", m.promo, m.size_text as "sizeText", m.unit,
m.unit_amount::float8 as "unitAmount", m.unit_price_cents as "unitPriceCents",
m.observed_on::text as "observedOn", m.photo_url as "photoUrl", m.note
from groceries.manual_prices m
left join groceries.items i on i.id = m.item_id
left join groceries.groups g0 on g0.id = m.group_id`;
/** Manual prices older than this stop counting for "cheapest" in the list (still in the detail). */
const MANUAL_FRESH_DAYS = 90;
const SORTS = ['', 'unit', 'price', 'promo', 'stores'] as const;
type Sort = (typeof SORTS)[number];
export const load: PageServerLoad = async (event) => {
requireOwner(event);
@@ -69,7 +114,9 @@ export const load: PageServerLoad = async (event) => {
const q = fold(p.get('q')?.trim() ?? '').slice(0, 100);
const words = q.split(/\s+/).filter(Boolean).slice(0, 6);
const storeSlug = p.get('store') ?? '';
const category = p.get('cat') ?? '';
const catSlug = p.get('cat') ?? '';
const sortParam = p.get('sort') ?? '';
const sort: Sort = (SORTS as readonly string[]).includes(sortParam) ? (sortParam as Sort) : '';
const favorites = p.get('fav') === '1';
const promo = p.get('promo') === '1';
const multi = p.get('multi') === '1';
@@ -85,27 +132,61 @@ export const load: PageServerLoad = async (event) => {
(select count(*)::int from groceries.items i where i.store_id = st.id and i.available) as items
from groceries.stores st where st.archived_at is null order by st.sort_order, st.name`;
const store = stores.find((x) => x.slug === storeSlug) ?? null;
const cats = await s<Omit<CategoryRow, 'count'>[]>`
select id, slug, name, parent_id as "parentId", icon, color, is_food as "isFood"
from groceries.categories order by sort_order, name`;
const cat = cats.find((c) => c.slug === catSlug) ?? null;
const catIds = cat
? cats.filter((c) => c.id === cat.id || c.parentId === cat.id).map((c) => c.id)
: [];
const topOf = (c: (typeof cats)[number]) =>
c.parentId ? cats.find((x) => x.id === c.parentId)! : c;
const homewareIds = cats
.filter((c) => (HOMEWARE_TOPS as readonly string[]).includes(topOf(c).slug))
.map((c) => c.id);
// Choosing a homeware category shows it even with the non-food toggle off.
const showNonfood = nonfood || (cat ? homewareIds.includes(cat.id) : false);
const conds = [s`g.merged_into_id is null`, s`(g.item_count > 0 or g.favorite)`];
for (const w of words)
conds.push(s`g.search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
if (store) conds.push(s`g.store_ids @> array[${store.id}]::int[]`);
if (category) conds.push(s`g.category = ${category}`);
if (store)
conds.push(s`(g.store_ids @> array[${store.id}]::int[] or exists (
select 1 from groceries.manual_prices m where m.group_id = g.id and m.store_id = ${store.id}))`);
if (favorites) conds.push(s`g.favorite`);
if (promo) conds.push(s`g.promo_count > 0`);
if (multi) conds.push(s`g.store_count >= 2`);
if (!nonfood) conds.push(s`(g.is_food or g.category is distinct from 'Overig')`);
const where = conds.reduce((a, c) => s`${a} and ${c}`);
const order = q
? s`g.favorite desc, (lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc, g.store_count desc, g.item_count desc, g.name`
: s`g.favorite desc, g.store_count desc, g.item_count desc, g.name`;
if (!showNonfood && homewareIds.length)
conds.push(
s`(g.is_food or g.category_id is null or not (g.category_id = any(${homewareIds}::int[])))`
);
const and = (list: typeof conds) => list.reduce((a, c) => s`${a} and ${c}`);
// Chip counts ignore the category filter itself (facets).
const facetWhere = and(conds);
const where = cat ? and([...conds, s`g.category_id = any(${catIds}::int[])`]) : facetWhere;
const relevance = q
? s`(lower(g.name) = ${q}) desc, public.word_similarity(${q}::text, lower(g.name)) desc,`
: s``;
const order =
sort === 'unit'
? s`g.min_unit_price_cents asc nulls last, g.store_count desc`
: sort === 'price'
? s`g.min_price_cents asc nulls last, g.store_count desc`
: sort === 'promo'
? s`(g.promo_count > 0) desc, g.favorite desc, ${relevance} g.store_count desc, g.item_count desc`
: sort === 'stores'
? s`g.store_count desc, g.item_count desc`
: s`g.favorite desc, ${relevance} g.store_count desc, g.item_count desc`;
const [groups, [{ total }]] = await Promise.all([
s<GroupRow[]>`
select ${GROUP_COLS(s)} from groceries.groups g where ${where}
order by ${order}, g.id limit ${PAGE_SIZE} offset ${(page - 1) * PAGE_SIZE}`,
order by ${order}, g.name, g.id limit ${PAGE_SIZE} offset ${(page - 1) * PAGE_SIZE}`,
s<{ total: number }[]>`select count(*)::int as total from groceries.groups g where ${where}`
]);
const facetRows = await s<{ id: number | null; n: number }[]>`
select g.category_id as id, count(*)::int as n from groceries.groups g where ${facetWhere}
group by 1`;
const ids = groups.map((g) => g.id);
const items = ids.length
? await s<ItemRow[]>`
@@ -113,6 +194,13 @@ export const load: PageServerLoad = async (event) => {
where i.group_id = any(${ids}::int[]) and i.available
order by coalesce(i.promo_unit_price_cents, i.unit_price_cents) nulls last, i.id`
: [];
const manual = ids.length
? await s<ManualRow[]>`
${MANUAL_SELECT(s)}
where coalesce(i.group_id, g0.merged_into_id, m.group_id) = any(${ids}::int[])
and m.observed_on >= current_date - ${MANUAL_FRESH_DAYS}::int
order by m.observed_on desc, m.id desc`
: [];
const [stats] = await s<
{
@@ -138,21 +226,22 @@ export const load: PageServerLoad = async (event) => {
>`
select distinct on (store_id) store_id as "storeId", finished_at as "finishedAt", status, fetched
from groceries.import_runs order by store_id, started_at desc`;
const categories = await s<{ category: string }[]>`
select distinct category from groceries.groups where category is not null and item_count > 0`;
const catList = [
...CATEGORIES.filter((c) => categories.some((x) => x.category === c)),
...categories
.map((x) => x.category)
.filter((c) => !(CATEGORIES as readonly string[]).includes(c))
];
const facet = new Map(facetRows.map((r) => [r.id, r.n]));
const categories: CategoryRow[] = cats.map((c) => ({
...c,
count:
(facet.get(c.id) ?? 0) +
(c.parentId
? 0
: cats.filter((x) => x.parentId === c.id).reduce((a, x) => a + (facet.get(x.id) ?? 0), 0))
}));
let detail = null;
if (detailId) {
const [group] = await s<(GroupRow & { notes: string | null })[]>`
select ${GROUP_COLS(s)}, g.notes from groceries.groups g where g.id = ${detailId}`;
if (group) {
const [dItems, similar] = await Promise.all([
const [dItems, similar, dManual] = await Promise.all([
s<ItemRow[]>`
select ${ITEM_COLS(s)} from groceries.items i where i.group_id = ${detailId}
order by i.available desc, coalesce(i.promo_unit_price_cents, i.unit_price_cents) nulls last, i.id`,
@@ -162,7 +251,11 @@ export const load: PageServerLoad = async (event) => {
where g.family_key = ${group.familyKey} and g.id <> ${detailId}
and g.merged_into_id is null and g.item_count > 0
order by g.min_unit_price_cents nulls last limit 12`
: Promise.resolve([] as GroupRow[])
: Promise.resolve([] as GroupRow[]),
s<ManualRow[]>`
${MANUAL_SELECT(s)}
where coalesce(i.group_id, g0.merged_into_id, m.group_id) = ${detailId}
order by m.observed_on desc, m.id desc limit 100`
]);
const history = dItems.length
? await s<
@@ -179,7 +272,7 @@ export const load: PageServerLoad = async (event) => {
from groceries.item_prices where item_id = any(${dItems.map((i) => i.id)}::int[])
order by observed_on desc limit 400`
: [];
detail = { group, items: dItems, similar, history };
detail = { group, items: dItems, similar, history, manual: dManual };
}
}
@@ -187,19 +280,22 @@ export const load: PageServerLoad = async (event) => {
stores,
groups,
items,
manual,
total,
page,
pageSize: PAGE_SIZE,
filters: {
q: p.get('q') ?? '',
store: store?.slug ?? '',
category,
category: cat?.slug ?? '',
sort,
favorites,
promo,
multi,
nonfood
},
categories: catList,
categories,
manualFreshDays: MANUAL_FRESH_DAYS,
stats,
runs,
detail,
@@ -212,7 +308,100 @@ const id = (v: FormDataEntryValue | null) => {
return Number.isInteger(n) && n > 0 ? n : null;
};
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
export const actions: Actions = {
/**
* Record a shelf price seen in a store (owner only): for a store product or a whole group,
* with an optional pack size (for the unit price), date, note and photo.
*/
addManualPrice: async (event) => {
requireOwner(event);
const s = sql();
const form = await event.request.formData();
const storeId = id(form.get('storeId'));
let groupId = id(form.get('groupId'));
const itemId = id(form.get('itemId'));
const priceCents = parseEuros(String(form.get('price') ?? ''));
if (!storeId) return fail(400, { message: 'Pick a store' });
if (!priceCents) return fail(400, { message: 'Enter a price like 1,29' });
const [st] =
await s`select id from groceries.stores where id = ${storeId} and archived_at is null`;
if (!st) return fail(404, { message: 'Store not found' });
type ItemSize = { group_id: number | null; unit: string | null; unit_amount: number | null };
let item: ItemSize | undefined;
if (itemId) {
[item] = await s<ItemSize[]>`
select group_id, unit, unit_amount::float8 as unit_amount from groceries.items where id = ${itemId}`;
if (!item) return fail(404, { message: 'Product not found' });
groupId = item.group_id ?? groupId;
}
if (!groupId) return fail(400, { message: 'Pick a product' });
const [grp] = await s<{ id: number; unit: string | null }[]>`
select coalesce(merged_into_id, id) as id, unit from groceries.groups where id = ${groupId}`;
if (!grp) return fail(404, { message: 'Product not found' });
groupId = grp.id;
const sizeText =
String(form.get('size') ?? '')
.trim()
.slice(0, 60) || null;
let size = parseSize(sizeText);
if (!size && item?.unit && item.unit_amount)
size = { unit: item.unit as 'kg', amount: item.unit_amount };
if (!size && !sizeText) {
// Same pack as the group's usual one (when its products agree on a size).
const [usual] = await s<{ unit: string; amount: number }[]>`
select unit, unit_amount::float8 as amount from groceries.items
where group_id = ${groupId} and available and unit is not null and unit_amount > 0
group by 1, 2 order by count(*) desc limit 1`;
if (usual) size = { unit: usual.unit as 'kg', amount: usual.amount };
}
const unitPriceCents = size && size.amount > 0 ? Math.round(priceCents / size.amount) : null;
const dateRaw = String(form.get('observedOn') ?? '');
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
const observedOn =
ISO_DATE.test(dateRaw) && dateRaw >= '2020-01-01' && dateRaw <= today ? dateRaw : today;
const note =
String(form.get('note') ?? '')
.trim()
.slice(0, 500) || null;
let photoUrl: string | null = null;
const photo = form.get('photo');
if (photo instanceof File && photo.size > 0) {
if (!PHOTO_TYPES.includes(photo.type) || photo.size > 15 * 1024 * 1024)
return fail(400, { message: 'Photo: JPEG, PNG or WebP up to 15 MB' });
try {
photoUrl = (await uploadImage(photo, 'groceries')).publicUrl;
} catch (e) {
console.error('groceries photo upload failed', e);
return fail(502, { message: 'Photo upload failed; try again without the photo' });
}
}
const [row] = await s<{ id: number }[]>`
insert into groceries.manual_prices (store_id, group_id, item_id, price_cents, promo, size_text, unit,
unit_amount, unit_price_cents, observed_on, photo_url, note)
values (${storeId}, ${groupId}, ${itemId}, ${priceCents}, ${form.get('promo') === 'on' || form.get('promo') === '1'},
${sizeText}, ${size?.unit ?? null}, ${size?.amount ?? null}, ${unitPriceCents}, ${observedOn},
${photoUrl}, ${note})
returning id`;
return { manualAdded: row.id, groupId };
},
deleteManualPrice: async (event) => {
requireOwner(event);
const mid = id((await event.request.formData()).get('id'));
if (!mid) return fail(400, { message: 'Invalid price' });
const [row] = await sql()<{ photo_url: string | null }[]>`
delete from groceries.manual_prices where id = ${mid} returning photo_url`;
const key = row?.photo_url ? groceryImageKey(row.photo_url) : null;
if (key)
await deleteObject(key).catch((e) => console.error('groceries photo delete failed', e));
return { manualDeleted: mid };
},
favorite: async (event) => {
requireOwner(event);
const form = await event.request.formData();

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import { json } from '@sveltejs/kit';
import type { Sql } from 'postgres';
import type { RequestHandler } from './$types';
/** Group picker for merge/move: top matches for ?q= (owner only). */
/** Group picker for merge/move and shelf prices: top matches for ?q= (owner only). */
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const q = fold(event.url.searchParams.get('q')?.trim() ?? '').slice(0, 100);
@@ -14,7 +14,8 @@ export const GET: RequestHandler = async (event) => {
const s = db.$client as unknown as Sql;
const conds = words.map((w) => s`search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
const rows = await s`
select id, name, brand, unit, store_count as "storeCount", item_count as "itemCount"
select id, name, brand, unit, store_count as "storeCount", item_count as "itemCount",
image_url as "imageUrl", min_price_cents as "minPriceCents"
from groceries.groups
where merged_into_id is null and ${conds.reduce((a, c) => s`${a} and ${c}`)}
order by public.word_similarity(${q}::text, lower(name)) desc, store_count desc, item_count desc, id