feat: add real-time collaborative shopping list at /cospend/list
All checks were successful
CI / update (push) Successful in 1m18s
All checks were successful
CI / update (push) Successful in 1m18s
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?.();
|
||||
}
|
||||
|
||||
|
||||
1
src/lib/data/shoppingCategoryEmbeddings.json
Normal file
1
src/lib/data/shoppingCategoryEmbeddings.json
Normal file
File diff suppressed because one or more lines are too long
329
src/lib/data/shoppingCategoryItems.ts
Normal file
329
src/lib/data/shoppingCategoryItems.ts
Normal 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
src/lib/data/shoppingIconCategories.json
Normal file
390
src/lib/data/shoppingIconCategories.json
Normal 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"
|
||||
}
|
||||
1
src/lib/data/shoppingIconEmbeddings.json
Normal file
1
src/lib/data/shoppingIconEmbeddings.json
Normal file
File diff suppressed because one or more lines are too long
221
src/lib/js/shoppingSync.svelte.ts
Normal file
221
src/lib/js/shoppingSync.svelte.ts
Normal 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
src/lib/server/shoppingCategorizer.ts
Normal file
333
src/lib/server/shoppingCategorizer.ts
Normal 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
src/lib/server/shoppingSSE.ts
Normal file
35
src/lib/server/shoppingSSE.ts
Normal 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;
|
||||
}
|
||||
22
src/models/ShoppingItemCategory.ts
Normal file
22
src/models/ShoppingItemCategory.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface IShoppingItemCategory {
|
||||
normalizedName: string;
|
||||
originalName: string;
|
||||
category: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
const ShoppingItemCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
normalizedName: { type: String, required: true, unique: true, index: true },
|
||||
originalName: { type: String, required: true },
|
||||
category: { type: String, required: true },
|
||||
icon: { type: String, default: null },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const ShoppingItemCategory =
|
||||
mongoose.models.ShoppingItemCategory ||
|
||||
mongoose.model<IShoppingItemCategory>('ShoppingItemCategory', ShoppingItemCategorySchema);
|
||||
40
src/models/ShoppingList.ts
Normal file
40
src/models/ShoppingList.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export interface IShoppingItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
icon: string | null;
|
||||
checked: boolean;
|
||||
addedBy: string;
|
||||
checkedBy?: string;
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
export interface IShoppingList {
|
||||
_id?: string;
|
||||
version: number;
|
||||
items: IShoppingItem[];
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const ShoppingItemSchema = new mongoose.Schema({
|
||||
id: { type: String, required: true },
|
||||
name: { type: String, required: true, trim: true },
|
||||
category: { type: String, default: 'Sonstiges' },
|
||||
icon: { type: String, default: null },
|
||||
checked: { type: Boolean, default: false },
|
||||
addedBy: { type: String, required: true, trim: true },
|
||||
checkedBy: { type: String, default: null },
|
||||
addedAt: { type: Date, default: () => new Date() }
|
||||
}, { _id: false });
|
||||
|
||||
const ShoppingListSchema = new mongoose.Schema(
|
||||
{
|
||||
version: { type: Number, required: true, default: 0 },
|
||||
items: { type: [ShoppingItemSchema], default: [] }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const ShoppingList = mongoose.model<IShoppingList>('ShoppingList', ShoppingListSchema);
|
||||
@@ -177,7 +177,7 @@ section h2{
|
||||
<h3>{labels.searchEngine}</h3>
|
||||
</a>
|
||||
|
||||
<a href="cospend">
|
||||
<a href="cospend/list">
|
||||
<svg class="lock-icon"><use href="#lock-icon"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M0 24C0 10.7 10.7 0 24 0H69.5c22 0 41.5 12.8 50.6 32h411c26.3 0 45.5 25 38.6 50.4l-41 152.3c-8.5 31.4-37 53.3-69.5 53.3H170.7l5.4 28.5c2.2 11.3 12.1 19.5 23.6 19.5H488c13.3 0 24 10.7 24 24s-10.7 24-24 24H199.7c-34.6 0-64.3-24.6-70.7-58.5L77.4 54.5c-.7-3.8-4-6.5-7.9-6.5H24C10.7 48 0 37.3 0 24zM128 464a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zm336-48a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"/></svg>
|
||||
<h3>{labels.shopping}</h3>
|
||||
|
||||
79
src/routes/api/cospend/list/+server.ts
Normal file
79
src/routes/api/cospend/list/+server.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { ShoppingList } from '$models/ShoppingList';
|
||||
import { broadcast } from '$lib/server/shoppingSSE';
|
||||
|
||||
async function getOrCreateList() {
|
||||
let list = await ShoppingList.findOne().lean();
|
||||
if (!list) {
|
||||
list = await ShoppingList.create({ version: 0, items: [] });
|
||||
list = list.toObject();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// GET /api/cospend/list — fetch current shopping list
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
await dbConnect();
|
||||
const list = await getOrCreateList();
|
||||
return json(list);
|
||||
};
|
||||
|
||||
// PUT /api/cospend/list — update shopping list with version conflict detection
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
await dbConnect();
|
||||
const data = await request.json();
|
||||
const { items, expectedVersion } = data;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
throw error(400, 'items must be an array');
|
||||
}
|
||||
|
||||
const existing = await getOrCreateList();
|
||||
|
||||
if (expectedVersion != null && existing.version !== expectedVersion) {
|
||||
return json(
|
||||
{ error: 'Version conflict', list: existing },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const newVersion = existing.version + 1;
|
||||
|
||||
const doc = await ShoppingList.findOneAndUpdate(
|
||||
{},
|
||||
{ $set: { items, version: newVersion } },
|
||||
{ upsert: true, returnDocument: 'after', lean: true }
|
||||
);
|
||||
|
||||
broadcast('update', doc);
|
||||
|
||||
return json(doc);
|
||||
};
|
||||
|
||||
// DELETE /api/cospend/list — clear all items
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
await dbConnect();
|
||||
const existing = await getOrCreateList();
|
||||
const newVersion = existing.version + 1;
|
||||
|
||||
const doc = await ShoppingList.findOneAndUpdate(
|
||||
{},
|
||||
{ $set: { items: [], version: newVersion } },
|
||||
{ upsert: true, returnDocument: 'after', lean: true }
|
||||
);
|
||||
|
||||
broadcast('update', doc);
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
17
src/routes/api/cospend/list/categorize/+server.ts
Normal file
17
src/routes/api/cospend/list/categorize/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { categorizeItem } from '$lib/server/shoppingCategorizer';
|
||||
|
||||
// POST /api/cospend/list/categorize — categorize a shopping item by name
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
const { name } = await request.json();
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw error(400, 'name is required');
|
||||
}
|
||||
|
||||
const result = await categorizeItem(name);
|
||||
return json(result);
|
||||
};
|
||||
46
src/routes/api/cospend/list/stream/+server.ts
Normal file
46
src/routes/api/cospend/list/stream/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { addConnection, removeConnection } from '$lib/server/shoppingSSE';
|
||||
|
||||
// GET /api/cospend/list/stream — SSE endpoint for live shopping list updates
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const auth = await locals.auth();
|
||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerRef: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controllerRef = controller;
|
||||
addConnection(controller);
|
||||
|
||||
try {
|
||||
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
removeConnection(controllerRef);
|
||||
}
|
||||
});
|
||||
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
try {
|
||||
controllerRef.enqueue(encoder.encode(': heartbeat\n\n'));
|
||||
} catch {
|
||||
clearInterval(heartbeatInterval);
|
||||
removeConnection(controllerRef);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -7,7 +7,7 @@
|
||||
import PaymentModal from '$lib/components/cospend/PaymentModal.svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||
import { LayoutDashboard, Wallet, RefreshCw } from '@lucide/svelte';
|
||||
import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
// Check if URL contains payment view route OR if we have paymentId in state
|
||||
const match = $page.url.pathname.match(/\/cospend\/payments\/view\/([^\/]+)/);
|
||||
const statePaymentId = $page.state?.paymentId;
|
||||
const isOnDashboard = $page.route.id === '/cospend';
|
||||
const isOnDashboard = $page.route.id === '/cospend/dash';
|
||||
|
||||
// Only show modal if we're on the dashboard AND have a payment to show
|
||||
if (isOnDashboard && (match || statePaymentId)) {
|
||||
@@ -38,7 +38,7 @@
|
||||
paymentId = null;
|
||||
|
||||
// Dispatch a custom event to trigger dashboard refresh
|
||||
if ($page.route.id === '/cospend') {
|
||||
if ($page.route.id === '/cospend/dash') {
|
||||
window.dispatchEvent(new CustomEvent('dashboardRefresh'));
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,8 @@
|
||||
function isActive(path) {
|
||||
const currentPath = $page.url.pathname;
|
||||
// Exact match for cospend root
|
||||
if (path === '/cospend') {
|
||||
return currentPath === '/cospend' || currentPath === '/cospend/';
|
||||
if (path === '/cospend/dash') {
|
||||
return currentPath === '/cospend/dash' || currentPath === '/cospend/dash/';
|
||||
}
|
||||
// For other paths, check if current path starts with the link path
|
||||
return currentPath.startsWith(path);
|
||||
@@ -58,7 +58,8 @@
|
||||
<Header>
|
||||
{#snippet links()}
|
||||
<ul class="site_header">
|
||||
<li style="--active-fill: var(--nord9)"><a href="/cospend" class:active={isActive('/cospend')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li>
|
||||
<li style="--active-fill: var(--nord9)"><a href="/cospend/dash" class:active={isActive('/cospend/dash')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li>
|
||||
<li style="--active-fill: var(--nord13)"><a href="/cospend/list" class:active={isActive('/cospend/list')}><ShoppingCart size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Liste</span></a></li>
|
||||
<li style="--active-fill: var(--nord14)"><a href="/cospend/payments" class:active={isActive('/cospend/payments')}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">All Payments</span></a></li>
|
||||
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/cospend/recurring" class:active={isActive('/cospend/recurring')}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">Recurring</span></a></li>
|
||||
</ul>
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch both balance and debt data server-side using existing APIs
|
||||
const [balanceResponse, debtResponse] = await Promise.all([
|
||||
fetch('/api/cospend/balance'),
|
||||
fetch('/api/cospend/debts')
|
||||
]);
|
||||
|
||||
if (!balanceResponse.ok) {
|
||||
throw new Error('Failed to fetch balance');
|
||||
}
|
||||
|
||||
if (!debtResponse.ok) {
|
||||
throw new Error('Failed to fetch debt data');
|
||||
}
|
||||
|
||||
const balance = await balanceResponse.json();
|
||||
const debtData = await debtResponse.json();
|
||||
|
||||
return {
|
||||
session,
|
||||
balance,
|
||||
debtData
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error loading dashboard data:', e);
|
||||
throw error(500, 'Failed to load dashboard data');
|
||||
}
|
||||
};
|
||||
export function load() {
|
||||
redirect(302, '/cospend/list');
|
||||
}
|
||||
|
||||
38
src/routes/cospend/dash/+page.server.ts
Normal file
38
src/routes/cospend/dash/+page.server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch both balance and debt data server-side using existing APIs
|
||||
const [balanceResponse, debtResponse] = await Promise.all([
|
||||
fetch('/api/cospend/balance'),
|
||||
fetch('/api/cospend/debts')
|
||||
]);
|
||||
|
||||
if (!balanceResponse.ok) {
|
||||
throw new Error('Failed to fetch balance');
|
||||
}
|
||||
|
||||
if (!debtResponse.ok) {
|
||||
throw new Error('Failed to fetch debt data');
|
||||
}
|
||||
|
||||
const balance = await balanceResponse.json();
|
||||
const debtData = await debtResponse.json();
|
||||
|
||||
return {
|
||||
session,
|
||||
balance,
|
||||
debtData
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error loading dashboard data:', e);
|
||||
throw error(500, 'Failed to load dashboard data');
|
||||
}
|
||||
};
|
||||
8
src/routes/cospend/list/+page.server.ts
Normal file
8
src/routes/cospend/list/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session) throw redirect(302, '/login');
|
||||
return { session };
|
||||
};
|
||||
412
src/routes/cospend/list/+page.svelte
Normal file
412
src/routes/cospend/list/+page.svelte
Normal file
@@ -0,0 +1,412 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getShoppingSync } from '$lib/js/shoppingSync.svelte';
|
||||
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
|
||||
import { Plus, ListX } from '@lucide/svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let { data } = $props();
|
||||
let user = $derived(data.session?.user?.nickname || '');
|
||||
const sync = getShoppingSync();
|
||||
|
||||
let newItemName = $state('');
|
||||
/** @type {HTMLInputElement | null} */
|
||||
let inputEl = $state(null);
|
||||
let categorizing = new SvelteSet();
|
||||
|
||||
/** @type {Record<string, boolean>} */
|
||||
let collapsed = $state({});
|
||||
|
||||
/** Get icon URL for an item */
|
||||
function iconUrl(item) {
|
||||
if (item.icon) return `https://bocken.org/static/shopping-icons/${item.icon}.png`;
|
||||
// Fallback: first letter
|
||||
const letter = item.name.charAt(0).toLowerCase();
|
||||
if (letter >= 'a' && letter <= 'z') return `https://bocken.org/static/shopping-icons/${letter}.png`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Group items by category, unchecked first within each group
|
||||
let groupedItems = $derived.by(() => {
|
||||
/** @type {Map<string, import('$lib/js/shoppingSync.svelte').ShoppingItem[]>} */
|
||||
const groups = new Map();
|
||||
|
||||
for (const item of sync.items) {
|
||||
if (!groups.has(item.category)) groups.set(item.category, []);
|
||||
groups.get(item.category).push(item);
|
||||
}
|
||||
|
||||
for (const [, items] of groups) {
|
||||
items.sort((a, b) => Number(a.checked) - Number(b.checked));
|
||||
}
|
||||
|
||||
const ordered = [...SHOPPING_CATEGORIES]
|
||||
.filter(cat => groups.has(cat))
|
||||
.map(cat => ({ category: cat, items: groups.get(cat) }));
|
||||
|
||||
for (const [cat, items] of groups) {
|
||||
if (!SHOPPING_CATEGORIES.includes(/** @type {any} */ (cat))) {
|
||||
ordered.push({ category: cat, items });
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
});
|
||||
|
||||
let checkedCount = $derived(sync.items.filter(i => i.checked).length);
|
||||
let totalCount = $derived(sync.items.length);
|
||||
|
||||
onMount(() => { sync.init(); });
|
||||
onDestroy(() => { sync.disconnect(); });
|
||||
|
||||
async function addItem() {
|
||||
const name = newItemName.trim();
|
||||
if (!name) return;
|
||||
|
||||
sync.addItem(name, user);
|
||||
newItemName = '';
|
||||
inputEl?.focus();
|
||||
|
||||
const addedItem = sync.items[sync.items.length - 1];
|
||||
if (!addedItem) return;
|
||||
|
||||
const itemId = addedItem.id;
|
||||
categorizing.add(itemId);
|
||||
|
||||
try {
|
||||
console.log(`[shopping] Categorizing "${name}" (item ${itemId})...`);
|
||||
const res = await fetch('/api/cospend/list/categorize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
console.log(`[shopping] Categorize response: ${res.status}`);
|
||||
if (res.ok) {
|
||||
const { category, icon } = await res.json();
|
||||
console.log(`[shopping] Got category=${category}, icon=${icon}, updating item ${itemId}`);
|
||||
sync.updateItemCategory(itemId, category, icon);
|
||||
} else {
|
||||
console.warn(`[shopping] Categorize failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[shopping] Categorize error:', err);
|
||||
}
|
||||
|
||||
categorizing.delete(itemId);
|
||||
}
|
||||
|
||||
/** @param {KeyboardEvent} e */
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addItem(); }
|
||||
}
|
||||
|
||||
/** @param {string} cat */
|
||||
function toggleCollapse(cat) {
|
||||
collapsed = { ...collapsed, [cat]: !collapsed[cat] };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="shopping-page">
|
||||
<header class="page-header">
|
||||
<h1>Einkaufsliste</h1>
|
||||
{#if totalCount > 0}
|
||||
<p class="subtitle">{checkedCount} / {totalCount} erledigt</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="add-bar">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={newItemName}
|
||||
onkeydown={onKeydown}
|
||||
type="text"
|
||||
placeholder="Artikel hinzufügen..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if totalCount === 0}
|
||||
<p class="empty-state">Die Einkaufsliste ist leer</p>
|
||||
{:else}
|
||||
<div class="item-list">
|
||||
{#each groupedItems as group (group.category)}
|
||||
<section class="category-section" transition:slide={{ duration: 200 }}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="category-header" onclick={() => toggleCollapse(group.category)}>
|
||||
<h2>{group.category}</h2>
|
||||
<span class="category-count">{group.items.filter(i => !i.checked).length}</span>
|
||||
</div>
|
||||
|
||||
{#if !collapsed[group.category]}
|
||||
<div class="card-grid" transition:slide={{ duration: 150 }}>
|
||||
{#each group.items as item (item.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="item-card"
|
||||
class:checked={item.checked}
|
||||
animate:flip={{ duration: 200 }}
|
||||
onclick={() => sync.toggleItem(item.id, user)}
|
||||
>
|
||||
<div class="card-icon">
|
||||
{#if iconUrl(item)}
|
||||
<img src={iconUrl(item)} alt="" />
|
||||
{:else}
|
||||
<span class="card-letter">{item.name.charAt(0)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="card-name">{item.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if checkedCount > 0}
|
||||
<button class="btn-clear-checked" onclick={() => sync.clearChecked()}>
|
||||
<ListX size={16} />
|
||||
Erledigte entfernen ({checkedCount})
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if sync.status === 'offline'}
|
||||
<div class="status-badge offline">Offline</div>
|
||||
{:else if sync.status === 'syncing'}
|
||||
<div class="status-badge syncing">Synchronisiere...</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shopping-page {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Add bar */
|
||||
.add-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.add-bar input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.add-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--nord10);
|
||||
}
|
||||
.btn-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-add:hover { background: var(--nord9); }
|
||||
.btn-add:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
/* Categories */
|
||||
.item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.2rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.category-header h2 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.category-count {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
/* Card grid */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.6rem 0.3rem;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
aspect-ratio: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.item-card:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.item-card:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.item-card.checked {
|
||||
opacity: 0.45;
|
||||
background: color-mix(in srgb, var(--nord14) 8%, var(--color-surface));
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-icon img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.item-card.checked .card-icon {
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
.card-letter {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
.item-card.checked .card-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Clear checked */
|
||||
.btn-clear-checked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
margin: 1.5rem auto 0;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
}
|
||||
.btn-clear-checked:hover {
|
||||
color: var(--nord11);
|
||||
border-color: var(--nord11);
|
||||
background: color-mix(in srgb, var(--nord11) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.status-badge {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 100px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
z-index: 50;
|
||||
}
|
||||
.status-badge.offline { background: var(--nord11); color: white; }
|
||||
.status-badge.syncing { background: var(--nord13); color: var(--nord0); }
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.shopping-page { padding: 1rem 0.75rem; }
|
||||
h1 { font-size: 1.3rem; }
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.card-icon { width: 36px; height: 36px; }
|
||||
.card-name { font-size: 0.68rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -286,7 +286,7 @@
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
await goto('/cospend');
|
||||
await goto('/cospend/dash');
|
||||
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
<h2>🎉 All Settled!</h2>
|
||||
<p>No outstanding debts to settle. Everyone is even!</p>
|
||||
<div class="actions">
|
||||
<a href="/cospend" class="btn btn-primary">Back to Dashboard</a>
|
||||
<a href="/cospend/dash" class="btn btn-primary">Back to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -342,7 +342,7 @@
|
||||
<button type="submit" class="btn btn-settlement">
|
||||
Record Settlement
|
||||
</button>
|
||||
<a href="/cospend" class="btn btn-secondary">
|
||||
<a href="/cospend/dash" class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user