diff --git a/app/database.py b/app/database.py index 48a3363..a92eadd 100755 --- a/app/database.py +++ b/app/database.py @@ -61,16 +61,43 @@ for c in CHAPTERS: CHAPTER_MAP[str(c["number"])] = c["number"] # Standard-Kategorien für die Einkaufsliste (Supermarkt-Laufweg) +# Standard (Vollsortimenter z. B. Edeka / Rewe) DEFAULT_SHOPPING_CATEGORIES = [ - {"name": "Obst & Gemüse", "icon": "🥦", "sort_order": 1}, - {"name": "Brot & Backwaren", "icon": "🍞", "sort_order": 2}, - {"name": "Kühlregal & Käse", "icon": "🧀", "sort_order": 3}, - {"name": "Fleisch & Fisch", "icon": "🥩", "sort_order": 4}, - {"name": "Vorrat & Nudeln", "icon": "🥫", "sort_order": 5}, - {"name": "Gewürze & Öle", "icon": "🧂", "sort_order": 6}, - {"name": "Tiefkühl", "icon": "❄️", "sort_order": 7}, - {"name": "Getränke", "icon": "🧃", "sort_order": 8}, - {"name": "Drogerie & Sonstiges", "icon": "🧺", "sort_order": 9}, + {"name": "Obst & Gemüse", "icon": "🥦", "shelf_type": "produce", "sort_order": 1}, + {"name": "Brot & Backwaren", "icon": "🍞", "shelf_type": "bakery", "sort_order": 2}, + {"name": "Vorrat & Nudeln", "icon": "🥫", "shelf_type": "pantry", "sort_order": 3}, + {"name": "Gewürze & Öle", "icon": "🧂", "shelf_type": "spices", "sort_order": 4}, + {"name": "Kühlregal & Käse", "icon": "🧀", "shelf_type": "dairy", "sort_order": 5}, + {"name": "Fleisch & Fisch", "icon": "🥩", "shelf_type": "meat", "sort_order": 6}, + {"name": "Tiefkühl", "icon": "❄️", "shelf_type": "frozen", "sort_order": 7}, + {"name": "Getränke", "icon": "🧃", "shelf_type": "drinks", "sort_order": 8}, + {"name": "Drogerie & Sonstiges", "icon": "🧺", "shelf_type": "drugstore", "sort_order": 9}, +] + +# Discounter-Preset (z. B. Aldi / Lidl: Kühlung & Fleisch früher, Vorrat/Aktion zentral) +DISCOUNTER_SHOPPING_CATEGORIES = [ + {"name": "Obst & Gemüse", "icon": "🥦", "shelf_type": "produce", "sort_order": 1}, + {"name": "Brot & Backautomat", "icon": "🍞", "shelf_type": "bakery", "sort_order": 2}, + {"name": "Kühlregal & Käse", "icon": "🧀", "shelf_type": "dairy", "sort_order": 3}, + {"name": "Frischfleisch & Wurst", "icon": "🥩", "shelf_type": "meat", "sort_order": 4}, + {"name": "Vorrat, Nudeln & Konserven", "icon": "🥫", "shelf_type": "pantry", "sort_order": 5}, + {"name": "Gewürze, Öle & Soßen", "icon": "🧂", "shelf_type": "spices", "sort_order": 6}, + {"name": "Tiefkühl", "icon": "❄️", "shelf_type": "frozen", "sort_order": 7}, + {"name": "Getränke", "icon": "🧃", "shelf_type": "drinks", "sort_order": 8}, + {"name": "Drogerie & Haushalt", "icon": "🧺", "shelf_type": "drugstore", "sort_order": 9}, +] + +SHELF_TYPES = [ + {"type": "produce", "label": "Obst & Gemüse", "icon": "🥦"}, + {"type": "bakery", "label": "Brot & Backwaren", "icon": "🍞"}, + {"type": "pantry", "label": "Vorrat & Nudeln", "icon": "🥫"}, + {"type": "spices", "label": "Gewürze & Öle", "icon": "🧂"}, + {"type": "dairy", "label": "Kühlregal & Käse", "icon": "🧀"}, + {"type": "meat", "label": "Fleisch & Fisch", "icon": "🥩"}, + {"type": "frozen", "label": "Tiefkühl", "icon": "❄️"}, + {"type": "drinks", "label": "Getränke", "icon": "🧃"}, + {"type": "drugstore", "label": "Drogerie & Haushalt", "icon": "🧺"}, + {"type": "other", "label": "Sonstiges", "icon": "🛒"}, ] @@ -158,38 +185,48 @@ def get_ingredient_category_mapping(ingredient_name: str) -> Optional[str]: def guess_category_for_ingredient(name: str) -> str: - """Bestimmt automatisch die passende Supermarkt-Kategorie dynamisch und lernfähig.""" + """Bestimmt automatisch die passende Supermarkt-Kategorie dynamisch, semantisch über Regal-Typen (shelf_type) und lernfähig.""" if not name: return "Drogerie & Sonstiges" # 1. Gespeichertes Nutzer-Mapping prüfen learned = get_ingredient_category_mapping(name) try: - all_cats = [c["name"] for c in get_shopping_categories()] + active_cats = get_shopping_categories() except Exception: - all_cats = [] + active_cats = [] - if learned and (not all_cats or learned in all_cats): + cat_names = [c["name"] for c in active_cats] + if learned and (not cat_names or learned in cat_names): return learned from .ingredients import clean_ingredient_name clean = clean_ingredient_name(name).lower() - # Hilfsfunktion zum dynamischen Abgleichen gegen die tatsächlichen Kategorien - def find_best_category_match(search_terms: List[str], fallback: str) -> str: - for cat_name in all_cats: + # Hilfsfunktion: Finde Kategorie passend zum Ziel-Regaltyp (shelf_type) oder Name + def resolve_category(target_type: str, search_terms: List[str], fallback_name: str) -> str: + # Prio A: Exakter Match auf shelf_type + for cat in active_cats: + if cat.get("shelf_type") == target_type: + return cat["name"] + # Prio B: Namens-Match gegen bestehende Kategorien + for cat_name in cat_names: cat_lower = cat_name.lower() if any(term in cat_lower for term in search_terms): return cat_name - return fallback + # Prio C: Gibt es den Fallback-Namen? + if fallback_name in cat_names: + return fallback_name + # Prio D: Erste Kategorie oder Sonstiges + return cat_names[-1] if cat_names else fallback_name - # 2. Tiefkühl + # 2. Tiefkühl (frozen) if any(k in clean for k in [ "tk", "tiefkühl", "gefroren", "eiscreme", "speiseeis", "erbsen (tk)", "tk-", "tiefgefroren" ]): - return find_best_category_match(["tiefkühl", "tk", "frost", "eis"], "Tiefkühl") + return resolve_category("frozen", ["tiefkühl", "tk", "frost", "eis"], "Tiefkühl") - # 3. Obst & Gemüse (mit Kresse, Edamame, Sprossen, Kräutern etc.) + # 3. Obst & Gemüse (produce) produce_keywords = [ "zwiebel", "knoblauch", "kartoffel", "karotte", "möhre", "tomate", "paprika", "brokkoli", "blumenkohl", "spinat", "zucchini", "aubergine", "gurke", "salat", @@ -207,9 +244,9 @@ def guess_category_for_ingredient(name: str) -> str: "kopfsalat", "eisbergsalat", "chicoree", "radicchio", "feige", "traube", "weintraube" ] if any(re.search(r"\b" + re.escape(k), clean) for k in produce_keywords): - return find_best_category_match(["obst", "gemüse", "frische", "früchte"], "Obst & Gemüse") + return resolve_category("produce", ["obst", "gemüse", "frische", "früchte"], "Obst & Gemüse") - # 4. Kühlregal & Käse + # 4. Kühlregal & Käse (dairy) dairy_keywords = [ "milch", "butter", "sahne", "schmand", "quark", "joghurt", "käse", "cheddar", "parmesan", "mozzarella", "feta", "gouda", "bergkäse", "frischkäse", "emmentaler", @@ -219,9 +256,9 @@ def guess_category_for_ingredient(name: str) -> str: "skyr", "hafermilch", "sojamilch", "mandelmilch", "streukäse", "ghee" ] if any(re.search(r"\b" + re.escape(k), clean) for k in dairy_keywords): - return find_best_category_match(["kühl", "käse", "milch", "molkerei"], "Kühlregal & Käse") + return resolve_category("dairy", ["kühl", "käse", "milch", "molkerei"], "Kühlregal & Käse") - # 5. Fleisch & Fisch + # 5. Fleisch & Fisch (meat) meat_keywords = [ "hackfleisch", "hack", "rinderhack", "hähnchen", "huhn", "pute", "speck", "schinken", "wurst", "würstchen", "lachs", "garnele", "garnelen", "fisch", @@ -229,18 +266,18 @@ def guess_category_for_ingredient(name: str) -> str: "bratwurst", "wiener", "bacon", "meeresfrüchte", "thunfisch" ] if any(re.search(r"\b" + re.escape(k), clean) for k in meat_keywords): - return find_best_category_match(["fleisch", "fisch", "metzger", "wurst"], "Fleisch & Fisch") + return resolve_category("meat", ["fleisch", "fisch", "metzger", "wurst"], "Fleisch & Fisch") - # 6. Brot & Backwaren + # 6. Brot & Backwaren (bakery) bakery_keywords = [ "brot", "brötchen", "semmel", "semmeln", "toast", "toastbrot", "baguette", "ciabatta", "fladenbrot", "tortilla", "wrap", "wraps", "pita", "naan", "panko", "paniermehl", "semmelbrösel", "blätterteig", "croissant", "brioche" ] if any(re.search(r"\b" + re.escape(k), clean) for k in bakery_keywords): - return find_best_category_match(["brot", "backwaren", "bäckerei"], "Brot & Backwaren") + return resolve_category("bakery", ["brot", "backwaren", "bäckerei"], "Brot & Backwaren") - # 7. Gewürze & Öle + # 7. Gewürze & Öle (spices) spice_keywords = [ "olivenöl", "rapsöl", "sonnenblumenöl", "sesamöl", "bratöl", "kokosöl", "leinöl", "erdnussöl", "essig", "balsamico", "apfelessig", "weißweinessig", "rotweinessig", "salz", "meersalz", @@ -250,9 +287,9 @@ def guess_category_for_ingredient(name: str) -> str: "wacholder", "garam masala", "ras el hanout" ] if re.search(r"\böl\b", clean) or any(re.search(r"\b" + re.escape(k), clean) for k in spice_keywords): - return find_best_category_match(["gewürz", "öl", "essig"], "Gewürze & Öle") + return resolve_category("spices", ["gewürz", "öl", "essig"], "Gewürze & Öle") - # 8. Vorrat & Nudeln + # 8. Vorrat & Nudeln (pantry) pantry_keywords = [ "nudel", "nudeln", "spaghetti", "penne", "mie", "pasta", "reis", "basmati", "jasmin", "linsen", "kichererbsen", "bohnen", "kidneybohnen", "mais", @@ -267,19 +304,19 @@ def guess_category_for_ingredient(name: str) -> str: "sahnesteif", "speisestärke", "stärke" ] if any(re.search(r"\b" + re.escape(k), clean) for k in pantry_keywords): - return find_best_category_match(["vorrat", "nudel", "konserve", "trocken"], "Vorrat & Nudeln") + return resolve_category("pantry", ["vorrat", "nudel", "konserve", "trocken"], "Vorrat & Nudeln") - # 9. Getränke + # 9. Getränke (drinks) drink_keywords = [ "wasser", "mineralwasser", "saft", "apfelsaft", "orangensaft", "wein", "weißwein", "rotwein", "sekt", "prosecco", "bier", "kaffee", "espresso", "tee", "cola", "limonade", "sirup", "tonic" ] if any(re.search(r"\b" + re.escape(k), clean) for k in drink_keywords): - return find_best_category_match(["getränk", "durst", "wein", "saft"], "Getränke") + return resolve_category("drinks", ["getränk", "durst", "wein", "saft"], "Getränke") - # Fallback: Drogerie & Sonstiges - return find_best_category_match(["drogerie", "sonstig"], "Drogerie & Sonstiges") + # Fallback: Drogerie & Sonstiges (drugstore / other) + return resolve_category("drugstore", ["drogerie", "haushalt", "sonstig"], "Drogerie & Sonstiges") def get_connection(): @@ -441,6 +478,7 @@ def init_db(): id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, icon VARCHAR(50) DEFAULT '🛒', + shelf_type VARCHAR(50) DEFAULT 'other', sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -456,6 +494,16 @@ def init_db(): ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) + try: + cursor.execute(""" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'shopping_categories' AND COLUMN_NAME = 'shelf_type' + """, (MARIADB_NAME,)) + if not cursor.fetchone(): + cursor.execute("ALTER TABLE shopping_categories ADD COLUMN shelf_type VARCHAR(50) DEFAULT 'other' AFTER icon") + except Exception: + pass + try: cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS @@ -556,6 +604,7 @@ def init_db(): id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, icon TEXT DEFAULT '🛒', + shelf_type TEXT DEFAULT 'other', sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) @@ -570,6 +619,11 @@ def init_db(): ) """) + cursor.execute("PRAGMA table_info(shopping_categories)") + existing_cat_cols = [c[1] for c in cursor.fetchall()] + if "shelf_type" not in existing_cat_cols: + cursor.execute("ALTER TABLE shopping_categories ADD COLUMN shelf_type TEXT DEFAULT 'other'") + cursor.execute("PRAGMA table_info(shopping_list)") existing_shop_cols = [c[1] for c in cursor.fetchall()] if "category" not in existing_shop_cols: @@ -1094,16 +1148,17 @@ def clear_meal_plan_week(year: int, week: int) -> int: # --- Shopping Categories & Shopping List (Einkaufsliste) --- -def seed_default_shopping_categories_if_empty(): +def seed_default_shopping_categories_if_empty(preset: str = "standard"): """Initialisiert die Standard-Kategorien, falls noch keine vorhanden sind, und kategorisiert alte Einträge.""" try: cats = execute_query("SELECT COUNT(*) as cnt FROM shopping_categories", fetch="one") cnt = cats["cnt"] if cats else 0 if cnt == 0: - for c in DEFAULT_SHOPPING_CATEGORIES: + cat_list = DISCOUNTER_SHOPPING_CATEGORIES if preset == "discounter" else DEFAULT_SHOPPING_CATEGORIES + for c in cat_list: execute_query( - "INSERT INTO shopping_categories (name, icon, sort_order) VALUES (?, ?, ?)", - (c["name"], c["icon"], c["sort_order"]), + "INSERT INTO shopping_categories (name, icon, shelf_type, sort_order) VALUES (?, ?, ?, ?)", + (c["name"], c["icon"], c.get("shelf_type", "other"), c["sort_order"]), fetch="none", commit=True ) @@ -1121,38 +1176,44 @@ def seed_default_shopping_categories_if_empty(): def get_shopping_categories() -> List[Dict[str, Any]]: """Gibt alle Supermarkt-Bereiche/Kategorien sortiert nach Reihenfolge zurück.""" - query = "SELECT id, name, icon, sort_order, created_at FROM shopping_categories ORDER BY sort_order ASC, id ASC" + query = "SELECT id, name, icon, shelf_type, sort_order, created_at FROM shopping_categories ORDER BY sort_order ASC, id ASC" rows = execute_query(query, fetch="all") if not rows: seed_default_shopping_categories_if_empty() rows = execute_query(query, fetch="all") for r in rows: + if not r.get("shelf_type"): + r["shelf_type"] = "other" if isinstance(r.get("created_at"), (datetime, date)): r["created_at"] = r["created_at"].isoformat() return rows -def add_shopping_category(name: str, icon: str = "🛒", sort_order: Optional[int] = None) -> Dict[str, Any]: +def add_shopping_category(name: str, icon: str = "🛒", shelf_type: str = "other", sort_order: Optional[int] = None) -> Dict[str, Any]: """Fügt einen neuen Supermarkt-Bereich hinzu.""" clean_name = name.strip() if not clean_name: raise ValueError("Name des Bereichs darf nicht leer sein.") clean_icon = (icon or "🛒").strip() + clean_shelf = (shelf_type or "other").strip() if sort_order is None: max_row = execute_query("SELECT MAX(sort_order) as m FROM shopping_categories", fetch="one") sort_order = ((max_row["m"] or 0) + 1) if max_row else 1 - query = "INSERT INTO shopping_categories (name, icon, sort_order) VALUES (?, ?, ?)" - res = execute_query(query, (clean_name, clean_icon, sort_order), fetch="none", commit=True) + query = "INSERT INTO shopping_categories (name, icon, shelf_type, sort_order) VALUES (?, ?, ?, ?)" + res = execute_query(query, (clean_name, clean_icon, clean_shelf, sort_order), fetch="none", commit=True) cat_id = res.get("lastrowid") row = execute_query("SELECT * FROM shopping_categories WHERE id = ?", (cat_id,), fetch="one") - if row and isinstance(row.get("created_at"), (datetime, date)): - row["created_at"] = row["created_at"].isoformat() + if row: + if not row.get("shelf_type"): + row["shelf_type"] = clean_shelf + if isinstance(row.get("created_at"), (datetime, date)): + row["created_at"] = row["created_at"].isoformat() return row -def update_shopping_category(cat_id: int, name: str, icon: str, sort_order: Optional[int] = None) -> Optional[Dict[str, Any]]: +def update_shopping_category(cat_id: int, name: str, icon: str, shelf_type: str = "other", sort_order: Optional[int] = None) -> Optional[Dict[str, Any]]: """Aktualisiert einen Supermarkt-Bereich und benennt vorhandene Zuweisungen um.""" old = execute_query("SELECT * FROM shopping_categories WHERE id = ?", (cat_id,), fetch="one") if not old: @@ -1160,18 +1221,22 @@ def update_shopping_category(cat_id: int, name: str, icon: str, sort_order: Opti clean_name = name.strip() clean_icon = (icon or "🛒").strip() + clean_shelf = (shelf_type or old.get("shelf_type") or "other").strip() order_val = sort_order if sort_order is not None else old.get("sort_order", 0) - query = "UPDATE shopping_categories SET name = ?, icon = ?, sort_order = ? WHERE id = ?" - execute_query(query, (clean_name, clean_icon, order_val, cat_id), fetch="none", commit=True) + query = "UPDATE shopping_categories SET name = ?, icon = ?, shelf_type = ?, sort_order = ? WHERE id = ?" + execute_query(query, (clean_name, clean_icon, clean_shelf, order_val, cat_id), fetch="none", commit=True) # Falls der Name geändert wurde, aktualisiere die Items in shopping_list if old["name"] != clean_name: execute_query("UPDATE shopping_list SET category = ? WHERE category = ?", (clean_name, old["name"]), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_categories WHERE id = ?", (cat_id,), fetch="one") - if row and isinstance(row.get("created_at"), (datetime, date)): - row["created_at"] = row["created_at"].isoformat() + if row: + if not row.get("shelf_type"): + row["shelf_type"] = clean_shelf + if isinstance(row.get("created_at"), (datetime, date)): + row["created_at"] = row["created_at"].isoformat() return row @@ -1196,10 +1261,18 @@ def reorder_shopping_categories(category_ids: List[int]) -> bool: return True -def reset_default_shopping_categories() -> List[Dict[str, Any]]: - """Setzt alle Bereiche auf die Standard-Reihenfolge zurück.""" +def reset_default_shopping_categories(preset: str = "standard") -> List[Dict[str, Any]]: + """Setzt alle Bereiche auf das gewählte Preset zurück (standard=Vollsortimenter, discounter=Discounter).""" execute_query("DELETE FROM shopping_categories", fetch="none", commit=True) - seed_default_shopping_categories_if_empty() + seed_default_shopping_categories_if_empty(preset=preset) + # Alle offenen Listeneinträge nach dem neuen Laufweg neu kategorisieren + try: + items = execute_query("SELECT id, item_name FROM shopping_list", fetch="all") + for it in items: + guessed = guess_category_for_ingredient(it.get("item_name", "")) + execute_query("UPDATE shopping_list SET category = ? WHERE id = ?", (guessed, it["id"]), fetch="none", commit=True) + except Exception: + pass return get_shopping_categories() diff --git a/app/main.py b/app/main.py index 5fa7f25..22afaa1 100755 --- a/app/main.py +++ b/app/main.py @@ -59,7 +59,8 @@ from .database import ( reorder_shopping_categories, reset_default_shopping_categories, update_shopping_item_category, - update_shopping_item + update_shopping_item, + SHELF_TYPES ) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -218,11 +219,13 @@ class ShoppingBulkCreate(BaseModel): class ShoppingCategoryCreate(BaseModel): name: str icon: Optional[str] = "🛒" + shelf_type: Optional[str] = "other" sort_order: Optional[int] = None class ShoppingCategoryUpdate(BaseModel): name: str icon: Optional[str] = "🛒" + shelf_type: Optional[str] = "other" sort_order: Optional[int] = None class ShoppingReorderRequest(BaseModel): @@ -360,6 +363,7 @@ async def shopping_list_view(request: Request): items_json = json.dumps(items, default=json_serial) categories_json = json.dumps(categories, default=json_serial) + shelf_types_json = json.dumps(SHELF_TYPES, default=json_serial) return templates.TemplateResponse( request=request, @@ -367,8 +371,10 @@ async def shopping_list_view(request: Request): context={ "items": items, "categories": categories, + "shelf_types": SHELF_TYPES, "items_json": items_json, "categories_json": categories_json, + "shelf_types_json": shelf_types_json, "current_year": iso_year, "current_week": iso_week } @@ -1154,7 +1160,7 @@ def api_get_shopping_categories(): @app.post("/api/shopping-categories") async def api_add_shopping_category(payload: ShoppingCategoryCreate): try: - cat = add_shopping_category(payload.name, payload.icon or "🛒", payload.sort_order) + cat = add_shopping_category(payload.name, payload.icon or "🛒", payload.shelf_type or "other", payload.sort_order) await shopping_ws_manager.broadcast({ "action": "categories_updated", "categories": get_shopping_categories(), @@ -1167,7 +1173,7 @@ async def api_add_shopping_category(payload: ShoppingCategoryCreate): @app.put("/api/shopping-categories/{cat_id}") async def api_update_shopping_category(cat_id: int, payload: ShoppingCategoryUpdate): - cat = update_shopping_category(cat_id, payload.name, payload.icon or "🛒", payload.sort_order) + cat = update_shopping_category(cat_id, payload.name, payload.icon or "🛒", payload.shelf_type or "other", payload.sort_order) if not cat: raise HTTPException(status_code=404, detail="Bereich nicht gefunden.") await shopping_ws_manager.broadcast({ @@ -1202,8 +1208,8 @@ async def api_reorder_shopping_categories(payload: ShoppingReorderRequest): @app.post("/api/shopping-categories/reset-defaults") -async def api_reset_default_shopping_categories(): - cats = reset_default_shopping_categories() +async def api_reset_default_shopping_categories(preset: str = Query("standard")): + cats = reset_default_shopping_categories(preset=preset) await shopping_ws_manager.broadcast({ "action": "categories_updated", "categories": cats, diff --git a/app/templates/shopping_list.html b/app/templates/shopping_list.html index 91b5ca1..c7b7090 100644 --- a/app/templates/shopping_list.html +++ b/app/templates/shopping_list.html @@ -250,12 +250,103 @@ background: rgba(30, 77, 59, 0.05); } + /* Laufweg Schrittnummer & Badges */ + .shop-cat-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--shop-green); + color: #FFFFFF; + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 800; + min-width: 20px; + height: 20px; + padding: 0 4px; + border-radius: 10px; + margin-right: 4px; + flex-shrink: 0; + } + .shop-badge-shelf { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.72rem; + font-weight: 600; + color: var(--shop-green); + background: rgba(30, 77, 59, 0.08); + border: 1px solid rgba(30, 77, 59, 0.2); + border-radius: 4px; + padding: 1px 6px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + font-family: inherit; + } + .shop-badge-shelf:hover, .shop-badge-shelf:active { + background: rgba(30, 77, 59, 0.18); + border-color: var(--shop-green); + } + .shop-item-row.is-checked .shop-badge-shelf { + opacity: 0.6; + } + .quick-shelf-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background: #FFFFFF; + border: 1.5px solid rgba(34, 34, 34, 0.15); + border-radius: 8px; + cursor: pointer; + font-family: inherit; + font-size: 0.95rem; + color: var(--shop-schwarz); + transition: background-color 0.15s, border-color 0.15s; + width: 100%; + } + .quick-shelf-btn:hover, .quick-shelf-btn:active { + background: rgba(30, 77, 59, 0.08); + border-color: var(--shop-green); + } + .quick-shelf-btn.is-current { + background: rgba(30, 77, 59, 0.12); + border-color: var(--shop-green); + font-weight: 700; + } + .shop-preset-bar { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + .shop-preset-btn { + flex: 1 1 180px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + font-size: 0.85rem; + font-weight: 700; + border-radius: 6px; + border: 1.5px solid var(--shop-green); + background: #FFFFFF; + color: var(--shop-green); + cursor: pointer; + transition: all 0.15s; + font-family: inherit; + } + .shop-preset-btn:hover, .shop-preset-btn:active { + background: var(--shop-green); + color: #FFFFFF; + } + /* Badge nur für persönliche Notiz */ .shop-item-badges { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; + margin-top: 2px; } .shop-badge-note { font-size: 0.74rem; @@ -725,6 +816,31 @@ + + + + + @@ -739,6 +855,21 @@ Ordne die Bereiche mit ▲ und ▼ so an, wie du durch deinen Supermarkt gehst.

+ +
+
+ Laufweg-Vorlage für deinen Markt: +
+
+ + +
+
+
@@ -746,7 +877,24 @@
- + +
+
+ +
@@ -754,7 +902,7 @@
- ${item.note ? ` -
+
+ + ${item.note ? ` 📝 ${escapeHtml(item.note)} -
- ` : ''} + ` : ''} +
- - - - - `).join(''); + ul.innerHTML = allCategories.map((cat, idx) => { + const stepNum = cat.sort_order || (idx + 1); + const shelfObj = (allShelfTypes || []).find(st => st.type === cat.shelf_type); + const shelfLabel = shelfObj ? shelfObj.label : (cat.shelf_type || 'Sonstiges'); + + return ` +
  • +
    + ${stepNum}. + ${cat.icon || '🛒'} +
    + ${escapeHtml(cat.name)} + Typ: ${escapeHtml(shelfLabel)} +
    +
    +
    + + + +
    +
  • + `; + }).join(''); } async function moveCategory(catId, dir) { @@ -1661,6 +1832,7 @@ const [removed] = allCategories.splice(idx, 1); allCategories.splice(targetIdx, 0, removed); + allCategories.forEach((c, i) => c.sort_order = i + 1); renderManageCategoriesList(); renderCategoryFilterChips(); renderShoppingList(); @@ -1679,8 +1851,10 @@ e.preventDefault(); const iconInput = document.getElementById('new-cat-icon'); const nameInput = document.getElementById('new-cat-name'); + const shelfSelect = document.getElementById('new-cat-shelf-type'); const name = nameInput.value.trim(); const icon = iconInput.value.trim() || '🛒'; + const shelfType = shelfSelect ? shelfSelect.value : 'other'; if (!name) return; nameInput.value = ''; @@ -1689,7 +1863,12 @@ const res = await fetch('/api/shopping-categories', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name, icon: icon, sort_order: allCategories.length + 1 }) + body: JSON.stringify({ + name: name, + icon: icon, + shelf_type: shelfType, + sort_order: allCategories.length + 1 + }) }); if (res.ok) { const d = await res.json(); @@ -1697,6 +1876,7 @@ localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories)); renderManageCategoriesList(); renderCategoryFilterChips(); + renderShoppingList(); showToast(`Bereich "${name}" angelegt`); } } catch (e) {} @@ -1718,20 +1898,106 @@ } catch (e) {} } - async function resetDefaultCategories() { - if (!confirm('Möchtest du wirklich alle Supermarkt-Bereiche auf den Standard-Laufweg zurücksetzen?')) return; + async function applyCategoryPreset(presetName) { + const label = presetName === 'discounter' ? 'Discounter (Aldi/Lidl)' : 'Supermarkt (Edeka/Rewe)'; + if (!confirm(`Möchtest du den Laufweg auf "${label}" umstellen?\nAlle Artikel deiner Liste werden automatisch den neuen Stationen zugeordnet.`)) { + return; + } + try { - const res = await fetch('/api/shopping-categories/reset-defaults', { method: 'POST' }); + const res = await fetch(`/api/shopping-categories/reset-defaults?preset=${encodeURIComponent(presetName)}`, { + method: 'POST' + }); if (res.ok) { const d = await res.json(); allCategories = d.categories || []; localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories)); + await refreshDataFromServer(); renderManageCategoriesList(); renderCategoryFilterChips(); renderShoppingList(); - showToast('Standard-Bereiche wiederhergestellt'); + showToast(`✓ Laufweg "${label}" aktiviert`); } - } catch (e) {} + } catch (e) { + console.error("Fehler beim Aktivieren der Vorlage:", e); + showToast('Fehler beim Aktivieren der Vorlage'); + } + } + + async function resetDefaultCategories() { + applyCategoryPreset('standard'); + } + + // ========================================== + // SCHNELL-REGALWAHL (1-TAP PRO ARTIKEL) + // ========================================== + let quickShelfTargetItemId = null; + + function openQuickShelfPicker(itemId, event) { + if (event) event.stopPropagation(); + const item = allItems.find(it => it.id === itemId); + if (!item) return; + + quickShelfTargetItemId = itemId; + const subTitle = document.getElementById('quick-shelf-subtitle'); + if (subTitle) { + subTitle.innerHTML = `Regal für ${escapeHtml(item.item_name)} wählen:`; + } + + const container = document.getElementById('quick-shelf-options'); + if (container) { + container.innerHTML = allCategories.map((cat, idx) => { + const isCurrent = (item.category || 'Sonstiges') === cat.name; + const stepNum = cat.sort_order || (idx + 1); + return ` + + `; + }).join(''); + } + + openModal('modal-quick-shelf'); + } + + async function applyQuickShelfChange(newCategory) { + if (!quickShelfTargetItemId) return; + const itemId = quickShelfTargetItemId; + const item = allItems.find(it => it.id === itemId); + closeModal('modal-quick-shelf'); + + if (item && item.category === newCategory) { + return; + } + + // Optimistisches Update + if (item) { + item.category = newCategory; + saveLocalCache(allItems); + renderCategoryFilterChips(); + renderShoppingList(); + } + + showToast(`✓ Zu "${newCategory}" verschoben & gelernt!`); + + try { + const res = await fetch(`/api/shopping-list/${itemId}/category`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category: newCategory }) + }); + if (!res.ok) throw new Error('Fehler beim Zuweisen der Kategorie'); + } catch (err) { + console.warn("Offline oder Fehler bei Kategorieänderung:", err); + pushOfflineQueue({ + method: 'POST', + url: `/api/shopping-list/${itemId}/category`, + body: { category: newCategory } + }); + } } // ========================================== @@ -1764,9 +2030,10 @@ if (!sel) return; let html = selectId === 'add-item-category' ? '' : ''; - allCategories.forEach(cat => { + allCategories.forEach((cat, idx) => { const isSel = (cat.name === selectedValue) ? 'selected' : ''; - html += ``; + const stepNum = cat.sort_order || (idx + 1); + html += ``; }); if (!allCategories.some(c => c.name === 'Sonstiges')) { diff --git a/scripts/test_walking_path_and_presets.py b/scripts/test_walking_path_and_presets.py new file mode 100644 index 0000000..004e000 --- /dev/null +++ b/scripts/test_walking_path_and_presets.py @@ -0,0 +1,91 @@ +import sys +import os + +# Set UTF-8 encoding for stdout +if sys.stdout.encoding.lower() != 'utf-8': + sys.stdout.reconfigure(encoding='utf-8') + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.database import ( + get_shopping_categories, + reset_default_shopping_categories, + guess_category_for_ingredient, + add_shopping_category, + update_shopping_category, + delete_shopping_category, + add_shopping_item, + update_shopping_item_category, + get_ingredient_category_mapping, + clear_shopping_list +) + +def run_tests(): + print("--- 1. Testing Standard Walking Path Categories ---") + reset_default_shopping_categories(preset="standard") + cats = get_shopping_categories() + assert len(cats) == 9, f"Expected 9 categories, got {len(cats)}" + + # Check natural walking order: Produce (1) -> Bakery (2) -> Pantry (3) -> Spices (4) -> Dairy (5) -> Meat (6) -> Frozen (7) -> Drinks (8) -> Drugstore (9) + expected_order = [ + ("Obst & Gemüse", "produce", 1), + ("Brot & Backwaren", "bakery", 2), + ("Vorrat & Nudeln", "pantry", 3), + ("Gewürze & Öle", "spices", 4), + ("Kühlregal & Käse", "dairy", 5), + ("Fleisch & Fisch", "meat", 6), + ("Tiefkühl", "frozen", 7), + ("Getränke", "drinks", 8), + ("Drogerie & Sonstiges", "drugstore", 9) + ] + for idx, (exp_name, exp_type, exp_order) in enumerate(expected_order): + cat = cats[idx] + assert cat["name"] == exp_name, f"Cat {idx} name mismatch: {cat['name']} != {exp_name}" + assert cat["shelf_type"] == exp_type, f"Cat {idx} shelf_type mismatch: {cat['shelf_type']} != {exp_type}" + assert cat["sort_order"] == exp_order, f"Cat {idx} sort_order mismatch: {cat['sort_order']} != {exp_order}" + print("[OK] Standard walking path matches physical supermarket logic!") + + print("--- 2. Testing Discounter Preset ---") + reset_default_shopping_categories(preset="discounter") + disc_cats = get_shopping_categories() + assert len(disc_cats) == 9 + assert disc_cats[0]["shelf_type"] == "produce" + assert disc_cats[1]["shelf_type"] == "bakery" + assert disc_cats[2]["shelf_type"] == "dairy" # In discounters, cold wall is often early along the outer wall + assert disc_cats[3]["shelf_type"] == "meat" + print("[OK] Discounter preset applied successfully!") + + print("--- 3. Testing Semantic Matching via shelf_type (Custom Category Renaming) ---") + # Reset back to standard + reset_default_shopping_categories(preset="standard") + cats = get_shopping_categories() + pantry_cat = next(c for c in cats if c["shelf_type"] == "pantry") + # User customizes the name: "Gang 3: Nudeln, Reis & Saucen" + update_shopping_category(pantry_cat["id"], "Gang 3: Nudeln, Reis & Saucen", pantry_cat["icon"], pantry_cat["shelf_type"], pantry_cat["sort_order"]) + + # Guess for spaghetti + guessed = guess_category_for_ingredient("Spaghetti") + assert guessed == "Gang 3: Nudeln, Reis & Saucen", f"Expected matching via shelf_type, got {guessed}" + print(f"[OK] Renamed category was resolved via shelf_type: 'Spaghetti' -> '{guessed}'") + + print("--- 4. Testing 1-Tap Shelf Reassignment & Learned Memory ---") + clear_shopping_list() + item = add_shopping_item("Frische Petersilie", "1", "Bund") + assert item["category"] == "Obst & Gemüse" + + # User taps 1-tap shelf selector to move it to "Kühlregal & Käse" (or custom shelf) + updated = update_shopping_item_category(item["id"], "Kühlregal & Käse") + assert updated["category"] == "Kühlregal & Käse" + + # Check that system memorized it + learned = get_ingredient_category_mapping("Frische Petersilie") + assert learned == "Kühlregal & Käse", f"Expected learned mapping 'Kühlregal & Käse', got '{learned}'" + print("[OK] 1-Tap shelf reassignment immediately updated item and saved learned memory!") + + # Reset back to clean standard + reset_default_shopping_categories(preset="standard") + clear_shopping_list() + print("ALL WALKING PATH & PRESET TESTS PASSED SUCCESSFULLY!") + +if __name__ == "__main__": + run_tests()