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:
@@ -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
@@ -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' },
|
||||
];
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user