From f7590cc76dc29fc6b434acff1be81332fc7bebb9 Mon Sep 17 00:00:00 2001
From: kobold-ai
Date: Tue, 20 Oct 2026 13:16:05 +0200
Subject: [PATCH] Implement supermarket walking path (Laufwege & Regale),
presets, and 1-tap shelf reassignment
---
app/database.py | 179 +++++++++----
app/main.py | 16 +-
app/templates/shopping_list.html | 327 ++++++++++++++++++++---
scripts/test_walking_path_and_presets.py | 91 +++++++
4 files changed, 525 insertions(+), 88 deletions(-)
create mode 100644 scripts/test_walking_path_and_presets.py
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 @@
+
+
+
+
+
+
+
+
+ Wohin gehört dieser Artikel im Supermarkt?
+
+
+
+
+
+
+
+
+
+
+
@@ -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)}
-
- ` : ''}
+ ` : ''}
+