feat: add real-time collaborative shopping list at /cospend/list

Real-time shopping list with SSE sync between multiple clients, automatic
item categorization using embedding-based classification + Bring icon
matching, and card-based UI with category grouping.

- SSE broadcast for live sync (add/check/remove items across tabs)
- Hybrid categorizer: direct catalog lookup → category-scoped embedding
  search → per-category default icons, with DB caching
- 388 Bring catalog icons matched via multilingual-e5-base embeddings
- 170+ English→German icon aliases for reliable cross-language matching
- Move cospend dashboard to /cospend/dash, /cospend redirects to list
- Shopping icon on homepage links to /cospend/list
This commit is contained in:
2026-04-07 23:50:50 +02:00
parent 2b85d3f2a1
commit 5e161c4b0c
28 changed files with 2281 additions and 49 deletions
@@ -76,7 +76,7 @@
function closeModal() {
// Use shallow routing to go back to dashboard without full navigation
goto('/cospend', { replaceState: true, noScroll: true, keepFocus: true });
goto('/cospend/dash', { replaceState: true, noScroll: true, keepFocus: true });
onclose?.();
}
File diff suppressed because one or more lines are too long
+329
View File
@@ -0,0 +1,329 @@
/**
* Representative items for each shopping category.
* Used to pre-compute embeddings for semantic category matching.
* Includes both German and English names for multilingual support.
*/
export const SHOPPING_CATEGORIES = [
'Obst & Gemüse',
'Fleisch & Fisch',
'Milchprodukte',
'Brot & Backwaren',
'Pasta, Reis & Getreide',
'Gewürze & Saucen',
'Getränke',
'Süßes & Snacks',
'Tiefkühl',
'Haushalt',
'Hygiene & Körperpflege',
'Sonstiges',
] as const;
export type ShoppingCategory = (typeof SHOPPING_CATEGORIES)[number];
export interface CategoryItem {
name: string;
category: ShoppingCategory;
}
export const CATEGORY_ITEMS: CategoryItem[] = [
// ── Obst & Gemüse ──
{ name: 'Äpfel', category: 'Obst & Gemüse' },
{ name: 'Bananen', category: 'Obst & Gemüse' },
{ name: 'Orangen', category: 'Obst & Gemüse' },
{ name: 'Zitronen', category: 'Obst & Gemüse' },
{ name: 'Erdbeeren', category: 'Obst & Gemüse' },
{ name: 'Trauben', category: 'Obst & Gemüse' },
{ name: 'Birnen', category: 'Obst & Gemüse' },
{ name: 'Wassermelone', category: 'Obst & Gemüse' },
{ name: 'Ananas', category: 'Obst & Gemüse' },
{ name: 'Mango', category: 'Obst & Gemüse' },
{ name: 'Kiwi', category: 'Obst & Gemüse' },
{ name: 'Blaubeeren', category: 'Obst & Gemüse' },
{ name: 'Himbeeren', category: 'Obst & Gemüse' },
{ name: 'Tomaten', category: 'Obst & Gemüse' },
{ name: 'Gurke', category: 'Obst & Gemüse' },
{ name: 'Paprika', category: 'Obst & Gemüse' },
{ name: 'Kartoffeln', category: 'Obst & Gemüse' },
{ name: 'Zwiebeln', category: 'Obst & Gemüse' },
{ name: 'Knoblauch', category: 'Obst & Gemüse' },
{ name: 'Karotten', category: 'Obst & Gemüse' },
{ name: 'Brokkoli', category: 'Obst & Gemüse' },
{ name: 'Blumenkohl', category: 'Obst & Gemüse' },
{ name: 'Zucchini', category: 'Obst & Gemüse' },
{ name: 'Spinat', category: 'Obst & Gemüse' },
{ name: 'Salat', category: 'Obst & Gemüse' },
{ name: 'Pilze', category: 'Obst & Gemüse' },
{ name: 'Champignons', category: 'Obst & Gemüse' },
{ name: 'Avocado', category: 'Obst & Gemüse' },
{ name: 'Lauch', category: 'Obst & Gemüse' },
{ name: 'Sellerie', category: 'Obst & Gemüse' },
{ name: 'Radieschen', category: 'Obst & Gemüse' },
{ name: 'Aubergine', category: 'Obst & Gemüse' },
{ name: 'apples', category: 'Obst & Gemüse' },
{ name: 'bananas', category: 'Obst & Gemüse' },
{ name: 'tomatoes', category: 'Obst & Gemüse' },
{ name: 'potatoes', category: 'Obst & Gemüse' },
{ name: 'lettuce', category: 'Obst & Gemüse' },
{ name: 'carrots', category: 'Obst & Gemüse' },
{ name: 'onions', category: 'Obst & Gemüse' },
{ name: 'broccoli', category: 'Obst & Gemüse' },
// ── Fleisch & Fisch ──
{ name: 'Hähnchenbrust', category: 'Fleisch & Fisch' },
{ name: 'Hackfleisch', category: 'Fleisch & Fisch' },
{ name: 'Rindfleisch', category: 'Fleisch & Fisch' },
{ name: 'Schweinefleisch', category: 'Fleisch & Fisch' },
{ name: 'Lachs', category: 'Fleisch & Fisch' },
{ name: 'Thunfisch', category: 'Fleisch & Fisch' },
{ name: 'Garnelen', category: 'Fleisch & Fisch' },
{ name: 'Schinken', category: 'Fleisch & Fisch' },
{ name: 'Salami', category: 'Fleisch & Fisch' },
{ name: 'Würstchen', category: 'Fleisch & Fisch' },
{ name: 'Bratwurst', category: 'Fleisch & Fisch' },
{ name: 'Putenbrust', category: 'Fleisch & Fisch' },
{ name: 'Speck', category: 'Fleisch & Fisch' },
{ name: 'Forelle', category: 'Fleisch & Fisch' },
{ name: 'Kabeljau', category: 'Fleisch & Fisch' },
{ name: 'chicken breast', category: 'Fleisch & Fisch' },
{ name: 'ground beef', category: 'Fleisch & Fisch' },
{ name: 'salmon', category: 'Fleisch & Fisch' },
{ name: 'bacon', category: 'Fleisch & Fisch' },
{ name: 'sausages', category: 'Fleisch & Fisch' },
// ── Milchprodukte ──
{ name: 'Milch', category: 'Milchprodukte' },
{ name: 'Butter', category: 'Milchprodukte' },
{ name: 'Käse', category: 'Milchprodukte' },
{ name: 'Joghurt', category: 'Milchprodukte' },
{ name: 'Sahne', category: 'Milchprodukte' },
{ name: 'Quark', category: 'Milchprodukte' },
{ name: 'Frischkäse', category: 'Milchprodukte' },
{ name: 'Gouda', category: 'Milchprodukte' },
{ name: 'Mozzarella', category: 'Milchprodukte' },
{ name: 'Parmesan', category: 'Milchprodukte' },
{ name: 'Emmentaler', category: 'Milchprodukte' },
{ name: 'Gruyère', category: 'Milchprodukte' },
{ name: 'Gruyere', category: 'Milchprodukte' },
{ name: 'Appenzeller', category: 'Milchprodukte' },
{ name: 'Tilsiter', category: 'Milchprodukte' },
{ name: 'Edamer', category: 'Milchprodukte' },
{ name: 'Brie', category: 'Milchprodukte' },
{ name: 'Camembert', category: 'Milchprodukte' },
{ name: 'Ricotta', category: 'Milchprodukte' },
{ name: 'Mascarpone', category: 'Milchprodukte' },
{ name: 'Hüttenkäse', category: 'Milchprodukte' },
{ name: 'Raclettekäse', category: 'Milchprodukte' },
{ name: 'Reibkäse', category: 'Milchprodukte' },
{ name: 'Eier', category: 'Milchprodukte' },
{ name: 'Schmand', category: 'Milchprodukte' },
{ name: 'Skyr', category: 'Milchprodukte' },
{ name: 'Crème fraîche', category: 'Milchprodukte' },
{ name: 'Schlagsahne', category: 'Milchprodukte' },
{ name: 'milk', category: 'Milchprodukte' },
{ name: 'cheese', category: 'Milchprodukte' },
{ name: 'yogurt', category: 'Milchprodukte' },
{ name: 'eggs', category: 'Milchprodukte' },
{ name: 'cream', category: 'Milchprodukte' },
// ── Brot & Backwaren ──
{ name: 'Brot', category: 'Brot & Backwaren' },
{ name: 'Brötchen', category: 'Brot & Backwaren' },
{ name: 'Toast', category: 'Brot & Backwaren' },
{ name: 'Vollkornbrot', category: 'Brot & Backwaren' },
{ name: 'Baguette', category: 'Brot & Backwaren' },
{ name: 'Croissant', category: 'Brot & Backwaren' },
{ name: 'Mehl', category: 'Brot & Backwaren' },
{ name: 'Weissmehl', category: 'Brot & Backwaren' },
{ name: 'Weißmehl', category: 'Brot & Backwaren' },
{ name: 'Vollkornmehl', category: 'Brot & Backwaren' },
{ name: 'Dinkelmehl', category: 'Brot & Backwaren' },
{ name: 'Roggenmehl', category: 'Brot & Backwaren' },
{ name: 'Stärke', category: 'Brot & Backwaren' },
{ name: 'Speisestärke', category: 'Brot & Backwaren' },
{ name: 'Maismehl', category: 'Brot & Backwaren' },
{ name: 'Hefe', category: 'Brot & Backwaren' },
{ name: 'Backpulver', category: 'Brot & Backwaren' },
{ name: 'Zucker', category: 'Brot & Backwaren' },
{ name: 'Vanillezucker', category: 'Brot & Backwaren' },
{ name: 'Puderzucker', category: 'Brot & Backwaren' },
{ name: 'bread', category: 'Brot & Backwaren' },
{ name: 'flour', category: 'Brot & Backwaren' },
{ name: 'sugar', category: 'Brot & Backwaren' },
{ name: 'baking powder', category: 'Brot & Backwaren' },
{ name: 'yeast', category: 'Brot & Backwaren' },
// ── Pasta, Reis & Getreide ──
{ name: 'Spaghetti', category: 'Pasta, Reis & Getreide' },
{ name: 'Penne', category: 'Pasta, Reis & Getreide' },
{ name: 'Rigatoni', category: 'Pasta, Reis & Getreide' },
{ name: 'Fusilli', category: 'Pasta, Reis & Getreide' },
{ name: 'Farfalle', category: 'Pasta, Reis & Getreide' },
{ name: 'Tagliatelle', category: 'Pasta, Reis & Getreide' },
{ name: 'Linguine', category: 'Pasta, Reis & Getreide' },
{ name: 'Lasagneblätter', category: 'Pasta, Reis & Getreide' },
{ name: 'Gnocchi', category: 'Pasta, Reis & Getreide' },
{ name: 'Nudeln', category: 'Pasta, Reis & Getreide' },
{ name: 'Reis', category: 'Pasta, Reis & Getreide' },
{ name: 'Basmati', category: 'Pasta, Reis & Getreide' },
{ name: 'Couscous', category: 'Pasta, Reis & Getreide' },
{ name: 'Haferflocken', category: 'Pasta, Reis & Getreide' },
{ name: 'Müsli', category: 'Pasta, Reis & Getreide' },
{ name: 'Cornflakes', category: 'Pasta, Reis & Getreide' },
{ name: 'Linsen', category: 'Pasta, Reis & Getreide' },
{ name: 'Kichererbsen', category: 'Pasta, Reis & Getreide' },
{ name: 'Bohnen', category: 'Pasta, Reis & Getreide' },
{ name: 'Tortellini', category: 'Pasta, Reis & Getreide' },
{ name: 'Quinoa', category: 'Pasta, Reis & Getreide' },
{ name: 'pasta', category: 'Pasta, Reis & Getreide' },
{ name: 'rice', category: 'Pasta, Reis & Getreide' },
{ name: 'oats', category: 'Pasta, Reis & Getreide' },
{ name: 'lentils', category: 'Pasta, Reis & Getreide' },
// ── Gewürze & Saucen ──
{ name: 'Salz', category: 'Gewürze & Saucen' },
{ name: 'Pfeffer', category: 'Gewürze & Saucen' },
{ name: 'Olivenöl', category: 'Gewürze & Saucen' },
{ name: 'Sonnenblumenöl', category: 'Gewürze & Saucen' },
{ name: 'Essig', category: 'Gewürze & Saucen' },
{ name: 'Ketchup', category: 'Gewürze & Saucen' },
{ name: 'Senf', category: 'Gewürze & Saucen' },
{ name: 'Sojasauce', category: 'Gewürze & Saucen' },
{ name: 'Tomatenmark', category: 'Gewürze & Saucen' },
{ name: 'Passierte Tomaten', category: 'Gewürze & Saucen' },
{ name: 'Mayonnaise', category: 'Gewürze & Saucen' },
{ name: 'Paprikapulver', category: 'Gewürze & Saucen' },
{ name: 'Zimt', category: 'Gewürze & Saucen' },
{ name: 'Oregano', category: 'Gewürze & Saucen' },
{ name: 'Basilikum', category: 'Gewürze & Saucen' },
{ name: 'Currypulver', category: 'Gewürze & Saucen' },
{ name: 'Honig', category: 'Gewürze & Saucen' },
{ name: 'olive oil', category: 'Gewürze & Saucen' },
{ name: 'salt', category: 'Gewürze & Saucen' },
{ name: 'pepper', category: 'Gewürze & Saucen' },
{ name: 'ketchup', category: 'Gewürze & Saucen' },
{ name: 'mustard', category: 'Gewürze & Saucen' },
{ name: 'soy sauce', category: 'Gewürze & Saucen' },
// ── Getränke ──
{ name: 'Wasser', category: 'Getränke' },
{ name: 'Mineralwasser', category: 'Getränke' },
{ name: 'Sprudel', category: 'Getränke' },
{ name: 'Apfelsaft', category: 'Getränke' },
{ name: 'Orangensaft', category: 'Getränke' },
{ name: 'Cola', category: 'Getränke' },
{ name: 'Bier', category: 'Getränke' },
{ name: 'Wein', category: 'Getränke' },
{ name: 'Kaffee', category: 'Getränke' },
{ name: 'Tee', category: 'Getränke' },
{ name: 'Limonade', category: 'Getränke' },
{ name: 'Milch (Hafer)', category: 'Getränke' },
{ name: 'Hafermilch', category: 'Getränke' },
{ name: 'Saft', category: 'Getränke' },
{ name: 'water', category: 'Getränke' },
{ name: 'juice', category: 'Getränke' },
{ name: 'coffee', category: 'Getränke' },
{ name: 'tea', category: 'Getränke' },
{ name: 'beer', category: 'Getränke' },
{ name: 'wine', category: 'Getränke' },
// ── Süßes & Snacks ──
{ name: 'Schokolade', category: 'Süßes & Snacks' },
{ name: 'Chips', category: 'Süßes & Snacks' },
{ name: 'Gummibärchen', category: 'Süßes & Snacks' },
{ name: 'Kekse', category: 'Süßes & Snacks' },
{ name: 'Eis', category: 'Süßes & Snacks' },
{ name: 'Nüsse', category: 'Süßes & Snacks' },
{ name: 'Erdnüsse', category: 'Süßes & Snacks' },
{ name: 'Mandeln', category: 'Süßes & Snacks' },
{ name: 'Kuchen', category: 'Süßes & Snacks' },
{ name: 'Bonbons', category: 'Süßes & Snacks' },
{ name: 'Müsliriegel', category: 'Süßes & Snacks' },
{ name: 'Popcorn', category: 'Süßes & Snacks' },
{ name: 'Salzstangen', category: 'Süßes & Snacks' },
{ name: 'Snickers', category: 'Süßes & Snacks' },
{ name: 'Mars', category: 'Süßes & Snacks' },
{ name: 'Twix', category: 'Süßes & Snacks' },
{ name: 'Haribo', category: 'Süßes & Snacks' },
{ name: 'Milka', category: 'Süßes & Snacks' },
{ name: 'Oreo', category: 'Süßes & Snacks' },
{ name: 'chocolate', category: 'Süßes & Snacks' },
{ name: 'chips', category: 'Süßes & Snacks' },
{ name: 'cookies', category: 'Süßes & Snacks' },
{ name: 'ice cream', category: 'Süßes & Snacks' },
{ name: 'nuts', category: 'Süßes & Snacks' },
// ── Tiefkühl ──
{ name: 'Tiefkühlpizza', category: 'Tiefkühl' },
{ name: 'Tiefkühlgemüse', category: 'Tiefkühl' },
{ name: 'Fischstäbchen', category: 'Tiefkühl' },
{ name: 'Pommes', category: 'Tiefkühl' },
{ name: 'Tiefkühlbeeren', category: 'Tiefkühl' },
{ name: 'Tiefkühltorte', category: 'Tiefkühl' },
{ name: 'Tiefkühlspinat', category: 'Tiefkühl' },
{ name: 'frozen pizza', category: 'Tiefkühl' },
{ name: 'frozen vegetables', category: 'Tiefkühl' },
{ name: 'fish sticks', category: 'Tiefkühl' },
{ name: 'french fries', category: 'Tiefkühl' },
// ── Haushalt ──
{ name: 'Spülmittel', category: 'Haushalt' },
{ name: 'Waschmittel', category: 'Haushalt' },
{ name: 'Müllbeutel', category: 'Haushalt' },
{ name: 'Küchenrolle', category: 'Haushalt' },
{ name: 'Toilettenpapier', category: 'Haushalt' },
{ name: 'Schwamm', category: 'Haushalt' },
{ name: 'Alufolie', category: 'Haushalt' },
{ name: 'Frischhaltefolie', category: 'Haushalt' },
{ name: 'Spülmaschinentabs', category: 'Haushalt' },
{ name: 'Allzweckreiniger', category: 'Haushalt' },
{ name: 'Kerzen', category: 'Haushalt' },
{ name: 'Batterien', category: 'Haushalt' },
{ name: 'Glühbirne', category: 'Haushalt' },
{ name: 'Backpapier', category: 'Haushalt' },
{ name: 'Zürisäcke', category: 'Haushalt' },
{ name: 'Züribags', category: 'Haushalt' },
{ name: 'Kehrichtsäcke', category: 'Haushalt' },
{ name: 'dish soap', category: 'Haushalt' },
{ name: 'detergent', category: 'Haushalt' },
{ name: 'trash bags', category: 'Haushalt' },
{ name: 'paper towels', category: 'Haushalt' },
{ name: 'toilet paper', category: 'Haushalt' },
{ name: 'aluminum foil', category: 'Haushalt' },
{ name: 'batteries', category: 'Haushalt' },
// ── Hygiene & Körperpflege ──
{ name: 'Zahnpasta', category: 'Hygiene & Körperpflege' },
{ name: 'Zahnbürste', category: 'Hygiene & Körperpflege' },
{ name: 'Duschgel', category: 'Hygiene & Körperpflege' },
{ name: 'Shampoo', category: 'Hygiene & Körperpflege' },
{ name: 'Deodorant', category: 'Hygiene & Körperpflege' },
{ name: 'Rasierer', category: 'Hygiene & Körperpflege' },
{ name: 'Sonnencreme', category: 'Hygiene & Körperpflege' },
{ name: 'Handcreme', category: 'Hygiene & Körperpflege' },
{ name: 'Seife', category: 'Hygiene & Körperpflege' },
{ name: 'Taschentücher', category: 'Hygiene & Körperpflege' },
{ name: 'Pflaster', category: 'Hygiene & Körperpflege' },
{ name: 'Wattepads', category: 'Hygiene & Körperpflege' },
{ name: 'Binden', category: 'Hygiene & Körperpflege' },
{ name: 'Tampons', category: 'Hygiene & Körperpflege' },
{ name: 'Slipeinlagen', category: 'Hygiene & Körperpflege' },
{ name: 'Pads', category: 'Hygiene & Körperpflege' },
{ name: 'toothpaste', category: 'Hygiene & Körperpflege' },
{ name: 'shampoo', category: 'Hygiene & Körperpflege' },
{ name: 'soap', category: 'Hygiene & Körperpflege' },
{ name: 'deodorant', category: 'Hygiene & Körperpflege' },
{ name: 'sunscreen', category: 'Hygiene & Körperpflege' },
// ── Sonstiges ──
{ name: 'Blumen', category: 'Sonstiges' },
{ name: 'Zeitung', category: 'Sonstiges' },
{ name: 'Briefmarken', category: 'Sonstiges' },
{ name: 'Geschenkpapier', category: 'Sonstiges' },
{ name: 'Klebeband', category: 'Sonstiges' },
{ name: 'Tiernahrung', category: 'Sonstiges' },
{ name: 'Katzenfutter', category: 'Sonstiges' },
{ name: 'Hundefutter', category: 'Sonstiges' },
{ name: 'flowers', category: 'Sonstiges' },
{ name: 'pet food', category: 'Sonstiges' },
];
+390
View File
@@ -0,0 +1,390 @@
{
"passionsfrucht": "Fleisch & Fisch",
"getreideriegel": "Haushalt",
"glasreiniger": "Haushalt",
"gartenwerkzeug": "Obst & Gemüse",
"müesli": "Pasta, Reis & Getreide",
"pinienkerne": "Obst & Gemüse",
"creme fraiche": "Milchprodukte",
"hackfleisch": "Fleisch & Fisch",
"kekse": "Süßes & Snacks",
"salami": "Fleisch & Fisch",
"lippenpomade": "Getränke",
"putzmittel": "Haushalt",
"samen": "Hygiene & Körperpflege",
"wassermelone": "Obst & Gemüse",
"schokolade": "Süßes & Snacks",
"käse": "Milchprodukte",
"giesskanne": "Milchprodukte",
"bratwurst": "Fleisch & Fisch",
"garnelen": "Fleisch & Fisch",
"fenchel": "Brot & Backwaren",
"fruchtsaft": "Getränke",
"raclette": "Milchprodukte",
"brokkoli": "Obst & Gemüse",
"eistee": "Süßes & Snacks",
"haarspray": "Haushalt",
"pflaumen": "Hygiene & Körperpflege",
"pommes chips": "Tiefkühl",
"schweinefleisch": "Fleisch & Fisch",
"backpapier": "Haushalt",
"brot": "Brot & Backwaren",
"orangensaft": "Getränke",
"geschirrsalz": "Gewürze & Saucen",
"gipfeli": "Gewürze & Saucen",
"strohhalme": "Haushalt",
"birnen": "Obst & Gemüse",
"italian to go": "Pasta, Reis & Getreide",
"eier": "Milchprodukte",
"makeup entferner": "Milchprodukte",
"kartoffeln": "Obst & Gemüse",
"rasierklingen": "Hygiene & Körperpflege",
"kaffee": "Getränke",
"kohlrabi": "Obst & Gemüse",
"frischkäse": "Milchprodukte",
"essiggurken": "Gewürze & Saucen",
"öl": "Gewürze & Saucen",
"gelee": "Süßes & Snacks",
"trauben": "Obst & Gemüse",
"salz": "Gewürze & Saucen",
"glühbirne": "Haushalt",
"balsamico": "Gewürze & Saucen",
"fisch": "Fleisch & Fisch",
"geschenk": "Sonstiges",
"blumen": "Obst & Gemüse",
"kartoffelstock": "Obst & Gemüse",
"limonade": "Getränke",
"schwamm": "Haushalt",
"ahornsirup": "Gewürze & Saucen",
"limette": "Getränke",
"aubergine": "Obst & Gemüse",
"mettigel": "Süßes & Snacks",
"nüsse": "Süßes & Snacks",
"schinken": "Fleisch & Fisch",
"dip": "Haushalt",
"zucchetti": "Obst & Gemüse",
"suppe": "Hygiene & Körperpflege",
"rum": "Milchprodukte",
"frühlingszwiebeln": "Obst & Gemüse",
"spargel": "Getränke",
"sonnencreme": "Hygiene & Körperpflege",
"gnocchi": "Pasta, Reis & Getreide",
"handcreme": "Hygiene & Körperpflege",
"schnittlauch": "Obst & Gemüse",
"rote bete": "Brot & Backwaren",
"pelati": "Pasta, Reis & Getreide",
"fischstäbli": "Tiefkühl",
"margarine": "Milchprodukte",
"bbq sauce": "Gewürze & Saucen",
"zigaretten": "Gewürze & Saucen",
"muscheln": "Süßes & Snacks",
"oregano": "Gewürze & Saucen",
"basmatireis": "Pasta, Reis & Getreide",
"zahnseide": "Süßes & Snacks",
"tofu": "Pasta, Reis & Getreide",
"energy drink": "Süßes & Snacks",
"peperoni": "Gewürze & Saucen",
"sirup": "Haushalt",
"feigen": "Gewürze & Saucen",
"haselnüsse": "Süßes & Snacks",
"mehl": "Brot & Backwaren",
"haferflocken": "Pasta, Reis & Getreide",
"kokosmilch": "Getränke",
"apfelmus": "Getränke",
"reis": "Pasta, Reis & Getreide",
"mascarpone": "Milchprodukte",
"rasenmäher": "Hygiene & Körperpflege",
"schnitzel": "Fleisch & Fisch",
"chinese to go": "Hygiene & Körperpflege",
"grill": "Fleisch & Fisch",
"ketchup": "Gewürze & Saucen",
"lachs": "Fleisch & Fisch",
"zwiebeln": "Obst & Gemüse",
"beeren": "Obst & Gemüse",
"pflaster": "Hygiene & Körperpflege",
"fischfutter": "Tiefkühl",
"kerzen": "Haushalt",
"waffeln": "Obst & Gemüse",
"vanille sauce": "Brot & Backwaren",
"kalbfleisch": "Fleisch & Fisch",
"smoothie": "Getränke",
"rasierschaum": "Hygiene & Körperpflege",
"ingwer": "Milchprodukte",
"hüttenkäse": "Milchprodukte",
"pfirsich": "Gewürze & Saucen",
"sauerrahm": "Haushalt",
"lasagne": "Pasta, Reis & Getreide",
"pinsel": "Pasta, Reis & Getreide",
"hefe": "Brot & Backwaren",
"kuchen": "Süßes & Snacks",
"prosecco": "Milchprodukte",
"tampons": "Hygiene & Körperpflege",
"thunfisch": "Fleisch & Fisch",
"zucker": "Brot & Backwaren",
"chicken wings": "Fleisch & Fisch",
"pouletbrüstli": "Fleisch & Fisch",
"blumenkohl": "Obst & Gemüse",
"speisestärke": "Hygiene & Körperpflege",
"salat": "Obst & Gemüse",
"brezeln": "Pasta, Reis & Getreide",
"corn flakes": "Pasta, Reis & Getreide",
"muffins": "Tiefkühl",
"knoblauch": "Obst & Gemüse",
"karotten": "Obst & Gemüse",
"toast": "Brot & Backwaren",
"waschmittel": "Haushalt",
"salatsauce": "Gewürze & Saucen",
"hundefutter": "Sonstiges",
"soya milch": "Milchprodukte",
"vanillezucker": "Brot & Backwaren",
"mundspülung": "Haushalt",
"babynahrung": "Sonstiges",
"windeln": "Süßes & Snacks",
"kondome": "Hygiene & Körperpflege",
"couscous": "Pasta, Reis & Getreide",
"geschirrglanz": "Hygiene & Körperpflege",
"aprikosen": "Tiefkühl",
"himbeeren": "Obst & Gemüse",
"indian to go": "Hygiene & Körperpflege",
"oliven": "Gewürze & Saucen",
"lebkuchen": "Tiefkühl",
"kürbis": "Milchprodukte",
"sportgetränk": "Tiefkühl",
"tonic water": "Getränke",
"nektarine": "Obst & Gemüse",
"penne": "Pasta, Reis & Getreide",
"shampoo": "Hygiene & Körperpflege",
"whisky": "Getränke",
"datteln": "Pasta, Reis & Getreide",
"fondue": "Tiefkühl",
"kakao": "Süßes & Snacks",
"olivenöl": "Gewürze & Saucen",
"bohnen": "Pasta, Reis & Getreide",
"pizza": "Tiefkühl",
"kiwi": "Obst & Gemüse",
"poulet": "Fleisch & Fisch",
"wasser": "Getränke",
"milch": "Milchprodukte",
"kirschen": "Haushalt",
"mandeln": "Süßes & Snacks",
"kichererbsen": "Pasta, Reis & Getreide",
"kosmetiktücher": "Hygiene & Körperpflege",
"kaugummi": "Fleisch & Fisch",
"gesichtscreme": "Hygiene & Körperpflege",
"süsskartoffeln": "Obst & Gemüse",
"getrocknete tomaten": "Obst & Gemüse",
"koriander": "Obst & Gemüse",
"knäckebrot": "Brot & Backwaren",
"champignons": "Obst & Gemüse",
"gemüse gefroren": "Tiefkühl",
"cola light": "Getränke",
"orange": "Obst & Gemüse",
"dessert": "Süßes & Snacks",
"alufolie": "Haushalt",
"tortilla chips": "Süßes & Snacks",
"melone": "Obst & Gemüse",
"bananen": "Obst & Gemüse",
"preiselbeer sauce": "Tiefkühl",
"zahnbürsten": "Fleisch & Fisch",
"zimt": "Gewürze & Saucen",
"äpfel": "Obst & Gemüse",
"cola": "Getränke",
"bouillon": "Brot & Backwaren",
"knödel": "Pasta, Reis & Getreide",
"salbei": "Fleisch & Fisch",
"radieschen": "Obst & Gemüse",
"soyasauce": "Gewürze & Saucen",
"rohschinken": "Fleisch & Fisch",
"reibkäse": "Milchprodukte",
"aufschnitt": "Hygiene & Körperpflege",
"geschirrtabs": "Haushalt",
"sonnenschirm": "Hygiene & Körperpflege",
"mineralwasser": "Getränke",
"taschentücher": "Hygiene & Körperpflege",
"feuchttücher": "Hygiene & Körperpflege",
"erbsen": "Pasta, Reis & Getreide",
"parmesan": "Milchprodukte",
"nougatcreme": "Hygiene & Körperpflege",
"speck": "Fleisch & Fisch",
"avocado": "Obst & Gemüse",
"quark": "Milchprodukte",
"paprikapulver": "Gewürze & Saucen",
"torte": "Getränke",
"abfallsäcke": "Getränke",
"essig": "Gewürze & Saucen",
"dünger": "Brot & Backwaren",
"pilze": "Obst & Gemüse",
"batterien": "Haushalt",
"tomatensauce": "Gewürze & Saucen",
"rucola": "Süßes & Snacks",
"bier": "Getränke",
"blumenerde": "Obst & Gemüse",
"rhabarber": "Hygiene & Körperpflege",
"artischocken": "Obst & Gemüse",
"rosmarin": "Getränke",
"salzstangen": "Süßes & Snacks",
"linsenmittel": "Pasta, Reis & Getreide",
"nagellackentferner": "Milchprodukte",
"bodylotion": "Hygiene & Körperpflege",
"apfelsaft": "Getränke",
"pudding": "Brot & Backwaren",
"vitamine": "Gewürze & Saucen",
"thai to go": "Gewürze & Saucen",
"guetzli": "Pasta, Reis & Getreide",
"binden": "Hygiene & Körperpflege",
"tomatenmark": "Gewürze & Saucen",
"gurke": "Obst & Gemüse",
"holzkohle": "Obst & Gemüse",
"basilikum": "Gewürze & Saucen",
"joghurt": "Milchprodukte",
"pop corn": "Süßes & Snacks",
"weichspüler": "Haushalt",
"butter": "Milchprodukte",
"dörrobst": "Fleisch & Fisch",
"rotwein": "Getränke",
"frankfurter": "Tiefkühl",
"schnaps": "Fleisch & Fisch",
"tomaten": "Obst & Gemüse",
"ricotta": "Milchprodukte",
"watterondellen": "Hygiene & Körperpflege",
"erdbeeren": "Obst & Gemüse",
"vogelfutter": "Sonstiges",
"thymian": "Obst & Gemüse",
"katzensnack": "Sonstiges",
"puderzucker": "Brot & Backwaren",
"kräuterbutter": "Brot & Backwaren",
"kaki": "Süßes & Snacks",
"insektenschutzmittel": "Sonstiges",
"erdnüsse": "Süßes & Snacks",
"pfefferkörner": "Gewürze & Saucen",
"schrauben": "Obst & Gemüse",
"sardellen": "Süßes & Snacks",
"rindfleisch": "Fleisch & Fisch",
"conditioner": "Haushalt",
"pizzateig": "Tiefkühl",
"blauschimmelkäse": "Milchprodukte",
"zitrone": "Obst & Gemüse",
"nägel": "Hygiene & Körperpflege",
"peperoncini": "Pasta, Reis & Getreide",
"senf": "Gewürze & Saucen",
"brötchen": "Brot & Backwaren",
"baumnüsse": "Süßes & Snacks",
"nudeln": "Pasta, Reis & Getreide",
"wurst": "Fleisch & Fisch",
"griess": "Milchprodukte",
"mandarinen": "Obst & Gemüse",
"weisswein": "Getränke",
"blätterteig": "Pasta, Reis & Getreide",
"zahnstocher": "Brot & Backwaren",
"cherrytomaten": "Obst & Gemüse",
"pfefferminze": "Gewürze & Saucen",
"katzenstreu": "Sonstiges",
"kohl": "Obst & Gemüse",
"brombeeren": "Obst & Gemüse",
"feta": "Gewürze & Saucen",
"gin": "Getränke",
"vodka": "Getränke",
"honig": "Gewürze & Saucen",
"wc-papier": "Haushalt",
"paniermehl": "Brot & Backwaren",
"rahm": "Haushalt",
"mayonnaise": "Gewürze & Saucen",
"spülmittel": "Haushalt",
"sellerie": "Obst & Gemüse",
"lauch": "Obst & Gemüse",
"light limonade": "Getränke",
"rindsgeschnetzeltes": "Tiefkühl",
"wc-reiniger": "Haushalt",
"baguette": "Brot & Backwaren",
"konfitüre": "Sonstiges",
"schmerzmittel": "Hygiene & Körperpflege",
"badreiniger": "Haushalt",
"heidelbeeren": "Tiefkühl",
"mango": "Obst & Gemüse",
"mozzarella": "Milchprodukte",
"ananas": "Obst & Gemüse",
"propangas": "Hygiene & Körperpflege",
"streichhölzer": "Tiefkühl",
"pasta sauce": "Pasta, Reis & Getreide",
"bratensauce": "Fleisch & Fisch",
"lamm": "Fleisch & Fisch",
"frischhaltefolie": "Haushalt",
"zahnpasta": "Hygiene & Körperpflege",
"spaghetti": "Pasta, Reis & Getreide",
"haargel": "Hygiene & Körperpflege",
"snacks": "Sonstiges",
"petersilie": "Gewürze & Saucen",
"grapefruit": "Obst & Gemüse",
"servietten": "Obst & Gemüse",
"töpfe": "Haushalt",
"linsen": "Pasta, Reis & Getreide",
"lattich": "Fleisch & Fisch",
"duschmittel": "Haushalt",
"gorgonzola": "Milchprodukte",
"spinat": "Obst & Gemüse",
"steak": "Fleisch & Fisch",
"hundesnack": "Sonstiges",
"backpulver": "Brot & Backwaren",
"risottoreis": "Pasta, Reis & Getreide",
"rasierer": "Hygiene & Körperpflege",
"pommes frites": "Tiefkühl",
"deo": "Hygiene & Körperpflege",
"pflanzen": "Haushalt",
"katzenfutter": "Sonstiges",
"geschenkpapier": "Sonstiges",
"champagner": "Milchprodukte",
"nagellack": "Hygiene & Körperpflege",
"tee": "Getränke",
"wattestäbchen": "Tiefkühl",
"kräuter": "Süßes & Snacks",
"seife": "Hygiene & Körperpflege",
"glacé": "Milchprodukte",
"mais": "Brot & Backwaren",
"haushaltspapier": "Haushalt",
"süssigkeiten": "Fleisch & Fisch",
"burrata": "Milchprodukte",
"mandelmus": "Süßes & Snacks",
"silberzwiebeln": "Obst & Gemüse",
"müsli": "Pasta, Reis & Getreide",
"lippenpflege": "Hygiene & Körperpflege",
"sämereien": "Obst & Gemüse",
"raclettekäse": "Milchprodukte",
"chips": "Süßes & Snacks",
"croissant": "Brot & Backwaren",
"italienisches essen": "Milchprodukte",
"kartoffelpüree": "Obst & Gemüse",
"mett für igel": "Brot & Backwaren",
"zucchini": "Obst & Gemüse",
"dosentomaten": "Gewürze & Saucen",
"fischstäbchen": "Tiefkühl",
"paprika": "Obst & Gemüse",
"chinesisches essen": "Sonstiges",
"sekt": "Getränke",
"hähnchenbrust": "Fleisch & Fisch",
"sojamilch": "Gewürze & Saucen",
"klarspüler": "Haushalt",
"indisches essen": "Süßes & Snacks",
"fonduekäse": "Milchprodukte",
"hähnchen": "Fleisch & Fisch",
"süßkartoffeln": "Obst & Gemüse",
"brühe": "Brot & Backwaren",
"sojasauce": "Gewürze & Saucen",
"müllsäcke": "Haushalt",
"thai essen": "Süßes & Snacks",
"plätzchen": "Brot & Backwaren",
"wattepads": "Hygiene & Körperpflege",
"haarspülung": "Hygiene & Körperpflege",
"chili": "Gewürze & Saucen",
"walnüsse": "Süßes & Snacks",
"grieß": "Fleisch & Fisch",
"wodka": "Getränke",
"toilettenpapier": "Haushalt",
"semmelbrösel": "Brot & Backwaren",
"sahne": "Milchprodukte",
"rindergeschnetzeltes": "Fleisch & Fisch",
"toilettenreiniger": "Haushalt",
"marmelade": "Milchprodukte",
"duschgel": "Hygiene & Körperpflege",
"eis": "Süßes & Snacks",
"küchenrolle": "Haushalt"
}
File diff suppressed because one or more lines are too long
+221
View File
@@ -0,0 +1,221 @@
/**
* Shopping list sync layer — real-time collaborative shopping list via SSE.
*
* Usage: call `getShoppingSync()` to get the shared singleton.
* Manages SSE connection, debounced pushes, and reactive item state.
*/
type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict';
export interface ShoppingItem {
id: string;
name: string;
category: string;
icon: string | null;
checked: boolean;
addedBy: string;
checkedBy?: string;
addedAt: string;
}
interface ServerList {
version: number;
items: ShoppingItem[];
}
function generateId(): string {
return Math.random().toString(36).substring(2, 10) + Date.now().toString(36);
}
export function createShoppingSync() {
let items: ShoppingItem[] = $state([]);
let status: SyncStatus = $state('idle');
let version = $state(0);
let eventSource: EventSource | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = 1000;
let _applying = false;
async function pushToServer() {
if (_applying) return;
status = 'syncing';
try {
const res = await fetch('/api/cospend/list', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items,
expectedVersion: version || undefined
})
});
if (res.ok) {
const doc = await res.json();
version = doc.version;
status = 'synced';
reconnectDelay = 1000;
} else if (res.status === 409) {
const { list } = await res.json();
applyServerState(list);
await pushToServer();
} else if (res.status === 401) {
status = 'offline';
} else {
status = 'offline';
}
} catch {
status = 'offline';
}
}
function debouncedPush() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => pushToServer(), 200);
}
function applyServerState(doc: ServerList) {
if (!doc) return;
_applying = true;
try {
version = doc.version;
items = doc.items;
status = 'synced';
} finally {
_applying = false;
}
}
function connectSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
try {
eventSource = new EventSource('/api/cospend/list/stream');
eventSource.addEventListener('update', (e) => {
try {
const doc = JSON.parse(e.data);
if (doc.version > version) {
applyServerState(doc);
}
} catch { /* ignore */ }
});
eventSource.onerror = () => {
status = 'offline';
eventSource?.close();
eventSource = null;
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
connectSSE();
}, reconnectDelay);
};
eventSource.onopen = () => {
status = 'synced';
reconnectDelay = 1000;
};
} catch {
status = 'offline';
}
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
status = 'idle';
}
async function init() {
try {
const res = await fetch('/api/cospend/list');
if (!res.ok) {
status = 'offline';
return;
}
const doc = await res.json();
version = doc.version;
items = doc.items || [];
status = 'synced';
connectSSE();
} catch {
status = 'offline';
}
}
function addItem(name: string, user: string, category = 'Sonstiges') {
items = [...items, {
id: generateId(),
name: name.trim(),
category,
icon: null,
checked: false,
addedBy: user,
addedAt: new Date().toISOString()
}];
debouncedPush();
}
function toggleItem(id: string, user: string) {
items = items.map(item =>
item.id === id
? { ...item, checked: !item.checked, checkedBy: !item.checked ? user : undefined }
: item
);
debouncedPush();
}
function removeItem(id: string) {
items = items.filter(item => item.id !== id);
debouncedPush();
}
function clearChecked() {
items = items.filter(item => !item.checked);
debouncedPush();
}
function updateItemCategory(id: string, category: string, icon?: string | null) {
items = items.map(item =>
item.id === id ? { ...item, category, ...(icon !== undefined ? { icon } : {}) } : item
);
debouncedPush();
}
return {
get items() { return items; },
get status() { return status; },
get version() { return version; },
init,
addItem,
toggleItem,
removeItem,
clearChecked,
updateItemCategory,
disconnect
};
}
let _instance: ReturnType<typeof createShoppingSync> | null = null;
export function getShoppingSync() {
if (!_instance) {
_instance = createShoppingSync();
}
return _instance;
}
+333
View File
@@ -0,0 +1,333 @@
/**
* Shopping item categorizer — hybrid approach:
* 1. Direct/substring catalog lookup for icon
* 2. Embedding-based category classification (267 representative items)
* 3. Category-scoped embedding search for icon (only icons in matched category)
* 4. Per-category default icon as final fallback
*
* DB cache ensures each unique item is only categorized once.
*/
import { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';
import { read } from '$app/server';
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
import { ShoppingItemCategory } from '$models/ShoppingItemCategory';
import { dbConnect } from '$utils/db';
import catalog from '../../../static/shopping-icons/catalog.json';
import iconCategoriesData from '$lib/data/shoppingIconCategories.json';
import categoryEmbeddingsUrl from '$lib/data/shoppingCategoryEmbeddings.json?url';
import iconEmbeddingsUrl from '$lib/data/shoppingIconEmbeddings.json?url';
const MODEL_NAME = 'Xenova/multilingual-e5-base';
const CATEGORY_THRESHOLD = 0.5;
const ICON_THRESHOLD = 0.83;
/** Fallback icons per category when no specific icon matches */
const CATEGORY_DEFAULT_ICONS: Record<string, string> = {
'Obst & Gemüse': 'doerrobst',
'Fleisch & Fisch': 'hackfleisch',
'Milchprodukte': 'kaese',
'Brot & Backwaren': 'brot',
'Pasta, Reis & Getreide': 'nudeln',
'Gewürze & Saucen': 'kraeuter',
'Getränke': 'wasser',
'Süßes & Snacks': 'schokolade',
'Tiefkühl': 'gemuese_gefroren',
'Haushalt': 'spuelmittel',
'Hygiene & Körperpflege': 'seife',
};
/**
* Aliases: maps common English/alternate names to their German catalog equivalent.
* Checked before embeddings so these are always correct and instant.
* Add entries here when an item consistently matches the wrong icon.
*/
const ICON_ALIASES: Record<string, string> = {
// English → German catalog name
// Fruits & vegetables
'apples': 'äpfel', 'apple': 'äpfel', 'bananas': 'bananen', 'banana': 'bananen',
'oranges': 'orange', 'lemons': 'zitrone', 'lemon': 'zitrone', 'grapes': 'trauben',
'strawberries': 'erdbeeren', 'blueberries': 'heidelbeeren', 'raspberries': 'himbeeren',
'tomatoes': 'tomaten', 'tomato': 'tomaten', 'potatoes': 'kartoffeln', 'potato': 'kartoffeln',
'cucumber': 'gurke', 'onions': 'zwiebeln', 'onion': 'zwiebeln', 'garlic': 'knoblauch',
'carrots': 'karotten', 'carrot': 'karotten', 'broccoli': 'brokkoli',
'spinach': 'spinat', 'lettuce': 'salat', 'mushrooms': 'pilze', 'mushroom': 'champignons',
'avocado': 'avocado', 'peas': 'erbsen', 'beans': 'bohnen', 'corn': 'mais',
'peppers': 'paprika', 'bell pepper': 'peperoni', 'celery': 'sellerie',
'pumpkin': 'kürbis', 'watermelon': 'wassermelone', 'pineapple': 'ananas',
'mango': 'mango', 'peach': 'pfirsich', 'pear': 'birnen', 'cherries': 'kirschen',
'asparagus': 'spargel', 'eggplant': 'aubergine', 'ginger': 'ingwer',
// Meat & fish
'chicken': 'poulet', 'chicken breast': 'hähnchenbrust',
'beef': 'rindfleisch', 'pork': 'schweinefleisch', 'lamb': 'lamm',
'ham': 'schinken', 'bacon': 'speck', 'sausage': 'wurst', 'sausages': 'bratwurst',
'salmon': 'lachs', 'tuna': 'thunfisch', 'shrimp': 'garnelen', 'prawns': 'garnelen',
'fish': 'fisch', 'steak': 'steak', 'ground beef': 'hackfleisch',
'salami': 'salami', 'meatballs': 'hackfleisch',
// Dairy
'milk': 'milch', 'butter': 'butter', 'cheese': 'käse', 'eggs': 'eier', 'egg': 'eier',
'yogurt': 'joghurt', 'yoghurt': 'joghurt', 'cream': 'rahm', 'sour cream': 'sauerrahm',
'cream cheese': 'frischkäse', 'cottage cheese': 'hüttenkäse',
'mozzarella': 'mozzarella', 'parmesan': 'parmesan', 'feta': 'feta',
'ricotta': 'ricotta', 'mascarpone': 'mascarpone',
// Bread & bakery
'bread': 'brot', 'rolls': 'brötchen', 'baguette': 'baguette', 'toast': 'toast',
'croissant': 'croissant', 'flour': 'mehl', 'yeast': 'hefe',
'baking powder': 'backpulver', 'sugar': 'zucker', 'powdered sugar': 'puderzucker',
'vanilla sugar': 'vanillezucker', 'cornstarch': 'speisestärke',
// Pasta, rice & grains
'pasta': 'nudeln', 'noodles': 'nudeln', 'spaghetti': 'spaghetti', 'penne': 'penne',
'rice': 'reis', 'basmati': 'basmatireis', 'couscous': 'couscous',
'oats': 'haferflocken', 'oatmeal': 'haferflocken', 'cereal': 'corn flakes',
'muesli': 'müsli', 'lentils': 'linsen', 'chickpeas': 'kichererbsen',
'gnocchi': 'gnocchi', 'lasagna': 'lasagne', 'tortellini': 'nudeln',
// Spices & sauces
'salt': 'salz', 'pepper': 'pfefferkörner', 'oil': 'öl', 'olive oil': 'olivenöl',
'vinegar': 'essig', 'balsamic': 'balsamico', 'ketchup': 'ketchup',
'mustard': 'senf', 'mayonnaise': 'mayonnaise', 'mayo': 'mayonnaise',
'soy sauce': 'sojasauce', 'tomato paste': 'tomatenmark', 'tomato sauce': 'tomatensauce',
'honey': 'honig', 'cinnamon': 'zimt', 'oregano': 'oregano', 'basil': 'basilikum',
'parsley': 'petersilie', 'rosemary': 'rosmarin', 'thyme': 'thymian',
'paprika': 'paprikapulver', 'curry': 'paprikapulver', 'chili': 'chili',
'herbs': 'kräuter', 'bbq sauce': 'bbq sauce', 'pesto': 'pasta sauce',
'jam': 'konfitüre', 'marmalade': 'marmelade',
// Drinks
'water': 'wasser', 'sparkling water': 'mineralwasser', 'juice': 'fruchtsaft',
'orange juice': 'orangensaft', 'apple juice': 'apfelsaft',
'coffee': 'kaffee', 'tea': 'tee', 'beer': 'bier', 'wine': 'rotwein',
'white wine': 'weisswein', 'red wine': 'rotwein',
'cola': 'cola', 'lemonade': 'limonade', 'soda': 'limonade',
'energy drink': 'energy drink', 'smoothie': 'smoothie',
'whiskey': 'whisky', 'whisky': 'whisky', 'gin': 'gin', 'vodka': 'vodka', 'rum': 'rum',
'cocoa': 'kakao', 'hot chocolate': 'kakao', 'iced tea': 'eistee',
'oat milk': 'soya milch', 'soy milk': 'soya milch', 'almond milk': 'soya milch',
'coconut milk': 'kokosmilch', 'tonic': 'tonic water',
// Sweets & snacks
'chocolate': 'schokolade', 'cookies': 'kekse', 'cookie': 'kekse', 'biscuits': 'kekse',
'chips': 'chips', 'crisps': 'chips', 'nuts': 'nüsse', 'peanuts': 'erdnüsse',
'almonds': 'mandeln', 'walnuts': 'baumnüsse', 'hazelnuts': 'haselnüsse',
'ice cream': 'eis', 'cake': 'kuchen', 'candy': 'süssigkeiten', 'sweets': 'süssigkeiten',
'gummy bears': 'süssigkeiten', 'popcorn': 'pop corn', 'pretzels': 'brezeln',
'granola bar': 'getreideriegel', 'muffins': 'muffins', 'waffles': 'waffeln',
'pudding': 'pudding', 'nutella': 'nougatcreme',
// Frozen
'frozen pizza': 'pizza', 'frozen vegetables': 'gemüse gefroren',
'fish sticks': 'fischstäbchen', 'french fries': 'pommes frites', 'fries': 'pommes frites',
// Household
'dish soap': 'spülmittel', 'detergent': 'waschmittel', 'laundry detergent': 'waschmittel',
'trash bags': 'abfallsäcke', 'garbage bags': 'abfallsäcke',
'paper towels': 'haushaltspapier', 'kitchen roll': 'küchenrolle',
'toilet paper': 'toilettenpapier', 'aluminum foil': 'alufolie', 'tin foil': 'alufolie',
'plastic wrap': 'frischhaltefolie', 'cling film': 'frischhaltefolie',
'sponge': 'schwamm', 'batteries': 'batterien', 'light bulb': 'glühbirne',
'candles': 'kerzen', 'matches': 'streichhölzer', 'baking paper': 'backpapier',
'dishwasher tabs': 'geschirrtabs', 'fabric softener': 'weichspüler',
'cleaning spray': 'putzmittel', 'glass cleaner': 'glasreiniger',
'napkins': 'servietten', 'straws': 'strohhalme',
// Hygiene & personal care
'toothpaste': 'zahnpasta', 'toothbrush': 'zahnbürsten', 'dental floss': 'zahnseide',
'shampoo': 'shampoo', 'conditioner': 'conditioner', 'shower gel': 'duschgel',
'body wash': 'duschmittel', 'soap': 'seife', 'deodorant': 'deo',
'sunscreen': 'sonnencreme', 'sunblock': 'sonnencreme',
'hand cream': 'handcreme', 'body lotion': 'bodylotion', 'face cream': 'gesichtscreme',
'razor': 'rasierer', 'razor blades': 'rasierklingen', 'shaving cream': 'rasierschaum',
'tissues': 'taschentücher', 'wet wipes': 'feuchttücher',
'cotton pads': 'wattepads', 'cotton swabs': 'wattestäbchen',
'band-aids': 'pflaster', 'plasters': 'pflaster',
'mouthwash': 'mundspülung', 'nail polish': 'nagellack',
'hair gel': 'haargel', 'hairspray': 'haarspray', 'diapers': 'windeln',
'condoms': 'kondome', 'vitamins': 'vitamine', 'painkillers': 'schmerzmittel',
// Misc
'flowers': 'blumen', 'gift': 'geschenk', 'wrapping paper': 'geschenkpapier',
'cat food': 'katzenfutter', 'dog food': 'hundefutter', 'pet food': 'katzenfutter',
'bird food': 'vogelfutter', 'cat litter': 'katzenstreu',
'zürisäcke': 'abfallsäcke', 'zürisack': 'abfallsäcke', 'züribags': 'abfallsäcke',
'züribag': 'abfallsäcke', 'kehrichtsäcke': 'abfallsäcke', 'kehrichtsack': 'abfallsäcke',
'tofu': 'tofu', 'olives': 'oliven', 'pickles': 'essiggurken',
'soup': 'suppe', 'broth': 'brühe', 'bouillon': 'bouillon',
'pizza': 'pizza', 'pizza dough': 'pizzateig',
};
// --- Catalog lookup maps (built once) ---
const catalogMap = new Map<string, string>();
for (const [displayName, iconFile] of Object.entries(catalog as Record<string, string>)) {
catalogMap.set(displayName.toLowerCase(), iconFile);
}
/** Pre-computed mapping: icon display name → category */
const iconCategories = iconCategoriesData as Record<string, string>;
/** Icons grouped by category for scoped search */
const iconsByCategory = new Map<string, string[]>();
for (const [iconName, category] of Object.entries(iconCategories)) {
if (!iconsByCategory.has(category)) iconsByCategory.set(category, []);
iconsByCategory.get(category)!.push(iconName);
}
// --- Embedding state (lazy) ---
let embedder: FeatureExtractionPipeline | null = null;
let categoryIndex: { name: string; category: string; vector: number[] }[] | null = null;
let iconIndex: { name: string; icon: string; vector: number[] }[] | null = null;
async function getEmbedder(): Promise<FeatureExtractionPipeline> {
if (!embedder) {
embedder = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'q8' });
}
return embedder;
}
async function getCategoryIndex() {
if (!categoryIndex) {
const raw = await read(categoryEmbeddingsUrl).json();
categoryIndex = raw.entries;
}
return categoryIndex!;
}
async function getIconIndex() {
if (!iconIndex) {
const raw = await read(iconEmbeddingsUrl).json();
iconIndex = raw.entries;
}
return iconIndex!;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
// --- Icon lookup (direct + substring) ---
function directIconLookup(query: string): string | null {
// Exact match
const exact = catalogMap.get(query);
if (exact) return exact;
// Alias lookup: resolve English/alternate name to German catalog name
const alias = ICON_ALIASES[query];
if (alias) {
const aliasIcon = catalogMap.get(alias);
if (aliasIcon) return aliasIcon;
}
// Substring match: query contains catalog name or vice versa
for (const [catalogName, iconFile] of catalogMap) {
if (query.includes(catalogName) || catalogName.includes(query)) {
return iconFile;
}
}
return null;
}
// --- Main function ---
export async function categorizeItem(name: string): Promise<{
category: string;
confidence: number;
icon: string | null;
}> {
const query = name.toLowerCase().trim();
if (!query) return { category: 'Sonstiges', confidence: 0, icon: null };
// Step 0: DB cache
try {
await dbConnect();
const cached = await ShoppingItemCategory.findOne({ normalizedName: query }).lean();
if (cached) {
console.log(`[categorizer] Cache hit for "${name}": ${cached.category} / ${cached.icon}`);
return { category: cached.category, confidence: 1.0, icon: cached.icon ?? null };
}
} catch { /* continue without cache */ }
// Step 1: Direct icon lookup (exact + substring against catalog)
const directIcon = directIconLookup(query);
if (directIcon) {
console.log(`[categorizer] Direct icon match for "${name}": ${directIcon}`);
}
// Step 2: Embedding-based category classification
const emb = await getEmbedder();
const catIdx = await getCategoryIndex();
const result = await emb(`query: ${query}`, { pooling: 'mean', normalize: true });
const queryVector = Array.from(result.data as Float32Array);
let bestCatScore = -1;
let bestCatIdx = 0;
for (let i = 0; i < catIdx.length; i++) {
const score = cosineSimilarity(queryVector, catIdx[i].vector);
if (score > bestCatScore) {
bestCatScore = score;
bestCatIdx = i;
}
}
const category = bestCatScore >= CATEGORY_THRESHOLD
? catIdx[bestCatIdx].category
: 'Sonstiges';
const confidence = bestCatScore;
console.log(`[categorizer] Category for "${name}": ${category} (${bestCatScore.toFixed(3)}, matched: ${catIdx[bestCatIdx].name})`);
// Step 3: Icon resolution
let icon: string | null = directIcon;
if (!icon) {
// Category-scoped embedding search: only compare against icons in the matched category
const icoIdx = await getIconIndex();
const scopedIconNames = new Set(iconsByCategory.get(category) || []);
let bestIcoScore = -1;
let bestIcoIdx = -1;
for (let i = 0; i < icoIdx.length; i++) {
// Only consider icons in the matched category
if (!scopedIconNames.has(icoIdx[i].name.toLowerCase()) && !scopedIconNames.has(icoIdx[i].name)) {
continue;
}
const score = cosineSimilarity(queryVector, icoIdx[i].vector);
if (score > bestIcoScore) {
bestIcoScore = score;
bestIcoIdx = i;
}
}
if (bestIcoIdx >= 0 && bestIcoScore >= ICON_THRESHOLD) {
icon = icoIdx[bestIcoIdx].icon;
console.log(`[categorizer] Scoped icon match for "${name}": ${icon} (${bestIcoScore.toFixed(3)}, matched: ${icoIdx[bestIcoIdx].name})`);
} else {
// Fall back to category default
icon = CATEGORY_DEFAULT_ICONS[category] ?? null;
console.log(`[categorizer] Using default icon for "${name}": ${icon} (category: ${category})`);
}
}
// Step 4: Cache result
try {
await ShoppingItemCategory.updateOne(
{ normalizedName: query },
{ $setOnInsert: { normalizedName: query, originalName: name, category, icon } },
{ upsert: true }
);
} catch { /* cache write failure is non-fatal */ }
return { category, confidence, icon };
}
+35
View File
@@ -0,0 +1,35 @@
/**
* SSE connection manager for the shared shopping list.
* Unlike sseManager.ts (per-user), this broadcasts to ALL connected clients.
*/
type SSEController = ReadableStreamDefaultController<Uint8Array>;
const connections = new Set<SSEController>();
const encoder = new TextEncoder();
export function addConnection(controller: SSEController) {
connections.add(controller);
}
export function removeConnection(controller: SSEController) {
connections.delete(controller);
}
export function broadcast(event: string, data: unknown, excludeController?: SSEController) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
const bytes = encoder.encode(payload);
for (const controller of connections) {
if (controller === excludeController) continue;
try {
controller.enqueue(bytes);
} catch {
connections.delete(controller);
}
}
}
export function getConnectionCount(): number {
return connections.size;
}