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.
*/