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
Some checks failed
Deploy production / deploy (push) Failing after 1m1s
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
CREATE TABLE "groceries"."categories" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"icon" text,
|
||||||
|
"color" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"is_food" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "categories_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
CONSTRAINT "categories_name_check" CHECK (length(btrim("name")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "groceries"."manual_prices" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"store_id" integer NOT NULL,
|
||||||
|
"group_id" integer,
|
||||||
|
"item_id" integer,
|
||||||
|
"price_cents" integer NOT NULL,
|
||||||
|
"promo" boolean DEFAULT false NOT NULL,
|
||||||
|
"size_text" text,
|
||||||
|
"unit" text,
|
||||||
|
"unit_amount" numeric(12,4),
|
||||||
|
"unit_price_cents" integer,
|
||||||
|
"observed_on" date DEFAULT now() NOT NULL,
|
||||||
|
"photo_url" text,
|
||||||
|
"note" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "manual_prices_price_check" CHECK ("price_cents" > 0 AND "price_cents" < 1000000),
|
||||||
|
CONSTRAINT "manual_prices_target_check" CHECK ("group_id" IS NOT NULL OR "item_id" IS NOT NULL),
|
||||||
|
CONSTRAINT "manual_prices_unit_check" CHECK ("unit" IS NULL OR "unit" IN ('piece','kg','l'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."groups" ADD COLUMN "category_id" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."items" ADD COLUMN "category_id" integer;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "categories_slug_key" ON "groceries"."categories" ("slug");--> statement-breakpoint
|
||||||
|
CREATE INDEX "categories_parent_idx" ON "groceries"."categories" ("parent_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "groups_category_id_idx" ON "groceries"."groups" ("category_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "items_category_id_idx" ON "groceries"."items" ("category_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "manual_prices_group_idx" ON "groceries"."manual_prices" ("group_id","observed_on");--> statement-breakpoint
|
||||||
|
CREATE INDEX "manual_prices_item_idx" ON "groceries"."manual_prices" ("item_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."categories" ADD CONSTRAINT "categories_parent_id_categories_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "groceries"."categories"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."groups" ADD CONSTRAINT "groups_category_id_categories_id_fkey" FOREIGN KEY ("category_id") REFERENCES "groceries"."categories"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."items" ADD CONSTRAINT "items_category_id_categories_id_fkey" FOREIGN KEY ("category_id") REFERENCES "groceries"."categories"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."manual_prices" ADD CONSTRAINT "manual_prices_store_id_stores_id_fkey" FOREIGN KEY ("store_id") REFERENCES "groceries"."stores"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."manual_prices" ADD CONSTRAINT "manual_prices_group_id_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "groceries"."groups"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groceries"."manual_prices" ADD CONSTRAINT "manual_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "groceries"."items"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
-- The category tree itself is synced from src/lib/server/groceries/taxonomy.ts by
|
||||||
|
-- applyTaxonomy() (nightly import, or `bun scripts/groceries-taxonomy.ts`).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA groceries TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA groceries TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
30145
drizzle/20261025190955_groceries_taxonomy_manual/snapshot.json
Normal file
30145
drizzle/20261025190955_groceries_taxonomy_manual/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import {
|
|||||||
importStore,
|
importStore,
|
||||||
regroup
|
regroup
|
||||||
} from '../src/lib/server/groceries/import';
|
} from '../src/lib/server/groceries/import';
|
||||||
|
import { applyTaxonomy } from '../src/lib/server/groceries/taxonomy';
|
||||||
import { SOURCES } from '../src/lib/server/groceries/sources';
|
import { SOURCES } from '../src/lib/server/groceries/sources';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -86,6 +87,7 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await regroup(sql, log);
|
await regroup(sql, log);
|
||||||
|
await applyTaxonomy(sql, log);
|
||||||
} finally {
|
} finally {
|
||||||
await sql.end();
|
await sql.end();
|
||||||
}
|
}
|
||||||
|
|||||||
17
scripts/groceries-taxonomy.ts
Normal file
17
scripts/groceries-taxonomy.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Re-sync the shared grocery category tree and reclassify all items and groups.
|
||||||
|
//
|
||||||
|
// bun scripts/groceries-taxonomy.ts
|
||||||
|
//
|
||||||
|
// The nightly import does the same after regrouping; run this after changing taxonomy.ts.
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { applyTaxonomy } from '../src/lib/server/groceries/taxonomy';
|
||||||
|
|
||||||
|
if (!process.env.DB_URL) throw new Error('DB_URL is not set');
|
||||||
|
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const t = Date.now();
|
||||||
|
await applyTaxonomy(sql, (m) => console.log(m));
|
||||||
|
console.log(`done in ${((Date.now() - t) / 1000).toFixed(1)} s`);
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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_ = (
|
const it_ = (
|
||||||
id: number,
|
id: number,
|
||||||
@@ -42,3 +51,69 @@ describe('grocery price helpers', () => {
|
|||||||
expect(formatUnitPrice(null, 'kg')).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -62,3 +62,119 @@ export function sortByPrice<T extends PricedItem>(items: T[], mode: 'unit' | 'pa
|
|||||||
((mode === 'unit' ? effectiveUnitPrice(i) : effectivePrice(i)) ?? 1e11);
|
((mode === 'unit' ? effectiveUnitPrice(i) : effectivePrice(i)) ?? 1e11);
|
||||||
return [...items].sort((a, b) => val(a) - val(b) || a.id - b.id);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* (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(),
|
name: text('name').notNull(),
|
||||||
brand: text('brand'), // display brand; 'Huismerk' for AH/Jumbo/Plus own brands
|
brand: text('brand'), // display brand; 'Huismerk' for AH/Jumbo/Plus own brands
|
||||||
category: text('category'),
|
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>(),
|
unit: text('unit').$type<GroceryUnit>(),
|
||||||
/** Name tokens without brand: groups sharing it are "similar" (e.g. house vs Campina melk). */
|
/** Name tokens without brand: groups sharing it are "similar" (e.g. house vs Campina melk). */
|
||||||
familyKey: text('family_key'),
|
familyKey: text('family_key'),
|
||||||
@@ -87,6 +120,7 @@ export const groceryGroups = groceriesSchema.table(
|
|||||||
uniqueIndex('groups_key_key').on(t.key),
|
uniqueIndex('groups_key_key').on(t.key),
|
||||||
index('groups_family_idx').on(t.familyKey),
|
index('groups_family_idx').on(t.familyKey),
|
||||||
index('groups_category_idx').on(t.category),
|
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_store_ids_idx').using('gin', t.storeIds),
|
||||||
index('groups_search_trgm_idx').using('gin', sql`${t.searchText} gin_trgm_ops`),
|
index('groups_search_trgm_idx').using('gin', sql`${t.searchText} gin_trgm_ops`),
|
||||||
index('groups_list_idx').on(t.storeCount, t.name),
|
index('groups_list_idx').on(t.storeCount, t.name),
|
||||||
@@ -119,6 +153,10 @@ export const groceryItems = groceriesSchema.table(
|
|||||||
promoUnitPriceCents: integer('promo_unit_price_cents'),
|
promoUnitPriceCents: integer('promo_unit_price_cents'),
|
||||||
category: text('category'), // canonical category (shared across stores)
|
category: text('category'), // canonical category (shared across stores)
|
||||||
categoryPath: text('category_path'), // the store's own path
|
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'),
|
gtin: text('gtin'),
|
||||||
gtinCheckedAt: timestamptz('gtin_checked_at'),
|
gtinCheckedAt: timestamptz('gtin_checked_at'),
|
||||||
imageUrl: text('image_url'),
|
imageUrl: text('image_url'),
|
||||||
@@ -139,6 +177,7 @@ export const groceryItems = groceriesSchema.table(
|
|||||||
uniqueIndex('items_store_product_key').on(t.storeId, t.storeProductId),
|
uniqueIndex('items_store_product_key').on(t.storeId, t.storeProductId),
|
||||||
index('items_group_idx').on(t.groupId),
|
index('items_group_idx').on(t.groupId),
|
||||||
index('items_gtin_idx').on(t.gtin),
|
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`),
|
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_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`)
|
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)]
|
(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. */
|
/** One importer run per store (counts and errors), for the UI footer and debugging. */
|
||||||
export const groceryImportRuns = groceriesSchema.table(
|
export const groceryImportRuns = groceriesSchema.table(
|
||||||
'import_runs',
|
'import_runs',
|
||||||
@@ -185,3 +259,5 @@ export const groceryImportRuns = groceriesSchema.table(
|
|||||||
export type GroceryStore = typeof groceryStores.$inferSelect;
|
export type GroceryStore = typeof groceryStores.$inferSelect;
|
||||||
export type GroceryGroup = typeof groceryGroups.$inferSelect;
|
export type GroceryGroup = typeof groceryGroups.$inferSelect;
|
||||||
export type GroceryItem = typeof groceryItems.$inferSelect;
|
export type GroceryItem = typeof groceryItems.$inferSelect;
|
||||||
|
export type GroceryCategory = typeof groceryCategories.$inferSelect;
|
||||||
|
export type GroceryManualPrice = typeof groceryManualPrices.$inferSelect;
|
||||||
|
|||||||
71
src/lib/server/groceries/taxonomy.test.ts
Normal file
71
src/lib/server/groceries/taxonomy.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
928
src/lib/server/groceries/taxonomy.ts
Normal file
928
src/lib/server/groceries/taxonomy.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ const EXT: Record<AllowedMimeType, string> = {
|
|||||||
/** Upload through the site server so browsers do not need R2 CORS access. */
|
/** Upload through the site server so browsers do not need R2 CORS access. */
|
||||||
export async function uploadImage(
|
export async function uploadImage(
|
||||||
file: File,
|
file: File,
|
||||||
prefix: 'sightings' | 'lists' | 'locations' | 'setup' = 'sightings'
|
prefix: 'sightings' | 'lists' | 'locations' | 'setup' | 'groceries' = 'sightings'
|
||||||
): Promise<{ key: string; publicUrl: string }> {
|
): Promise<{ key: string; publicUrl: string }> {
|
||||||
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
|
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
|
||||||
const mimeType = file.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;
|
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.
|
* Delete a photo from R2 by its key.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { db } from '#lib/server/db/index.js';
|
import { db } from '#lib/server/db/index.js';
|
||||||
import { requireOwner } from '#lib/server/owner.js';
|
import { requireOwner } from '#lib/server/owner.js';
|
||||||
import { refreshGroupStats } from '#lib/server/groceries/import.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 { fail } from '@sveltejs/kit';
|
||||||
import type { Sql } from 'postgres';
|
import type { Sql } from 'postgres';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -22,6 +25,34 @@ export type GroupRow = {
|
|||||||
minUnitPriceCents: number | null;
|
minUnitPriceCents: number | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
familyKey: 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 = {
|
export type ItemRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -60,7 +91,21 @@ const ITEM_COLS = (s: Sql) => s`
|
|||||||
const GROUP_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.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.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) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
requireOwner(event);
|
requireOwner(event);
|
||||||
@@ -69,7 +114,9 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
const q = fold(p.get('q')?.trim() ?? '').slice(0, 100);
|
const q = fold(p.get('q')?.trim() ?? '').slice(0, 100);
|
||||||
const words = q.split(/\s+/).filter(Boolean).slice(0, 6);
|
const words = q.split(/\s+/).filter(Boolean).slice(0, 6);
|
||||||
const storeSlug = p.get('store') ?? '';
|
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 favorites = p.get('fav') === '1';
|
||||||
const promo = p.get('promo') === '1';
|
const promo = p.get('promo') === '1';
|
||||||
const multi = p.get('multi') === '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
|
(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`;
|
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 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)`];
|
const conds = [s`g.merged_into_id is null`, s`(g.item_count > 0 or g.favorite)`];
|
||||||
for (const w of words)
|
for (const w of words)
|
||||||
conds.push(s`g.search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
|
conds.push(s`g.search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
|
||||||
if (store) conds.push(s`g.store_ids @> array[${store.id}]::int[]`);
|
if (store)
|
||||||
if (category) conds.push(s`g.category = ${category}`);
|
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 (favorites) conds.push(s`g.favorite`);
|
||||||
if (promo) conds.push(s`g.promo_count > 0`);
|
if (promo) conds.push(s`g.promo_count > 0`);
|
||||||
if (multi) conds.push(s`g.store_count >= 2`);
|
if (multi) conds.push(s`g.store_count >= 2`);
|
||||||
if (!nonfood) conds.push(s`(g.is_food or g.category is distinct from 'Overig')`);
|
if (!showNonfood && homewareIds.length)
|
||||||
const where = conds.reduce((a, c) => s`${a} and ${c}`);
|
conds.push(
|
||||||
const order = q
|
s`(g.is_food or g.category_id is null or not (g.category_id = any(${homewareIds}::int[])))`
|
||||||
? 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`;
|
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([
|
const [groups, [{ total }]] = await Promise.all([
|
||||||
s<GroupRow[]>`
|
s<GroupRow[]>`
|
||||||
select ${GROUP_COLS(s)} from groceries.groups g where ${where}
|
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}`
|
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 ids = groups.map((g) => g.id);
|
||||||
const items = ids.length
|
const items = ids.length
|
||||||
? await s<ItemRow[]>`
|
? await s<ItemRow[]>`
|
||||||
@@ -113,6 +194,13 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
where i.group_id = any(${ids}::int[]) and i.available
|
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`
|
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<
|
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
|
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`;
|
from groceries.import_runs order by store_id, started_at desc`;
|
||||||
const categories = await s<{ category: string }[]>`
|
const facet = new Map(facetRows.map((r) => [r.id, r.n]));
|
||||||
select distinct category from groceries.groups where category is not null and item_count > 0`;
|
const categories: CategoryRow[] = cats.map((c) => ({
|
||||||
const catList = [
|
...c,
|
||||||
...CATEGORIES.filter((c) => categories.some((x) => x.category === c)),
|
count:
|
||||||
...categories
|
(facet.get(c.id) ?? 0) +
|
||||||
.map((x) => x.category)
|
(c.parentId
|
||||||
.filter((c) => !(CATEGORIES as readonly string[]).includes(c))
|
? 0
|
||||||
];
|
: cats.filter((x) => x.parentId === c.id).reduce((a, x) => a + (facet.get(x.id) ?? 0), 0))
|
||||||
|
}));
|
||||||
|
|
||||||
let detail = null;
|
let detail = null;
|
||||||
if (detailId) {
|
if (detailId) {
|
||||||
const [group] = await s<(GroupRow & { notes: string | null })[]>`
|
const [group] = await s<(GroupRow & { notes: string | null })[]>`
|
||||||
select ${GROUP_COLS(s)}, g.notes from groceries.groups g where g.id = ${detailId}`;
|
select ${GROUP_COLS(s)}, g.notes from groceries.groups g where g.id = ${detailId}`;
|
||||||
if (group) {
|
if (group) {
|
||||||
const [dItems, similar] = await Promise.all([
|
const [dItems, similar, dManual] = await Promise.all([
|
||||||
s<ItemRow[]>`
|
s<ItemRow[]>`
|
||||||
select ${ITEM_COLS(s)} from groceries.items i where i.group_id = ${detailId}
|
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`,
|
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}
|
where g.family_key = ${group.familyKey} and g.id <> ${detailId}
|
||||||
and g.merged_into_id is null and g.item_count > 0
|
and g.merged_into_id is null and g.item_count > 0
|
||||||
order by g.min_unit_price_cents nulls last limit 12`
|
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
|
const history = dItems.length
|
||||||
? await s<
|
? 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[])
|
from groceries.item_prices where item_id = any(${dItems.map((i) => i.id)}::int[])
|
||||||
order by observed_on desc limit 400`
|
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,
|
stores,
|
||||||
groups,
|
groups,
|
||||||
items,
|
items,
|
||||||
|
manual,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
filters: {
|
filters: {
|
||||||
q: p.get('q') ?? '',
|
q: p.get('q') ?? '',
|
||||||
store: store?.slug ?? '',
|
store: store?.slug ?? '',
|
||||||
category,
|
category: cat?.slug ?? '',
|
||||||
|
sort,
|
||||||
favorites,
|
favorites,
|
||||||
promo,
|
promo,
|
||||||
multi,
|
multi,
|
||||||
nonfood
|
nonfood
|
||||||
},
|
},
|
||||||
categories: catList,
|
categories,
|
||||||
|
manualFreshDays: MANUAL_FRESH_DAYS,
|
||||||
stats,
|
stats,
|
||||||
runs,
|
runs,
|
||||||
detail,
|
detail,
|
||||||
@@ -212,7 +308,100 @@ const id = (v: FormDataEntryValue | null) => {
|
|||||||
return Number.isInteger(n) && n > 0 ? n : 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 = {
|
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) => {
|
favorite: async (event) => {
|
||||||
requireOwner(event);
|
requireOwner(event);
|
||||||
const form = await event.request.formData();
|
const form = await event.request.formData();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { Sql } from 'postgres';
|
import type { Sql } from 'postgres';
|
||||||
import type { RequestHandler } from './$types';
|
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) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
requireOwner(event);
|
requireOwner(event);
|
||||||
const q = fold(event.url.searchParams.get('q')?.trim() ?? '').slice(0, 100);
|
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 s = db.$client as unknown as Sql;
|
||||||
const conds = words.map((w) => s`search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
|
const conds = words.map((w) => s`search_text like ${'%' + w.replace(/[%_\\]/g, '\\$&') + '%'}`);
|
||||||
const rows = await s`
|
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
|
from groceries.groups
|
||||||
where merged_into_id is null and ${conds.reduce((a, c) => s`${a} and ${c}`)}
|
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
|
order by public.word_similarity(${q}::text, lower(name)) desc, store_count desc, item_count desc, id
|
||||||
|
|||||||
Reference in New Issue
Block a user