Implement supermarket walking path (Laufwege & Regale), presets, and 1-tap shelf reassignment
This commit is contained in:
+126
-53
@@ -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
@@ -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,
|
||||
|
||||
@@ -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">×</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')) {
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user