Vorrat: Mindestbestand, Sortierung nach Laden, Rezeptsammlung mit Vorschlägen

- Mindestbestand pro Liste/Produkt/Einheit (Tabelle stock_minimums): fällt
  der Vorrat darunter, füllt der Server die Einkaufsliste genau bis zur
  fehlenden Menge auf (nach Ändern, Aufgebraucht, Löschen, Setzen)
- Laden-Kategorien (kategorien.py): automatische Zuordnung über Stichwörter
  (längstes Stichwort gewinnt), änderbar in der Zeile und im
  Vorschläge-Dialog; Einkaufsliste "A–Z" oder "Nach Laden" gruppiert;
  Reihenfolge der Abteilungen pro Liste
- Rezeptsammlung (Tabelle recipes): "☆ Merken" beim Abgleich, "Was kann ich
  kochen?" sortiert gemerkte Rezepte nach vorhandenen Zutaten (Salz & Co.
  zählen nicht, nur sichtbare Produkte), vollständiger Abgleich per Klick
- Migration Schritt 3 (Kategorien für bestehende Produkte); _add_column für
  Tabellen, die SCHEMA in alten Datenbanken schon neu angelegt hat
- Tests in test_vorrat_extras.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:17:52 +02:00
1 parent 9df0e38f52
commit 9835f5e69e
15 files changed
+872 -33

No files matched your search

+12 -1
View File
@@ -156,6 +156,16 @@ und einen eigenen Einkaufszettel.
Administratoren aber nur bei Listen, die sie sehen. Löschen entfernt Vorrat und Einkauf der Liste.
- Beim Update werden der bisherige Vorrat und die bisherige Einkaufsliste zur Liste „Zuhause“
(für alle sichtbar).
- **Mindestbestand** („⚑ Mindestbestand“ im Vorrat): pro Produkt eine Untergrenze, z. B. „Milch: immer 2“.
Fällt der Vorrat darunter (weniger, aufgebraucht, gelöscht), kommt genau die fehlende Menge auf die
Einkaufsliste. Die Zeile im Vorrat zeigt „min 2“, darunter hervorgehoben.
- **Nach Laden sortieren:** Die Einkaufsliste lässt sich nach Abteilungen gruppieren (Obst & Gemüse,
Kühlregal …). Neue Produkte bekommen ihre Kategorie automatisch (Stichwörter, `backend/app/kategorien.py`),
ändern lässt sie sich direkt in der Zeile oder unter „Vorschläge verwalten“. „⇅ Reihenfolge“ legt pro
Liste fest, in welcher Reihenfolge man durch seinen Laden geht.
- **Rezeptsammlung:** Ein abgeglichenes Rezept lässt sich mit „☆ Merken“ speichern (für alle Konten).
„Was kann ich kochen?“ zeigt die gemerkten Rezepte, sortiert danach, wie viele Zutaten im Vorrat der
gewählten Liste sind, und was noch fehlt; ein Klick gleicht das Rezept vollständig ab.
- **Vorschläge** beim Eintippen und die Zuordnung beim Rezeptabgleich kommen nur aus Listen, die man
sehen darf – Einträge privater Listen tauchen bei anderen nicht auf. „Vorschläge verwalten“ löscht
nur aus den eigenen sichtbaren Listen.
@@ -255,7 +265,8 @@ backend/
app/links.py Schnellzugriff-Links pro Konto
app/monitoring.py Monitoring pro Konto (HTTP/TCP-Prüfung, P42-S.M.-Auslastung + Verlauf)
app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen), Rezeptsammlung
app/kategorien.py Laden-Kategorien und automatische Zuordnung neuer Produkte
app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata
app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen, Archiv + automatisches Löschen
app/tracking/ Paketdienst-Erkennung, 17TRACK, DHL
+25 -2
View File
@@ -127,7 +127,8 @@ CREATE TABLE IF NOT EXISTS shopping_lists (
everyone INTEGER NOT NULL DEFAULT 1,
position REAL NOT NULL,
created_at TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
category_order TEXT -- JSON-Liste der Kategorien in Laden-Reihenfolge; NULL = Standard
);
CREATE TABLE IF NOT EXISTS shopping_list_members (
@@ -143,7 +144,29 @@ CREATE TABLE IF NOT EXISTS products (
key TEXT PRIMARY KEY,
name TEXT NOT NULL,
uses INTEGER NOT NULL DEFAULT 0,
last_used TEXT NOT NULL
last_used TEXT NOT NULL,
category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet
);
-- Mindestbestand: fällt der Vorrat einer Liste darunter, kommt das Produkt auf ihre Einkaufsliste
CREATE TABLE IF NOT EXISTS stock_minimums (
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
key TEXT NOT NULL,
name TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('stk', 'kg', 'l')),
minimum REAL NOT NULL CHECK (minimum > 0),
PRIMARY KEY (list_id, key, unit)
);
-- Gemerkte Rezepte (gemeinsam für alle Konten): Zutatenzeilen wie im Rezept, Abgleich passiert beim Abruf
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
source TEXT,
servings INTEGER,
lines TEXT NOT NULL, -- JSON-Liste der Zutatenzeilen
created_at TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL
);
-- Welche Produkte in welcher Liste vorkamen: Vorschläge sieht man nur aus Listen, die man sehen darf
+123 -8
View File
@@ -6,6 +6,7 @@ Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte
(Anzahl, kg, l) nur einmal; erneutes Hinzufügen in derselben Einheit erhöht die Menge. Neue Produktnamen landen im Katalog und werden als
Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch".
"""
import json
import math
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -13,6 +14,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .common import ValidationError, clean_text, now_iso, user_list
from .db import transaction
from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess
router = APIRouter()
@@ -34,6 +36,7 @@ def _item(row) -> dict:
"quantity": _out_quantity(row["quantity"], row["unit"]),
"unit": row["unit"],
"checked": bool(row["checked"]),
"category": row["category"] or guess(row["name"]),
"updatedAt": row["updated_at"],
"updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None,
}
@@ -93,7 +96,10 @@ def _canonical_name(conn, name: str, list_id=None) -> str:
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.")
# Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
name = name[0].upper() + name[1:]
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso()))
conn.execute(
"INSERT INTO products (key, name, uses, last_used, category) VALUES (?, ?, 1, ?, ?)",
(_key(name), name, now_iso(), guess(name)),
)
if list_id is not None:
conn.execute(
"""INSERT INTO list_products (list_id, key, uses, last_used) VALUES (?, ?, 1, ?)
@@ -103,16 +109,20 @@ def _canonical_name(conn, name: str, list_id=None) -> str:
return name
def visible_products(conn, user: dict) -> list:
def _visible_product_rows(conn, user: dict) -> list:
"""Produkte aus den Listen, die das Konto sehen darf – häufig genutzte zuerst."""
rows = conn.execute(
f"""SELECT p.name, SUM(lp.uses) AS uses FROM list_products lp
f"""SELECT p.name, p.category, SUM(lp.uses) AS uses FROM list_products lp
JOIN products p ON p.key = lp.key
JOIN shopping_lists l ON l.id = lp.list_id
WHERE {_VISIBLE} GROUP BY p.key""",
_who(user),
).fetchall()
return [r["name"] for r in sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))]
return sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))
def visible_products(conn, user: dict) -> list:
return [r["name"] for r in _visible_product_rows(conn, user)]
def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None:
@@ -176,9 +186,19 @@ def _shopping_list(conn, row, user: dict) -> dict:
"members": members,
"createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None,
"canManage": _can_manage(row, user),
"categoryOrder": _category_order(row["category_order"]),
}
def _category_order(value) -> list:
"""Gespeicherte Reihenfolge, ergänzt um neu hinzugekommene Kategorien (am Ende)."""
try:
order = [k for k in json.loads(value) if k in CATEGORY_KEYS] if value else []
except ValueError:
order = []
return order + [k for k in CATEGORY_KEYS if k not in order]
def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple:
"""Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern."""
name = clean_text(data.get("name"), "Name der Liste", MAX_LIST_NAME)
@@ -212,20 +232,53 @@ def _get_item(conn, item_id: int, user: dict):
def _state(conn, user: dict) -> dict:
lists = _visible_lists(conn, user)
ids = [r["id"] for r in lists]
marks = ",".join("?" * len(ids))
items = conn.execute(
f"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i
f"""SELECT i.*, u.display_name AS updated_by_name, p.category FROM household_items i
LEFT JOIN users u ON u.id = i.updated_by
WHERE i.list_id IN ({','.join('?' * len(ids))})""",
LEFT JOIN products p ON p.key = i.key
WHERE i.list_id IN ({marks})""",
ids,
).fetchall()
minimums = conn.execute(f"SELECT * FROM stock_minimums WHERE list_id IN ({marks}) ORDER BY name", ids).fetchall()
products = _visible_product_rows(conn, user)
return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
"lists": [_shopping_list(conn, r, user) for r in lists],
"users": user_list(conn),
"products": visible_products(conn, user),
"products": [r["name"] for r in products],
"productCategories": {r["name"]: r["category"] or guess(r["name"]) for r in products},
"categories": [{"key": k, "title": t} for k, t in CATEGORIES],
"minimums": [
{"listId": r["list_id"], "key": r["key"], "name": r["name"], "unit": r["unit"], "minimum": _out_quantity(r["minimum"], r["unit"])}
for r in minimums
],
}
# ---------- Mindestbestand ----------
def _refill(conn, list_id: int, key: str, unit: str, user_id: int) -> None:
"""Liegt der Vorrat unter dem Mindestbestand, die Einkaufsliste bis zur fehlenden Menge auffüllen."""
minimum = conn.execute(
"SELECT name, minimum FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, key, unit)
).fetchone()
if not minimum:
return
def amount(list_):
row = conn.execute(
"SELECT quantity FROM household_items WHERE list_id = ? AND key = ? AND list = ? AND unit = ?", (list_id, key, list_, unit)
).fetchone()
return row["quantity"] if row else 0
needed = minimum["minimum"] - amount("vorrat") - amount("einkauf")
if unit == "stk":
needed = math.ceil(needed)
needed = round(needed, 2)
if needed > 0:
_add(conn, minimum["name"], "einkauf", list_id, min(needed, MAX_QUANTITY), unit, user_id)
@router.get("/api/haushalt")
def get_household(user: dict = Depends(current_user)):
with transaction() as conn:
@@ -280,6 +333,9 @@ def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(curre
"UPDATE household_items SET quantity = ?, unit = ?, updated_at = ?, updated_by = ? WHERE id = ?",
(quantity, unit, now_iso(), user["id"], item_id),
)
if row["list"] == "vorrat":
for u in {row["unit"], unit}: # bei Einheitenwechsel beide Einheiten prüfen
_refill(conn, row["list_id"], row["key"], u, user["id"])
return _state(conn, user)
@@ -293,6 +349,7 @@ def move_item(item_id: int, user: dict = Depends(current_user)):
quantity = row["quantity"] if target == "vorrat" else 1
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
_add(conn, row["name"], target, row["list_id"], quantity, row["unit"], user["id"])
_refill(conn, row["list_id"], row["key"], row["unit"], user["id"])
return _state(conn, user)
@@ -315,8 +372,10 @@ def finish_shopping(list_id: int, user: dict = Depends(current_user)):
@router.delete("/api/haushalt/items/{item_id}")
def delete_item(item_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
_get_item(conn, item_id, user)
row = _get_item(conn, item_id, user)
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
if row["list"] == "vorrat":
_refill(conn, row["list_id"], row["key"], row["unit"], user["id"])
return _state(conn, user)
@@ -373,3 +432,59 @@ def delete_product(name: str, user: dict = Depends(current_user)):
"DELETE FROM products WHERE key = ? AND NOT EXISTS (SELECT 1 FROM list_products WHERE key = ?)", (_key(name), _key(name))
)
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/mindestbestand")
def set_minimum(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Mindestbestand setzen ({name, unit, minimum}); minimum 0 oder null entfernt ihn."""
name = _clean_name(data.get("name"))
unit = _unit(data.get("unit"))
with transaction() as conn:
_get_list(conn, list_id, user)
if data.get("minimum") in (None, 0):
conn.execute("DELETE FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, _key(name), unit))
return _state(conn, user)
minimum = _quantity(data.get("minimum"), unit)
name = _canonical_name(conn, name, list_id)
conn.execute(
"""INSERT INTO stock_minimums (list_id, key, name, unit, minimum) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (list_id, key, unit) DO UPDATE SET minimum = excluded.minimum, name = excluded.name""",
(list_id, _key(name), name, unit, minimum),
)
_refill(conn, list_id, _key(name), unit, user["id"])
return _state(conn, user)
# ---------- Laden-Kategorien ----------
@router.put("/api/haushalt/produkte/kategorie")
def set_category(data: dict = Body(...), user: dict = Depends(current_user)):
"""Kategorie eines Produkts ändern – nur für Produkte aus Listen, die das Konto sieht."""
if data.get("category") not in CATEGORY_KEYS:
raise ValidationError("Unbekannte Kategorie.")
with transaction() as conn:
ids = [r["id"] for r in _visible_lists(conn, user)]
key = _key(_clean_name(data.get("name")))
visible = conn.execute(
f"SELECT 1 FROM household_items WHERE key = ? AND list_id IN ({','.join('?' * len(ids))}) "
f"UNION SELECT 1 FROM list_products WHERE key = ? AND list_id IN ({','.join('?' * len(ids))})",
(key, *ids, key, *ids),
).fetchone()
if not visible:
raise HTTPException(404, "Produkt nicht gefunden.")
conn.execute("UPDATE products SET category = ? WHERE key = ?", (data["category"], key))
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/reihenfolge")
def set_category_order(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Reihenfolge der Kategorien für den Laden dieser Liste (alle, die die Liste sehen, dürfen sie anpassen)."""
order = data.get("order")
if not isinstance(order, list) or sorted(order) != sorted(CATEGORY_KEYS):
raise ValidationError("Die Reihenfolge muss jede Kategorie genau einmal enthalten.")
with transaction() as conn:
_get_list(conn, list_id, user)
conn.execute("UPDATE shopping_lists SET category_order = ? WHERE id = ?", (json.dumps(order), list_id))
return _state(conn, user)
+88
View File
@@ -0,0 +1,88 @@
"""Laden-Kategorien für die Einkaufsliste und eine Zuordnung neuer Produkte über Stichwörter.
Die Zuordnung ist nur ein Vorschlag: Jedes Produkt lässt sich in der Oberfläche umsortieren,
die Reihenfolge der Kategorien kann jede Liste an ihren Laden anpassen.
"""
import re
# Standard-Reihenfolge = typischer Weg durch einen Supermarkt
CATEGORIES = [
("obst", "Obst & Gemüse"),
("brot", "Brot & Backwaren"),
("kuehl", "Kühlregal"),
("fleisch", "Fleisch & Fisch"),
("tiefkuehl", "Tiefkühl"),
("vorrat", "Nudeln, Reis & Konserven"),
("backen", "Backen & Gewürze"),
("suess", "Süßes & Snacks"),
("getraenke", "Getränke"),
("drogerie", "Drogerie & Haushalt"),
("sonstiges", "Sonstiges"),
]
KEYS = [k for k, _ in CATEGORIES]
# Stichwörter (Wortanfang oder ganzes Wort, klein geschrieben); das erste passende gewinnt
_WORDS = {
"tiefkuehl": ["tiefkühl", "tk-", "tk ", "pizza", "eis", "pommes", "fischstäbchen", "rahmspinat"],
"obst": [
"apfel", "äpfel", "banane", "birne", "orange", "zitrone", "limette", "mandarine", "traube", "beere", "erdbeer",
"kiwi", "mango", "ananas", "melone", "pfirsich", "nektarine", "kirsche", "pflaume", "avocado", "tomate",
"gurke", "salat", "paprika", "zwiebel", "knoblauch", "karotte", "möhre", "kartoffel", "zucchini", "aubergine",
"brokkoli", "blumenkohl", "kohl", "spinat", "lauch", "porree", "sellerie", "pilz", "champignon", "ingwer",
"petersilie", "schnittlauch", "basilikum", "dill", "kräuter", "radieschen", "rucola", "mais", "kürbis",
"rote bete", "spargel", "fenchel", "frühlingszwiebel", "obst", "gemüse", "lorbeer",
],
"brot": ["brot", "brötchen", "toast", "baguette", "croissant", "brezel", "laugen", "knäcke", "kuchen", "semmel"],
"kuehl": [
"milch", "joghurt", "quark", "käse", "butter", "sahne", "schmand", "crème", "creme fraiche", "frischkäse",
"mozzarella", "feta", "parmesan", "gouda", "skyr", "kefir", "ei", "eier", "eigelb", "eiweiß", "margarine", "pudding", "tofu",
"hefe", "aufschnitt", "wurst", "schinken", "salami",
],
"fleisch": [
"fleisch", "hack", "hähnchen", "huhn", "pute", "rind", "schwein", "steak", "schnitzel", "filet", "lachs",
"fisch", "thunfisch", "garnele", "speck", "bratwurst", "würstchen", "kotelett", "gulasch",
],
"vorrat": [
"nudel", "spaghetti", "pasta", "penne", "reis", "linsen", "bohnen", "kichererbsen", "konserve", "dose",
"tomatenmark", "passierte", "brühe", "fond", "couscous", "bulgur", "haferflocken", "müsli", "cornflakes",
"ketchup", "senf", "mayonnaise", "essig", "öl", "olivenöl", "soße", "sauce", "pesto", "honig", "marmelade",
"nutella", "erdnussbutter", "suppe",
],
"backen": [
"mehl", "zucker", "backpulver", "natron", "vanille", "puderzucker", "speisestärke", "kakao", "salz", "pfeffer",
"zimt", "paprikapulver", "gewürz", "curry", "oregano", "muskat", "kümmel", "chili", "gelatine", "rosinen",
"mandeln", "nüsse", "walnüsse", "haselnüsse", "kokos", "streusel", "schokostreusel", "kuvertüre", "butterschmalz",
],
"suess": ["schokolade", "chips", "keks", "gummibärchen", "bonbon", "riegel", "popcorn", "nüsschen", "süßigkeit", "salzstangen"],
"getraenke": [
"wasser", "saft", "cola", "limo", "bier", "wein", "sekt", "kaffee", "tee", "sprudel", "schorle", "energy",
"rotwein", "weißwein", "smoothie",
],
"drogerie": [
"toilettenpapier", "klopapier", "küchenrolle", "taschentuch", "spülmittel", "waschmittel", "weichspüler",
"seife", "shampoo", "duschgel", "zahnpasta", "zahnbürste", "deo", "müllbeutel", "schwamm", "reiniger",
"creme", "pflaster", "windel", "batterie", "alufolie", "frischhaltefolie", "backpapier", "spülmaschine",
"tabs", "kondom", "test", "tampon", "binde", "rasier",
],
}
def guess(name: str) -> str:
"""Kategorie für einen Produktnamen raten. Das längste passende Stichwort gewinnt
("Tomatenmark" → Konserven statt "Tomate" → Gemüse). Stichwörter zählen am Wortanfang und
– ab 4 Buchstaben – auch am Wortende ("Vollmilch"); kurze (bis 3 Buchstaben) nur als ganzes Wort ("Ei", "Öl")."""
text = name.casefold()
words = re.findall(r"[\wäöüß]+", text)
best, best_len = "sonstiges", 0
for key, stems in _WORDS.items():
for stem in stems:
s = stem.strip()
if " " in s or s.endswith("-"):
hit = s in text
elif len(s) <= 3:
hit = s in words
else:
hit = any(w.startswith(s) or w.endswith(s) for w in words)
if hit and len(s) > best_len:
best, best_len = key, len(s)
return best
+21
View File
@@ -4,6 +4,8 @@ SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Sche
1. SCHEMA in db.py anpassen – neue Datenbanken entstehen direkt so und bekommen die höchste Nummer.
2. Unten an STEPS eine Funktion anhängen, die eine bestehende Datenbank vom vorigen Stand dorthin bringt.
Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer.
Achtung: Eine Tabelle, die es in einer alten Datenbank noch gar nicht gab, legt SCHEMA schon in der
neuesten Form an. Spalten deshalb mit _add_column() ergänzen (fügt nur hinzu, was fehlt).
Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen).
"""
import logging
@@ -14,6 +16,7 @@ from .common import ValidationError, now_iso
from .config import DATA_DIR
from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE
from .htmlclean import sanitize
from .kategorien import guess
log = logging.getLogger(__name__)
@@ -32,6 +35,12 @@ def upgrade(conn) -> None:
conn.execute(f"PRAGMA user_version = {number}")
def _add_column(conn, table: str, column: str, definition: str) -> None:
"""Spalte ergänzen, falls sie fehlt (table/column/definition stammen nur aus diesem Modul)."""
if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def _rebuild(conn, table: str, definition: str, columns: str, select: str, params=()) -> None:
"""Tabelle neu aufbauen: neue Tabelle unter Zwischennamen anlegen, Daten kopieren, alte löschen, umbenennen.
@@ -229,8 +238,20 @@ def _v2_datenschutz(conn) -> None:
log.warning("Bild %s konnte nicht bereinigt werden: %s", f.name, e)
# ---------- Schritt 3: Laden-Kategorien ----------
def _v3_kategorien(conn) -> None:
"""Produkte bekommen eine Laden-Kategorie (geraten), Listen eine eigene Kategorie-Reihenfolge."""
_add_column(conn, "products", "category", "TEXT")
_add_column(conn, "shopping_lists", "category_order", "TEXT")
for row in conn.execute("SELECT key, name FROM products").fetchall():
conn.execute("UPDATE products SET category = ? WHERE key = ?", (guess(row["name"]), row["key"]))
# Reihenfolge = Versionsnummer (Index + 1). Nur anhängen, nie umsortieren oder entfernen.
STEPS = [
_v1_altbestand,
_v2_datenschutz,
_v3_kategorien,
]
+99 -2
View File
@@ -12,12 +12,12 @@ import socket
from urllib.parse import urljoin, urlsplit
import httpx
from fastapi import APIRouter, Body, Depends
from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .db import transaction
from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit, visible_products
from .common import ValidationError
from .common import ValidationError, clean_text, now_iso
router = APIRouter()
@@ -26,6 +26,8 @@ MAX_REDIRECTS = 3
TIMEOUT = 10
MAX_INGREDIENTS = 80
MAX_TEXT = 10_000
MAX_LINE = 300
MAX_RECIPES = 500
# ---------- Seite abrufen ----------
@@ -291,6 +293,8 @@ def _result(conn, user, title, source, servings, raw_ingredients) -> dict:
"source": source,
"servings": servings,
"ingredients": [match(i) for i in merge(parsed)],
# Zeilen wie im Rezept – damit lässt es sich merken und später neu abgleichen
"lines": [str(r)[:MAX_LINE] for r in raw_ingredients[:MAX_INGREDIENTS]],
}
@@ -360,3 +364,96 @@ def take_over(data: dict = Body(...), user: dict = Depends(current_user)):
for name, quantity, unit in checked:
_add(conn, _canonical_name(conn, name, list_id), "einkauf", list_id, quantity, unit, user["id"])
return _state(conn, user)
# ---------- Rezeptsammlung: merken und nach Vorrat vorschlagen ----------
def _recipe_fields(data: dict) -> tuple:
title = clean_text(data.get("title"), "Titel", 200)
source = data.get("source") or None
if source is not None and (not isinstance(source, str) or not re.match(r"^https?://\S+$", source) or len(source) > 500):
raise ValidationError("Ungültiger Rezept-Link.")
servings = data.get("servings")
if servings is not None and (isinstance(servings, bool) or not isinstance(servings, int) or not 0 < servings <= 100):
servings = None
lines = data.get("lines")
if not isinstance(lines, list) or not lines or len(lines) > MAX_INGREDIENTS or not all(isinstance(x, str) for x in lines):
raise ValidationError("Das Rezept hat keine gültigen Zutaten.")
return title, source, servings, [x.strip()[:MAX_LINE] for x in lines if x.strip()]
def _suggestion(row, match, stock: set, user: dict) -> dict:
"""Wie viele Zutaten sind im Vorrat? Salz, Wasser & Co. und optionale Zutaten zählen nicht."""
parsed = merge([p for p in (parse_ingredient(x) for x in json.loads(row["lines"])) if p])
have, missing = 0, []
counted = [i for i in parsed if not i["staple"] and not i["optional"]]
for ing in counted:
names = [ing["name"], *ing["alternatives"]]
found = [m["product"] for m in (match({**ing, "name": n, "alternatives": []}) for n in names) if m["known"]]
if any(_key(f) in stock for f in found):
have += 1
else:
missing.append(found[0] if found else ing["name"])
return {
"id": row["id"], "title": row["title"], "source": row["source"], "servings": row["servings"],
"total": len(counted), "have": have, "missing": missing,
"canDelete": user["isAdmin"] or row["created_by"] == user["id"],
}
@router.get("/api/haushalt/rezepte")
def list_recipes(listId: int, user: dict = Depends(current_user)):
"""Gemerkte Rezepte, sortiert danach, wie viel davon im Vorrat der Liste ist."""
with transaction() as conn:
_get_list(conn, listId, user)
stock = {r["key"] for r in conn.execute(
"SELECT key FROM household_items WHERE list_id = ? AND list = 'vorrat'", (listId,)
)}
match = _matcher(conn, user)
rows = conn.execute("SELECT * FROM recipes ORDER BY title COLLATE NOCASE").fetchall()
result = [_suggestion(r, match, stock, user) for r in rows]
return sorted(result, key=lambda r: (-(r["have"] / r["total"] if r["total"] else 1), len(r["missing"]), r["title"].casefold()))
@router.post("/api/haushalt/rezepte", status_code=201)
def save_recipe(data: dict = Body(...), user: dict = Depends(current_user)):
"""Rezept merken; gleicher Link = dasselbe Rezept (wird aktualisiert)."""
title, source, servings, lines = _recipe_fields(data)
with transaction() as conn:
existing = conn.execute("SELECT id FROM recipes WHERE source = ?", (source,)).fetchone() if source else None
if existing:
conn.execute(
"UPDATE recipes SET title = ?, servings = ?, lines = ? WHERE id = ?",
(title, servings, json.dumps(lines, ensure_ascii=False), existing["id"]),
)
return {"id": existing["id"]}
if conn.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] >= MAX_RECIPES:
raise ValidationError(f"Es sind schon {MAX_RECIPES} Rezepte gemerkt. Bitte alte löschen.")
recipe_id = conn.execute(
"INSERT INTO recipes (title, source, servings, lines, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)",
(title, source, servings, json.dumps(lines, ensure_ascii=False), now_iso(), user["id"]),
).lastrowid
return {"id": recipe_id}
@router.get("/api/haushalt/rezepte/{recipe_id}")
def get_recipe(recipe_id: int, user: dict = Depends(current_user)):
"""Gemerktes Rezept vollständig abgleichen (ohne die Seite erneut abzurufen)."""
with transaction() as conn:
row = conn.execute("SELECT * FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
if not row:
raise HTTPException(404, "Rezept nicht gefunden.")
return {**_result(conn, user, row["title"], row["source"], row["servings"], json.loads(row["lines"])), "id": row["id"]}
@router.delete("/api/haushalt/rezepte/{recipe_id}")
def delete_recipe(recipe_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
row = conn.execute("SELECT created_by FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
if not row:
raise HTTPException(404, "Rezept nicht gefunden.")
if not user["isAdmin"] and row["created_by"] != user["id"]:
raise HTTPException(403, "Nur wer das Rezept gemerkt hat (oder ein Administrator) kann es löschen.")
conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,))
return {"ok": True}
+2 -1
View File
@@ -156,6 +156,7 @@ def test_problembilder_verhindern_start_nicht(conn):
with conn:
conn.execute("PRAGMA user_version = 1")
db.init()
assert conn.execute("PRAGMA user_version").fetchone()[0] == 2
from app import migrations
assert conn.execute("PRAGMA user_version").fetchone()[0] == len(migrations.STEPS)
finally:
gesperrt.chmod(0o644)
+133
View File
@@ -0,0 +1,133 @@
"""Mindestbestand, Laden-Kategorien und Rezeptsammlung."""
from app.kategorien import KEYS
from test_haushalt import add, items, new_list, state
def home(client):
return state(client)["lists"][0]["id"]
def amounts(data, list_id, name):
return {(i["list"], i["unit"]): i["quantity"] for i in items(data, list_id) if i["name"] == name}
def set_min(client, list_id, name, minimum, unit="stk"):
res = client.put(f"/api/haushalt/listen/{list_id}/mindestbestand", json={"name": name, "unit": unit, "minimum": minimum})
assert res.status_code == 200, res.text
return res.json()
# ---------- Mindestbestand ----------
def test_mindestbestand_fuellt_einkauf_auf(login):
anna = login("anna")
lid = home(anna)
milch = items(add(anna, lid, "Milch", area="vorrat", quantity=3).json(), lid)[0]
data = set_min(anna, lid, "Milch", 2)
assert amounts(data, lid, "Milch") == {("vorrat", "stk"): 3} # genug da
data = anna.patch(f"/api/haushalt/items/{milch['id']}", json={"quantity": 1}).json()
assert amounts(data, lid, "Milch") == {("vorrat", "stk"): 1, ("einkauf", "stk"): 1}
data = anna.delete(f"/api/haushalt/items/{milch['id']}").json() # alles weg → auf 2 auffüllen
assert amounts(data, lid, "Milch") == {("einkauf", "stk"): 2}
assert data["minimums"] == [{"listId": lid, "key": "milch", "name": "Milch", "unit": "stk", "minimum": 2}]
def test_mindestbestand_aufgebraucht_und_sofort(login):
anna = login("anna")
lid = home(anna)
mehl = items(add(anna, lid, "Mehl", area="vorrat", quantity=1, unit="kg").json(), lid)[0]
set_min(anna, lid, "Mehl", 2.5, unit="kg") # schon darunter → sofort
data = state(anna)
assert amounts(data, lid, "Mehl") == {("vorrat", "kg"): 1, ("einkauf", "kg"): 1.5}
data = anna.post(f"/api/haushalt/items/{mehl['id']}/move").json() # aufgebraucht
assert amounts(data, lid, "Mehl") == {("einkauf", "kg"): 2.5}
data = set_min(anna, lid, "Mehl", 0, unit="kg")
assert data["minimums"] == []
def test_mindestbestand_nur_mit_listenzugriff(login):
anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False)
res = ben.put(f"/api/haushalt/listen/{privat}/mindestbestand", json={"name": "x", "unit": "stk", "minimum": 1})
assert res.status_code == 404
# ---------- Kategorien ----------
def test_kategorie_geraten_und_aenderbar(login):
anna = login("anna")
lid = home(anna)
data = add(anna, lid, "Vollmilch").json()
assert items(data, lid)[0]["category"] == "kuehl"
assert data["productCategories"]["Vollmilch"] == "kuehl"
data = anna.put("/api/haushalt/produkte/kategorie", json={"name": "Vollmilch", "category": "getraenke"}).json()
assert items(data, lid)[0]["category"] == "getraenke"
assert anna.put("/api/haushalt/produkte/kategorie", json={"name": "Vollmilch", "category": "quatsch"}).status_code == 400
def test_kategorie_privater_produkte_nicht_aenderbar(login):
anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False)
add(anna, privat, "Geheimzutat")
assert ben.put("/api/haushalt/produkte/kategorie", json={"name": "Geheimzutat", "category": "obst"}).status_code == 404
def test_laden_reihenfolge(login):
anna = login("anna")
lid = home(anna)
assert state(anna)["lists"][0]["categoryOrder"] == KEYS
order = list(reversed(KEYS))
data = anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": order}).json()
assert data["lists"][0]["categoryOrder"] == order
assert anna.put(f"/api/haushalt/listen/{lid}/reihenfolge", json={"order": KEYS[:3]}).status_code == 400
# ---------- Rezeptsammlung ----------
def save(client, title, lines, source=None):
res = client.post("/api/haushalt/rezepte", json={"title": title, "lines": lines, "source": source, "servings": 2})
assert res.status_code == 201, res.text
return res.json()["id"]
def test_rezepte_nach_vorrat_sortiert(login):
anna = login("anna")
lid = home(anna)
for name in ("Nudeln", "Tomaten"):
add(anna, lid, name, area="vorrat")
save(anna, "Pasta", ["250 g Nudeln", "3 Tomaten", "1 Prise Salz"]) # Salz zählt nicht
save(anna, "Kuchen", ["200 g Mehl", "3 Eier", "100 g Zucker"])
res = anna.get("/api/haushalt/rezepte", params={"listId": lid}).json()
assert [(r["title"], r["have"], r["total"]) for r in res] == [("Pasta", 2, 2), ("Kuchen", 0, 3)]
assert res[1]["missing"] == ["Mehl", "Eier", "Zucker"]
def test_rezept_gleicher_link_wird_aktualisiert(login):
anna = login("anna")
a = save(anna, "Alt", ["1 Ei"], source="https://example.org/r")
b = save(anna, "Neu", ["2 Eier"], source="https://example.org/r")
assert a == b
detail = anna.get(f"/api/haushalt/rezepte/{a}").json()
assert detail["title"] == "Neu" and detail["lines"] == ["2 Eier"]
def test_rezept_loeschen_nur_ersteller_oder_admin(login):
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
a = save(anna, "A", ["1 Ei"])
b = save(anna, "B", ["1 Ei"])
assert ben.delete(f"/api/haushalt/rezepte/{a}").status_code == 403
assert anna.delete(f"/api/haushalt/rezepte/{a}").status_code == 200
assert chef.delete(f"/api/haushalt/rezepte/{b}").status_code == 200
def test_rezept_vorschlag_verraet_keine_privaten_produkte(login):
anna, ben = login("anna"), login("ben")
privat = new_list(anna, "Privat", everyone=False)
add(anna, privat, "Kondome", area="vorrat")
save(ben, "Test", ["2 Kondom"])
res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json()
assert res[0]["missing"] == ["Kondom"] # nicht Annas Schreibweise "Kondome"
@@ -0,0 +1,39 @@
import { useState } from 'react';
// Reihenfolge der Abteilungen so, wie man durch den eigenen Laden läuft (gilt für die gewählte Liste)
export default function CategoryOrderDialog({ ref, categories, order, onSave, busy }) {
const [current, setCurrent] = useState(order);
const title = Object.fromEntries(categories.map((c) => [c.key, c.title]));
const move = (index, delta) => setCurrent((list) => {
const next = [...list];
[next[index], next[index + delta]] = [next[index + delta], next[index]];
return next;
});
async function save() {
if (await onSave(current)) ref.current.close();
}
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Reihenfolge im Laden</h2>
<p className="hint">So sortiert „Nach Laden“ die Einkaufsliste – am besten in der Reihenfolge, in der du durch den Markt gehst.</p>
<ol className="order-list">
{current.map((key, i) => (
<li key={key}>
<span>{title[key]}</span>
<button className="link-btn" type="button" disabled={i === 0} onClick={() => move(i, -1)} aria-label={`${title[key]} nach oben`}>↑</button>
<button className="link-btn" type="button" disabled={i === current.length - 1} onClick={() => move(i, 1)} aria-label={`${title[key]} nach unten`}>↓</button>
</li>
))}
</ol>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => setCurrent(categories.map((c) => c.key))}>Standard</button>
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="button" disabled={busy} onClick={save}>Speichern</button>
</div>
</div>
</dialog>
);
}
+78 -8
View File
@@ -10,12 +10,41 @@ export const LISTS = {
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
};
export default function ListPanel({ list, items, products, find, actions, busy, hidden }) {
const same = (a, b) => a.toLocaleLowerCase('de-DE') === b.toLocaleLowerCase('de-DE');
/**
* extras: { minimums, categories, categoryOrder, sortMode, onSortMode, onEditOrder, onEditMinimums, onCategory }
* – Mindestbestand (Vorrat) und Laden-Sortierung nach Kategorien (Einkauf).
*/
export default function ListPanel({ list, items, products, find, actions, busy, hidden, extras }) {
const { title, lead, empty } = LISTS[list];
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
// Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat
const open = items.filter((i) => !i.checked);
const inCart = items.filter((i) => i.checked);
const byShop = list === 'einkauf' && extras.sortMode === 'laden';
const minimumOf = (item) => extras.minimums.find((m) => same(m.name, item.name) && m.unit === item.unit);
const categoryTitle = Object.fromEntries(extras.categories.map((c) => [c.key, c.title]));
// Laden-Modus: nach Kategorien in der Reihenfolge dieser Liste gruppieren
const groups = byShop
? extras.categoryOrder
.map((key) => ({ key, title: categoryTitle[key], items: open.filter((i) => i.category === key) }))
.filter((g) => g.items.length)
: [{ key: 'alle', items: open }];
const row = (item) => (
<ItemRow
key={item.id}
item={item}
list={list}
other={find(other, item.name)}
actions={actions}
busy={busy}
minimum={list === 'vorrat' ? minimumOf(item) : null}
onEditMinimum={() => extras.onEditMinimums(item)}
categories={byShop ? extras.categories : null}
onCategory={extras.onCategory}
/>
);
// Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo?
function hint(name) {
@@ -39,21 +68,38 @@ export default function ListPanel({ list, items, products, find, actions, busy,
<span className="hint">{lead}</span>
</header>
{list === 'vorrat' && (
<div className="household-tools">
<button className="link-btn" type="button" onClick={() => extras.onEditMinimums(null)}>
⚑ Mindestbestand{extras.minimums.length ? ` (${extras.minimums.length})` : ''}
</button>
</div>
)}
{list === 'einkauf' && (
<div className="household-tools">
<div className="sort-toggle" role="group" aria-label="Sortierung">
{[['az', 'A–Z'], ['laden', 'Nach Laden']].map(([key, label]) => (
<button key={key} type="button" aria-pressed={extras.sortMode === key} onClick={() => extras.onSortMode(key)}>{label}</button>
))}
</div>
{byShop && <button className="link-btn" type="button" onClick={extras.onEditOrder}>⇅ Reihenfolge</button>}
</div>
)}
<AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} />
{items.length === 0 ? (
<p className="hint household-empty">{empty}</p>
) : (
<ul className="household-list">
{open.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
{groups.map((g) => [
byShop && <li key={`kat-${g.key}`} className="household-group-head">{g.title}</li>,
...g.items.map(row),
])}
{inCart.length > 0 && (
<li className="household-cart-head" aria-hidden="true">Im Wagen</li>
)}
{inCart.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
{inCart.map(row)}
</ul>
)}
@@ -159,7 +205,7 @@ function QuantityInput({ item, label, onCommit, disabled }) {
);
}
function ItemRow({ item, list, other, actions, busy }) {
function ItemRow({ item, list, other, actions, busy, minimum, onEditMinimum, categories, onCategory }) {
const isShopping = list === 'einkauf';
const step = STEP[item.unit];
const last = item.quantity - step <= 0;
@@ -184,8 +230,32 @@ function ItemRow({ item, list, other, actions, busy }) {
<strong>{item.name}</strong>
<small>
{other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `}
{minimum && (
<>
<button
type="button"
className={`min-badge${item.quantity < minimum.minimum ? ' below' : ''}`}
onClick={onEditMinimum}
title={item.quantity < minimum.minimum ? 'Unter dem Mindestbestand' : 'Mindestbestand'}
>
min {String(minimum.minimum).replace('.', ',')}
</button>
{' · '}
</>
)}
✎ {personName(item.updatedBy)}
</small>
{categories && (
<select
className="unit-select category-select"
value={item.category}
onChange={(e) => onCategory(item.name, e.target.value)}
disabled={busy}
aria-label={`Kategorie von ${item.name}`}
>
{categories.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select>
)}
</div>
<div className="stepper" role="group" aria-label={`Menge ${label}`}>
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { parseQty } from '../../units.js';
import ProductInput from './ProductInput.jsx';
import UnitSelect from './UnitSelect.jsx';
/**
* Mindestbestände der gewählten Liste: fällt der Vorrat darunter, kommt das Produkt automatisch auf die
* Einkaufsliste (bis zur fehlenden Menge). start: Produkt zum Vorbelegen ({ name, unit, quantity }) oder null.
*/
export default function MinimumDialog({ ref, minimums, products, start, onSave, busy }) {
const current = start && minimums.find((m) => m.name.toLocaleLowerCase('de-DE') === start.name.toLocaleLowerCase('de-DE') && m.unit === start.unit);
const [name, setName] = useState(start?.name || '');
const [unit, setUnit] = useState(start?.unit || 'stk');
const [qty, setQty] = useState(current ? String(current.minimum).replace('.', ',') : '');
const [invalid, setInvalid] = useState(false);
async function submit(e) {
e.preventDefault();
const q = parseQty(qty, unit);
setInvalid(q == null || !name.trim());
if (q == null || !name.trim()) return;
if (await onSave(name.trim(), unit, q)) { setName(''); setQty(''); }
}
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Mindestbestand</h2>
<p className="hint">Fällt der Vorrat unter diese Menge, kommt das Produkt automatisch auf die Einkaufsliste – genau so viel, wie fehlt.</p>
{minimums.length > 0 && (
<ul className="product-list">
{minimums.map((m) => (
<li key={`${m.key}-${m.unit}`}>
<span>{m.name} <small>immer {String(m.minimum).replace('.', ',')} {m.unit === 'stk' ? 'Stück' : m.unit}</small></span>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onSave(m.name, m.unit, 0)} aria-label={`Mindestbestand ${m.name} entfernen`}>
Entfernen
</button>
</li>
))}
</ul>
)}
<form className="household-add" onSubmit={submit} noValidate>
<label className="sr-only" htmlFor="min-name">Produkt</label>
<ProductInput id="min-name" value={name} onChange={setName} products={products} placeholder="Produkt, z. B. Milch" maxLength={60} required />
<div className="qty-unit">
<label className="sr-only" htmlFor="min-qty">Mindestmenge</label>
<input id="min-qty" className="household-qty-input" type="text" inputMode="decimal" value={qty} placeholder="2"
onChange={(e) => { setQty(e.target.value); setInvalid(false); }} aria-invalid={invalid || undefined} />
<UnitSelect id="min-unit" value={unit} onChange={setUnit} />
</div>
<button className="btn" type="submit" disabled={busy}>Speichern</button>
{invalid && <p className="error household-add-error">Produkt und Menge angeben (Anzahl: ganze Zahl, kg/l: z. B. 0,5).</p>}
</form>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
</div>
</div>
</dialog>
);
}
@@ -1,18 +1,28 @@
// Vorschläge (Produktkatalog) aufräumen, z. B. Tippfehler; Einträge auf den Listen bleiben
export default function ProductsDialog({ ref, products, onDelete }) {
export default function ProductsDialog({ ref, products, categories = [], productCategories = {}, onCategory, onDelete }) {
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Vorschläge verwalten</h2>
<p className="hint">
Tippfehler oder doppelte Schreibweisen hier löschen. Einträge auf den Listen bleiben erhalten.
Kategorie für „Nach Laden“ festlegen, Tippfehler oder doppelte Schreibweisen löschen. Einträge auf den Listen bleiben erhalten.
</p>
<ul className="product-list">
{sorted.map((name) => (
<li key={name}>
<span>{name}</span>
{categories.length > 0 && (
<select
className="unit-select category-select"
value={productCategories[name] || 'sonstiges'}
onChange={(e) => onCategory(name, e.target.value)}
aria-label={`Kategorie von ${name}`}
>
{categories.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select>
)}
<button className="link-btn danger" type="button" onClick={() => onDelete(name)} aria-label={`Vorschlag ${name} löschen`}>
Löschen
</button>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../api.js';
import { ceil2, fmtItems, fmtQty, parseQty } from '../../units.js';
import ProductInput from './ProductInput.jsx';
@@ -23,20 +23,30 @@ function loadPeople() {
* Rezept abgleichen: Zutaten laden, auf Personenzahl umrechnen, mit dem Vorrat vergleichen
* und Fehlendes in den Einkauf der gewählten Liste setzen. find(list, name) sucht in Vorrat bzw. Einkauf dieser Liste.
*/
export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy }) {
/**
* stockKey: ändert sich, wenn sich der Vorrat ändert – dann werden die Rezeptvorschläge neu bewertet.
*/
export default function RecipeCheck({ products, find, shoppingList, onTakeOver, busy, stockKey }) {
const [mode, setMode] = useState('url'); // url | text
const [url, setUrl] = useState('');
const [text, setText] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [recipe, setRecipe] = useState(null);
const [saved, setSaved] = useState(null); // gemerkte Rezepte mit Bewertung nach Vorrat
async function load(e) {
e.preventDefault();
setLoading(true);
setError(null);
const listId = shoppingList?.id;
const loadSaved = useCallback(async () => {
if (!listId) return;
try {
const data = await api('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text });
setSaved(await api('GET', `/api/haushalt/rezepte?listId=${listId}`));
} catch {
setSaved([]);
}
}, [listId]);
useEffect(() => { loadSaved(); }, [loadSaved, stockKey]);
function show(data) {
// Ohne Portionsangabe gilt die Liste für die gewählte Personenzahl, Mengen bleiben wie eingegeben
const people = loadPeople() || data.servings || 4;
setRecipe({
@@ -46,13 +56,50 @@ export default function RecipeCheck({ products, find, shoppingList, onTakeOver,
rows: data.ingredients.map((i) => ({ ...i, matched: i.product, take: null, qty: null })),
done: null,
});
}
async function request(method, path, body) {
setLoading(true);
setError(null);
try {
return await api(method, path, body);
} catch (err) {
setError(err.message);
return null;
} finally {
setLoading(false);
}
}
async function load(e) {
e.preventDefault();
const data = await request('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text });
if (data) show(data);
}
async function openSaved(id) {
const data = await request('GET', `/api/haushalt/rezepte/${id}`);
if (data) show(data);
}
async function remember() {
// Eingefügte Zutatenlisten haben keinen eigenen Titel – beim Merken nach einem Namen fragen
let title = recipe.title;
if (!recipe.source) {
title = window.prompt('Name für das Rezept:', title === 'Eigene Zutatenliste' ? '' : title)?.trim();
if (!title) return;
}
const res = await request('POST', '/api/haushalt/rezepte', {
title, source: recipe.source, servings: recipe.servings, lines: recipe.lines,
});
if (res) { setRecipe((r) => ({ ...r, id: res.id, title })); loadSaved(); }
}
async function forget(r) {
if (!window.confirm(`Rezept „${r.title}“ aus der Sammlung löschen?`)) return;
if (await request('DELETE', `/api/haushalt/rezepte/${r.id}`)) loadSaved();
}
if (!recipe) {
return (
<section className="card recipe" aria-labelledby="recipe-title">
@@ -86,6 +133,25 @@ export default function RecipeCheck({ products, find, shoppingList, onTakeOver,
<button className="btn" type="submit" disabled={loading}>{loading ? 'Lade …' : 'Zutaten prüfen'}</button>
</form>
{error && <p className="error" role="alert">{error}</p>}
{saved?.length > 0 && (
<div className="recipe-saved">
<h3>Was kann ich kochen? <small>– gemerkte Rezepte, nach Vorrat sortiert</small></h3>
<ul>
{saved.map((r) => (
<li key={r.id} className={r.have === r.total ? 'complete' : undefined}>
<button type="button" className="recipe-saved-title" onClick={() => openSaved(r.id)} disabled={loading}>{r.title}</button>
<span className="recipe-score">{r.total ? `${r.have}/${r.total} da` : 'nur Grundzutaten'}</span>
<small className="recipe-missing">
{r.missing.length ? `fehlt: ${r.missing.slice(0, 4).join(', ')}${r.missing.length > 4 ? ` +${r.missing.length - 4}` : ''}` : 'alles da'}
</small>
{r.canDelete && (
<button className="link-btn danger" type="button" onClick={() => forget(r)} aria-label={`Rezept ${r.title} löschen`}>✕</button>
)}
</li>
))}
</ul>
</div>
)}
</section>
);
}
@@ -181,7 +247,12 @@ export default function RecipeCheck({ products, find, shoppingList, onTakeOver,
? <a href={recipe.source} target="_blank" rel="noopener noreferrer">{recipe.title} ↗</a>
: recipe.title}
</h2>
<button className="link-btn" type="button" onClick={() => setRecipe(null)}>✕ Abgleich schließen</button>
<span className="recipe-head-actions">
{recipe.id
? <span className="hint">★ Gemerkt</span>
: <button className="link-btn" type="button" onClick={remember} disabled={loading}>☆ Merken</button>}
<button className="link-btn" type="button" onClick={() => { setRecipe(null); loadSaved(); }}>✕ Abgleich schließen</button>
</span>
</header>
<div className="recipe-people">
+71 -1
View File
@@ -1,9 +1,11 @@
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import CategoryOrderDialog from '../components/haushalt/CategoryOrderDialog.jsx';
import ListDialog from '../components/haushalt/ListDialog.jsx';
import ListPanel, { LISTS } from '../components/haushalt/ListPanel.jsx';
import ListSwitcher from '../components/haushalt/ListSwitcher.jsx';
import MinimumDialog from '../components/haushalt/MinimumDialog.jsx';
import ProductsDialog from '../components/haushalt/ProductsDialog.jsx';
import RecipeCheck from '../components/haushalt/RecipeCheck.jsx';
import { isCalm } from '../motion.js';
@@ -13,11 +15,16 @@ import useLiveData from '../useLiveData.js';
const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab';
const LIST_KEY = 'p42.haushaltListe';
const SORT_KEY = 'p42.einkaufSortierung';
function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
}
function loadSort() {
try { return localStorage.getItem(SORT_KEY) === 'laden' ? 'laden' : 'az'; } catch { return 'az'; }
}
function loadListId() {
try { return Number(localStorage.getItem(LIST_KEY)) || null; } catch { return null; }
}
@@ -29,6 +36,12 @@ export default function Haushalt() {
// Dialog für Listen: null = zu, { list: null } = neue Liste, { list } = bearbeiten
const [listDialog, setListDialog] = useState(null);
const dialog = useRef(null);
const [sortMode, setSortMode] = useState(loadSort);
// Dialoge: Mindestbestand (mit vorbelegtem Produkt) und Laden-Reihenfolge; opened erzwingt frischen Inhalt
const [minDialog, setMinDialog] = useState(null);
const [orderDialog, setOrderDialog] = useState(null);
const minRef = useRef(null);
const orderRef = useRef(null);
const listDialogRef = useRef(null);
// Jede Änderung liefert den aktuellen Stand aller Listen zurück.
@@ -80,6 +93,23 @@ export default function Haushalt() {
return Boolean(await run('DELETE', `/api/haushalt/listen/${list.id}`));
}
function chooseSort(mode) {
setSortMode(mode);
try { localStorage.setItem(SORT_KEY, mode); } catch { /* ignorieren */ }
}
function openMinimums(item) {
setMinDialog({ start: item ? { name: item.name, unit: item.unit } : null, opened: Date.now() });
requestAnimationFrame(() => minRef.current?.showModal());
}
function openOrder() {
setOrderDialog({ opened: Date.now() });
requestAnimationFrame(() => orderRef.current?.showModal());
}
const setCategory = (name, category) => run('PUT', '/api/haushalt/produkte/kategorie', { name, category });
const items = data?.items || [];
const lists = data?.lists || [];
// Gewählte Liste (z. B. Zuhause, Urlaub); gibt es sie nicht (mehr), die erste sichtbare
@@ -139,6 +169,7 @@ export default function Haushalt() {
find={find}
busy={busy}
shoppingList={current}
stockKey={byList.vorrat.map((i) => `${i.id}:${i.quantity}`).join(',')}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', { ...body, listId: current?.id })}
/>
@@ -169,6 +200,16 @@ export default function Haushalt() {
actions={actions}
busy={busy}
hidden={tab !== list}
extras={{
minimums: (data.minimums || []).filter((m) => m.listId === current.id),
categories: data.categories || [],
categoryOrder: current.categoryOrder || [],
sortMode,
onSortMode: chooseSort,
onEditOrder: openOrder,
onEditMinimums: openMinimums,
onCategory: setCategory,
}}
/>
))}
</div>
@@ -187,7 +228,36 @@ export default function Haushalt() {
/>
)}
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
<ProductsDialog
ref={dialog}
products={data?.products || []}
categories={data?.categories || []}
productCategories={data?.productCategories || {}}
onCategory={setCategory}
onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)}
/>
{minDialog && current && (
<MinimumDialog
key={minDialog.opened}
ref={minRef}
minimums={(data?.minimums || []).filter((m) => m.listId === current.id)}
products={data?.products || []}
start={minDialog.start}
busy={busy}
onSave={(name, unit, minimum) => run('PUT', `/api/haushalt/listen/${current.id}/mindestbestand`, { name, unit, minimum })}
/>
)}
{orderDialog && current && (
<CategoryOrderDialog
key={orderDialog.opened}
ref={orderRef}
categories={data?.categories || []}
order={current.categoryOrder || []}
busy={busy}
onSave={(order) => run('PUT', `/api/haushalt/listen/${current.id}/reihenfolge`, { order })}
/>
)}
</>
);
}
+30
View File
@@ -280,3 +280,33 @@ html.vt-items .household-item { view-transition-name: var(--vt); }
.household-item, .recipe-row, .product-list li { border-top-style: solid; border-top-color: var(--border); }
.household-item:hover { background: var(--accent-soft); }
.segmented .count::before, .segmented .count::after { content: none; }
/* Mindestbestand, Laden-Sortierung */
.household-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; margin-top: -6px; }
.sort-toggle { display: inline-flex; border: 1px solid var(--line-strong); border-radius: var(--radius-s); overflow: hidden; }
.sort-toggle button { font: inherit; font-size: 0.78rem; padding: 4px 10px; border: 0; background: transparent; color: var(--text); cursor: pointer; }
.sort-toggle button[aria-pressed='true'] { background: var(--accent); color: var(--accent-ink); }
.household-group-head { margin: 12px 0 2px; font-size: var(--label-size); letter-spacing: var(--label-tracking); text-transform: uppercase; color: var(--accent); }
.household-group-head:first-child { margin-top: 0; }
.min-badge { font: inherit; font-size: inherit; padding: 0; border: 0; background: none; color: var(--muted); text-decoration: underline dotted; cursor: pointer; }
.min-badge.below { color: var(--warn); font-weight: 600; }
.category-select { margin-top: 2px; max-width: 14em; font-size: 0.72rem; min-height: 22px; padding: 0 2px; color: var(--muted); background: transparent; border-color: transparent; }
.category-select:hover, .category-select:focus { border-color: var(--border); color: var(--text); }
.order-list { margin: 0; padding-left: 1.6em; display: grid; gap: 4px; }
.order-list li { display: flex; align-items: center; gap: 8px; }
.order-list li span { flex: 1; }
.product-list .category-select { margin: 0 8px 0 auto; }
/* Rezeptsammlung */
.recipe-head-actions { display: inline-flex; gap: 14px; align-items: baseline; }
.recipe-saved { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.recipe-saved h3 { margin: 0 0 8px; font-size: 0.95rem; }
.recipe-saved h3 small { font-weight: 400; color: var(--muted); text-transform: none; letter-spacing: 0; }
.recipe-saved ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.recipe-saved li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 6px 0; border-top: 1px dashed var(--border); }
.recipe-saved li:first-child { border-top: 0; }
.recipe-saved-title { font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
.recipe-saved-title:hover { color: var(--accent); }
.recipe-score { font-variant-numeric: tabular-nums; color: var(--warn); font-size: 0.82rem; }
.recipe-saved li.complete .recipe-score { color: var(--ok); }
.recipe-missing { flex: 1 1 200px; color: var(--muted); }