Implement supermarket walking path (Laufwege & Regale), presets, and 1-tap shelf reassignment

This commit is contained in:
2026-10-20 13:16:05 +02:00
parent 3709840d7a
commit f7590cc76d
4 changed files with 525 additions and 88 deletions
+126 -53
View File
@@ -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()
+11 -5
View File
@@ -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,
+297 -30
View File
@@ -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 @@
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SCHNELL-REGALWAHL (1-TAP) -->
<!-- ========================================== -->
<div id="modal-quick-shelf" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-quick-shelf')">
<div class="cb-modal-card" style="max-width: 440px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>🛒</span> Regal / Bereich wählen</h3>
<button type="button" class="cb-modal-close" onclick="closeModal('modal-quick-shelf')" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p id="quick-shelf-subtitle" style="font-size: 0.92rem; color: #555; margin: 0 0 12px 0;">
Wohin gehört dieser Artikel im Supermarkt?
</p>
<div id="quick-shelf-options" style="display: flex; flex-direction: column; gap: 6px; max-height: 55vh; overflow-y: auto; padding-right: 2px;">
<!-- Dynamisch befüllt -->
</div>
<div class="modal-btn-row" style="margin-top: 14px;">
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-quick-shelf')">
Abbrechen
</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SUPERMARKT-BEREICHE & LAUFWEG -->
<!-- ========================================== -->
@@ -739,6 +855,21 @@
Ordne die Bereiche mit <strong>▲</strong> und <strong>▼</strong> so an, wie du durch deinen Supermarkt gehst.
</p>
<!-- Presets Bar -->
<div style="margin-bottom: 14px; background: rgba(30, 77, 59, 0.05); border: 1.5px solid rgba(30, 77, 59, 0.15); border-radius: 8px; padding: 10px;">
<div style="font-size: 0.85rem; font-weight: 700; color: var(--shop-green); margin-bottom: 8px;">
Laufweg-Vorlage für deinen Markt:
</div>
<div class="shop-preset-bar">
<button type="button" class="shop-preset-btn" onclick="applyCategoryPreset('standard')" title="Obst/Gemüse ➔ Backwaren ➔ Vorrat ➔ Gewürze ➔ Kühlung ➔ Fleisch ➔ Tiefkühl ➔ Getränke ➔ Drogerie">
🛒 Supermarkt (Edeka / Rewe)
</button>
<button type="button" class="shop-preset-btn" onclick="applyCategoryPreset('discounter')" title="Obst/Gemüse ➔ Backwaren ➔ Kühlwand ➔ Fleisch ➔ Vorrat ➔ Tiefkühl ➔ Getränke ➔ Drogerie">
🏬 Discounter (Aldi / Lidl)
</button>
</div>
</div>
<ul id="cat-manage-list" style="list-style: none; margin: 0 0 16px 0; padding: 0; display: flex; flex-direction: column; gap: 8px;"></ul>
<div style="background: var(--shop-yellow); border: 1.5px solid var(--shop-schwarz); border-radius: 8px; padding: 12px;">
@@ -746,7 +877,24 @@
<form onsubmit="onAddCategory(event)" style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; gap: 8px;">
<input type="text" id="new-cat-icon" style="width: 44px; text-align: center; font-size: 1.2rem; border: 1.5px solid #888; border-radius: 6px;" value="🛒" title="Symbol / Emoji">
<input type="text" id="new-cat-name" placeholder="z. B. Drogerie & Haushalt" class="cb-input" style="flex: 1; padding: 8px 12px;" required>
<input type="text" id="new-cat-name" placeholder="z. B. Gang 3: Nudeln & Soßen" class="cb-input" style="flex: 1; padding: 8px 12px;" required>
</div>
<div>
<label for="new-cat-shelf-type" style="display: block; font-size: 0.78rem; font-weight: 700; color: #444; margin-bottom: 3px;">
Waren-Verhalten (für automatische Zutatenerkennung):
</label>
<select id="new-cat-shelf-type" class="cb-select" style="padding: 6px 10px; font-size: 0.88rem; width: 100%;">
<option value="produce">🥦 Obst, Gemüse & Kräuter</option>
<option value="bakery">🍞 Brot & Backwaren</option>
<option value="pantry" selected>🥫 Vorrat, Nudeln, Reis & Konserven</option>
<option value="spices">🧂 Gewürze, Öle & Essig</option>
<option value="dairy">🧀 Kühlregal, Milchprodukte & Käse</option>
<option value="meat">🥩 Fleisch, Fisch & Wurst</option>
<option value="frozen">❄️ Tiefkühl</option>
<option value="drinks">🧃 Getränke</option>
<option value="drugstore">🧺 Drogerie & Haushalt</option>
<option value="other">🛒 Sonstiges</option>
</select>
</div>
<button type="submit" class="cb-btn cb-btn-green" style="width: 100%; justify-content: center; padding: 9px 14px; font-weight: 700;">+ Bereich hinzufügen</button>
</form>
@@ -754,7 +902,7 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 14px;">
<button type="button" onclick="resetDefaultCategories()" style="background: none; border: none; font-size: 0.82rem; color: #777; cursor: pointer; text-decoration: underline;">
↺ Standard-Bereiche wiederherstellen
↺ Standard zurücksetzen
</button>
<button type="button" class="cb-btn cb-btn-secondary" onclick="closeModal('modal-categories')">
Fertig
@@ -781,12 +929,15 @@
// Server-Daten & Offline-Cache einbinden
let serverItems = [];
let serverCats = [];
let allShelfTypes = [];
try {
serverItems = {{ items_json | safe }};
serverCats = {{ categories_json | safe }};
allShelfTypes = {{ shelf_types_json | safe }};
} catch (e) {
serverItems = [];
serverCats = [];
allShelfTypes = [];
}
const cachedQueue = JSON.parse(localStorage.getItem('wtc_shop_offline_queue') || '[]');
@@ -1135,21 +1286,24 @@
});
let html = '';
groupNames.forEach(catName => {
groupNames.forEach((catName, groupIdx) => {
const group = groups[catName];
group.open.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
group.checked.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
const allInGroup = hideChecked ? group.open : [...group.open, ...group.checked];
if (allInGroup.length === 0) return; // Kategorie komplett ausblenden, wenn keine Einträge!
const catObj = allCategories.find(c => c.name === catName) || { name: catName, icon: '🛒' };
const catObj = allCategories.find(c => c.name === catName) || { name: catName, icon: '🛒', sort_order: groupIdx + 1 };
const stepNum = catObj.sort_order ? `${catObj.sort_order}.` : `${groupIdx + 1}.`;
const countBadge = hideChecked ? `${group.open.length} offen` : `${group.open.length} offen / ${allInGroup.length}`;
html += `
<section class="shop-cat-section">
<div class="shop-cat-header">
<h2 class="shop-cat-title">
<span>${catObj.icon || '🛒'}</span> <span>${escapeHtml(catName)}</span>
<span class="shop-cat-step-num">${stepNum}</span>
<span>${catObj.icon || '🛒'}</span>
<span>${escapeHtml(catName)}</span>
</h2>
<span class="shop-cat-count">${countBadge}</span>
</div>
@@ -1166,6 +1320,9 @@
function renderItemRow(item) {
const isChecked = item.checked ? 'is-checked' : '';
const amountStr = formatAmount(item.amount, item.unit);
const catObj = allCategories.find(c => c.name === item.category);
const catIcon = catObj ? (catObj.icon || '🛒') : '🛒';
const catName = item.category || 'Sonstiges';
return `
<li class="shop-item-row ${isChecked}" id="shop-row-${item.id}">
@@ -1180,11 +1337,14 @@
${amountStr ? `<span class="shop-item-amount">${escapeHtml(amountStr)}</span>` : ''}
</div>
${item.note ? `
<div class="shop-item-badges">
<div class="shop-item-badges">
<button type="button" class="shop-badge-shelf" onclick="openQuickShelfPicker(${item.id}, event)" title="Regal / Bereich schnell wechseln (lernt mit)">
<span>${catIcon}</span> <span>${escapeHtml(catName)}</span> <span>▾</span>
</button>
${item.note ? `
<span class="shop-badge-note">📝 ${escapeHtml(item.note)}</span>
</div>
` : ''}
` : ''}
</div>
</div>
<button type="button" class="shop-item-delete" onclick="deleteItemDirectly(${item.id}, event)" title="Eintrag löschen">
@@ -1626,6 +1786,7 @@
}
}
// ==========================================
// ==========================================
// MODAL: SUPERMARKT-BEREICHE VERWALTUNG
// ==========================================
@@ -1638,19 +1799,29 @@
const ul = document.getElementById('cat-manage-list');
if (!ul) return;
ul.innerHTML = allCategories.map((cat, idx) => `
<li style="display: flex; align-items: center; justify-content: space-between; padding: 7px 12px; background: #FFFFFF; border: 1.5px solid rgba(34,34,34,0.15); border-radius: 8px;">
<div style="display: flex; align-items: center; gap: 8px; font-weight: 600;">
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<span>${escapeHtml(cat.name)}</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, -1)" ${idx === 0 ? 'disabled style="opacity:0.3;"' : ''}>▲</button>
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, 1)" ${idx === allCategories.length - 1 ? 'disabled style="opacity:0.3;"' : ''}>▼</button>
<button type="button" class="cb-btn cb-btn-danger" style="padding: 2px 7px; font-size: 0.85rem; background:#b02a37; border-color:#b02a37; color:#fff;" onclick="deleteCategory(${cat.id})">✕</button>
</div>
</li>
`).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 `
<li style="display: flex; align-items: center; justify-content: space-between; padding: 7px 12px; background: #FFFFFF; border: 1.5px solid rgba(34,34,34,0.15); border-radius: 8px; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; font-weight: 600; min-width: 0; flex: 1;">
<span class="shop-cat-step-num">${stepNum}.</span>
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<div style="min-width: 0; display: flex; flex-direction: column;">
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(cat.name)}</span>
<span style="font-size: 0.72rem; color: #777; font-weight: 500;">Typ: ${escapeHtml(shelfLabel)}</span>
</div>
</div>
<div style="display: flex; align-items: center; gap: 4px; flex-shrink: 0;">
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, -1)" ${idx === 0 ? 'disabled style="opacity:0.3;"' : ''}>▲</button>
<button type="button" class="cb-btn cb-btn-secondary" style="padding: 2px 7px; font-size: 0.85rem;" onclick="moveCategory(${cat.id}, 1)" ${idx === allCategories.length - 1 ? 'disabled style="opacity:0.3;"' : ''}>▼</button>
<button type="button" class="cb-btn cb-btn-danger" style="padding: 2px 7px; font-size: 0.85rem; background:#b02a37; border-color:#b02a37; color:#fff;" onclick="deleteCategory(${cat.id})">✕</button>
</div>
</li>
`;
}).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 <strong>${escapeHtml(item.item_name)}</strong> 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 `
<button type="button" class="quick-shelf-btn ${isCurrent ? 'is-current' : ''}" onclick="applyQuickShelfChange('${escapeAttr(cat.name)}')">
<span class="shop-cat-step-num">${stepNum}.</span>
<span style="font-size: 1.25rem;">${cat.icon || '🛒'}</span>
<span style="flex: 1; text-align: left; font-weight: 600;">${escapeHtml(cat.name)}</span>
${isCurrent ? '<span style="font-size: 0.8rem; color: var(--shop-green); font-weight: 700;">✓ Hier</span>' : ''}
</button>
`;
}).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' ? '<option value="">🪄 Automatisch erkennen</option>' : '';
allCategories.forEach(cat => {
allCategories.forEach((cat, idx) => {
const isSel = (cat.name === selectedValue) ? 'selected' : '';
html += `<option value="${escapeAttr(cat.name)}" ${isSel}>${cat.icon || '🛒'} ${escapeHtml(cat.name)}</option>`;
const stepNum = cat.sort_order || (idx + 1);
html += `<option value="${escapeAttr(cat.name)}" ${isSel}>${stepNum}. ${cat.icon || '🛒'} ${escapeHtml(cat.name)}</option>`;
});
if (!allCategories.some(c => c.name === 'Sonstiges')) {
+91
View File
@@ -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()