diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 0546193..8a82b22 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -1,6 +1,6 @@ # BLS-Lebensmittelreferenz und FDDB-Mapping -**Stand:** 2026-09-12 · **Status:** Phase 1 (Umsetzung) +**Stand:** 2026-09-14 · **Status:** Phase 1 (Umsetzung) ## WAS @@ -37,14 +37,18 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a - BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports. - BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git. - Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen. -- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. +- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin. +- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. Nutzer-Mapping vor globalem Mapping. Globales Mapping nur auf BLS oder zentrale Admin-Foods. +- Sichtbarkeit: persönlich (nur Owner) / zentral (alle) / BLS (alle). Vorschläge und Suche filtern danach; bei gleichem Namen gewinnt persönlich vor zentral vor BLS. - Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros. - Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import. - Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten. ## Manuelle Lebensmittel / Supplemente -Eigene Einträge (z. B. Norsan Omega-3 + EPA) speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Admin → Stoffe & Attribute. +Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`. + +**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar. Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index 1481d8a..afbfc7c 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -1,6 +1,6 @@ # BLS Food Reference – technische Spec -**Stand:** 2026-09-12 · Migration **062**–**068** +**Stand:** 2026-09-14 · Migration **062**–**069** ## Tabellen @@ -28,15 +28,18 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / ## Router -- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings +- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA` +- `GET /bls/foods/mine` — eigene `manual_user`-Einträge; `GET /bls/foods/shared` — zentrale `manual_admin`; `GET /bls/foods/{id}` — Editor-Payload inkl. `visibility` und `editable` (Owner persönlich; Admin auch zentral); `PUT /bls/foods/manual/{id}` — nur Owner. Zentrale Updates: `PUT /admin/bls/foods/manual/{id}` (Admin). Suche `?scope=global` blendet persönliche Foods aus (Admin-Mappings). +- Sichtbarkeit: `data_layer/food_visibility.py`. Lookup-Mapping: User-Zeile nur wenn Food sichtbar; globale Mapping-Zeile nur `owner_profile_id IS NULL`. `upsert_food_mapping` erzwingt dasselbe. Vorschläge sortieren persönlich → zentral → BLS. +- `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST /admin/bls/foods/manual` — zentrale Foods. `PUT /admin/bls/foods/manual/{id}` — zentral **und** persönlich (`attributes` + `serving_g`). `GET /admin/bls/foods/{id}?editor=true` — Editor-Payload. Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht). - Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat -- `/api/admin/bls/*` — Import, Katalog, Attribute +- `/api/admin/bls/*` — Import, Katalog, Attribute. `GET /admin/bls/attributes` liefert `group_key`/`group_label` (BLS-Gruppen, sortiert). `PUT /admin/bls/attributes/{id}` ändert Name/Einheit/Gruppe (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen und fehlende Gruppen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`. - `/api/admin/food-mappings` — Admin-CRUD - `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve - `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply` - `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln - Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping -- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat). +- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Eigene Foods: Tab **Lebensmittel** (`MyFoodsPanel` + `FoodEditDialog`). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat). - Tandoor (Modul `tandoor`): Migration **067** `profile_tandoor_settings`; `GET/PUT /api/tandoor/settings` (Token nur `token_set` / `token_hint`); `POST /api/tandoor/test` (10/min, kein Redirect-Follow). Client: `data_layer/tandoor_client.py`. Import und `cooked_yield_g` später. - Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g). - `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`. diff --git a/CLAUDE.md b/CLAUDE.md index cc76692..752d884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ frontend/src/ - **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**. - **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“. - **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten. -- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. +- **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel** (persönlich ändern; zentral nur Admin). Admin: Katalog → Manuell — Listen **Zentral** und **Persönlich**, beide änderbar. Stoffe & Attribute: Bezeichnung editierbar, Schlüssel fest; Makros nur ENERCC/PROT625/FAT/CHO → `nutrition_log`. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste. - **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`. - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. diff --git a/backend/bls/attribute_labels.py b/backend/bls/attribute_labels.py new file mode 100644 index 0000000..885580c --- /dev/null +++ b/backend/bls/attribute_labels.py @@ -0,0 +1,321 @@ +"""Official BLS 4.0 component labels (MRI documentation) and placeholder repair. + +``attr_key`` stays the identity. These names only fill empty or key-equal ``name_de``. +""" +from __future__ import annotations + +from typing import Any + +# Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g +MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO") + +GROUP_META = ( + ("energy", "Energie"), + ("macro", "Makronährstoffe"), + ("carbs", "Kohlenhydrate"), + ("fiber", "Ballaststoffe"), + ("fatty_acids", "Fettsäuren"), + ("amino_acids", "Aminosäuren"), + ("vitamins", "Vitamine"), + ("minerals", "Mineralstoffe"), + ("organic_acids", "Organische Säuren"), + ("polyols", "Zuckeralkohole"), + ("other", "Sonstige"), + ("extension", "Eigene Erweiterungen"), + ("unknown", "Ohne Gruppe"), +) +GROUP_LABELS = {key: label for key, label in GROUP_META} +GROUP_RANK = {key: i for i, (key, _) in enumerate(GROUP_META)} + +# Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026) +_OFFICIAL: dict[str, tuple[str, str, str]] = { + "ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"), + "ENERCC": ("Energie (Kilokalorien)", "kcal", "energy"), + "WATER": ("Wasser", "g", "macro"), + "PROT625": ("Protein (N×6,25)", "g", "macro"), + "FAT": ("Fett", "g", "macro"), + "CHO": ("Kohlenhydrate, verfügbar", "g", "macro"), + "FIBT": ("Ballaststoffe, gesamt", "g", "fiber"), + "ALC": ("Alkohol (Ethanol)", "g", "macro"), + "OA": ("Organische Säuren, gesamt", "g", "organic_acids"), + "ASH": ("Rohasche", "g", "macro"), + "POLYL": ("Zuckeralkohole, gesamt", "g", "polyols"), + "GLUS": ("Glucose", "g", "carbs"), + "FRUS": ("Fructose", "g", "carbs"), + "GALS": ("Galactose", "g", "carbs"), + "MNSAC": ("Monosaccharide, gesamt", "g", "carbs"), + "SUCS": ("Saccharose", "g", "carbs"), + "MALS": ("Maltose", "g", "carbs"), + "LACS": ("Lactose", "g", "carbs"), + "DISAC": ("Disaccharide, gesamt", "g", "carbs"), + "SUGAR": ("Zucker, gesamt", "g", "carbs"), + "OLSAC": ("Oligosaccharide, verfügbar", "g", "carbs"), + "STARCH": ("Stärke", "g", "carbs"), + "FIBLMW": ("Ballaststoffe, niedermolekular", "g", "fiber"), + "FIBHMW": ("Ballaststoffe, hochmolekular", "g", "fiber"), + "FIBINS": ("Ballaststoffe, wasserunlöslich", "g", "fiber"), + "FIBSOL": ("Ballaststoffe, wasserlöslich", "g", "fiber"), + "FIBHMWS": ("Ballaststoffe, hochmolekular, wasserlöslich", "g", "fiber"), + "FIBHMWI": ("Ballaststoffe, hochmolekular, wasserunlöslich", "g", "fiber"), + "FASAT": ("Fettsäuren, gesättigt, gesamt", "g", "fatty_acids"), + "F4:0": ("Buttersäure (C4:0)", "g", "fatty_acids"), + "F6:0": ("Capronsäure (C6:0)", "g", "fatty_acids"), + "F8:0": ("Caprylsäure (C8:0)", "g", "fatty_acids"), + "F10:0": ("Caprinsäure (C10:0)", "g", "fatty_acids"), + "F12:0": ("Laurinsäure (C12:0)", "g", "fatty_acids"), + "F14:0": ("Myristinsäure (C14:0)", "g", "fatty_acids"), + "F15:0": ("Pentadecylsäure (C15:0)", "g", "fatty_acids"), + "F16:0": ("Palmitinsäure (C16:0)", "g", "fatty_acids"), + "F17:0": ("Margarinsäure (C17:0)", "g", "fatty_acids"), + "F18:0": ("Stearinsäure (C18:0)", "g", "fatty_acids"), + "F20:0": ("Arachinsäure (C20:0)", "g", "fatty_acids"), + "F22:0": ("Behensäure (C22:0)", "g", "fatty_acids"), + "F24:0": ("Lignocerinsäure (C24:0)", "g", "fatty_acids"), + "FAMS": ("Fettsäuren, einfach ungesättigt, gesamt", "g", "fatty_acids"), + "F14:1CN5": ("Myristoleinsäure (C14:1 n-5 cis)", "g", "fatty_acids"), + "F16:1CN7": ("Palmitoleinsäure (C16:1 n-7 cis)", "g", "fatty_acids"), + "F18:1CN7": ("Vaccensäure (C18:1 n-7 cis)", "g", "fatty_acids"), + "F18:1CN9": ("Ölsäure (C18:1 n-9 cis)", "g", "fatty_acids"), + "F20:1CN9": ("Gondosäure (C20:1 n-9 cis)", "g", "fatty_acids"), + "F22:1CN9": ("Erucasäure (C22:1 n-9 cis)", "g", "fatty_acids"), + "FAPU": ("Fettsäuren, mehrfach ungesättigt, gesamt", "g", "fatty_acids"), + "FAPUN3": ("Omega-3-Fettsäuren, gesamt", "g", "fatty_acids"), + "FAPUN6": ("Omega-6-Fettsäuren, gesamt", "g", "fatty_acids"), + "F18:2CN6": ("Linolsäure (C18:2 n-6 cis)", "g", "fatty_acids"), + "F18:2C9T11": ("Konjugierte Linolsäure (C18:2 cis-9, trans-11)", "g", "fatty_acids"), + "F18:3CN3": ("Alpha-Linolensäure (C18:3 n-3)", "g", "fatty_acids"), + "F18:3CN6": ("Gamma-Linolensäure (C18:3 n-6)", "g", "fatty_acids"), + "F18:4CN3": ("Stearidonsäure (C18:4 n-3)", "g", "fatty_acids"), + "F20:2CN6": ("Eicosadiensäure (C20:2 n-6)", "g", "fatty_acids"), + "F20:3CN6": ("Dihomo-Gamma-Linolensäure (C20:3 n-6)", "g", "fatty_acids"), + "F20:4CN6": ("Arachidonsäure (C20:4 n-6)", "g", "fatty_acids"), + "F20:5CN3": ("Eicosapentaensäure EPA (C20:5 n-3)", "g", "fatty_acids"), + "F22:5CN3": ("Docosapentaensäure DPA (C22:5 n-3)", "g", "fatty_acids"), + "F22:6CN3": ("Docosahexaensäure DHA (C22:6 n-3)", "g", "fatty_acids"), + "FAX": ("Fettsäuren, sonstige", "g", "fatty_acids"), + "AAE9": ("Aminosäuren, unentbehrlich, gesamt", "g", "amino_acids"), + "ILE": ("Isoleucin", "g", "amino_acids"), + "LEU": ("Leucin", "g", "amino_acids"), + "LYS": ("Lysin", "g", "amino_acids"), + "MET": ("Methionin", "g", "amino_acids"), + "PHE": ("Phenylalanin", "g", "amino_acids"), + "THR": ("Threonin", "g", "amino_acids"), + "TRP": ("Tryptophan", "g", "amino_acids"), + "VAL": ("Valin", "g", "amino_acids"), + "HIS": ("Histidin", "g", "amino_acids"), + "ALA": ("Alanin", "g", "amino_acids"), + "ARG": ("Arginin", "g", "amino_acids"), + "ASP": ("Asparaginsäure inkl. Asparagin", "g", "amino_acids"), + "CYS": ("Cystein", "g", "amino_acids"), + "GLU": ("Glutaminsäure inkl. Glutamin", "g", "amino_acids"), + "GLY": ("Glycin", "g", "amino_acids"), + "PRO": ("Prolin", "g", "amino_acids"), + "SER": ("Serin", "g", "amino_acids"), + "TYR": ("Tyrosin", "g", "amino_acids"), + "VITA": ("Vitamin A, Retinol-Äquivalent (RE)", "µg", "vitamins"), + "VITAA": ("Vitamin A, Retinol-Aktivitätsäquivalent (RAE)", "µg", "vitamins"), + "RETOL": ("Retinol", "µg", "vitamins"), + "CARTB": ("Beta-Carotin", "µg", "vitamins"), + "CAROTPAXB": ("Carotinoide außer Beta-Carotin", "µg", "vitamins"), + "VITD": ("Vitamin D", "µg", "vitamins"), + "ERGCAL": ("Vitamin D2 (Ergocalciferol)", "µg", "vitamins"), + "CHOCAL": ("Vitamin D3 (Cholecalciferol)", "µg", "vitamins"), + "VITE": ("Vitamin E (Alpha-Tocopherol)", "mg", "vitamins"), + "TOCPHA": ("Alpha-Tocopherol", "mg", "vitamins"), + "TOCPHB": ("Beta-Tocopherol", "mg", "vitamins"), + "TOCPHG": ("Gamma-Tocopherol", "mg", "vitamins"), + "TOCPHD": ("Delta-Tocopherol", "mg", "vitamins"), + "TOCTRA": ("Alpha-Tocotrienol", "mg", "vitamins"), + "VITK": ("Vitamin K", "µg", "vitamins"), + "VITK1": ("Vitamin K1 (Phyllochinon)", "µg", "vitamins"), + "VITK2": ("Vitamin K2 (Menachinone)", "µg", "vitamins"), + "THIA": ("Vitamin B1 (Thiamin)", "mg", "vitamins"), + "RIBF": ("Vitamin B2 (Riboflavin)", "mg", "vitamins"), + "NIA": ("Niacin", "mg", "vitamins"), + "NIAEQ": ("Niacin-Äquivalent", "mg", "vitamins"), + "PANTAC": ("Pantothensäure", "mg", "vitamins"), + "VITB6": ("Vitamin B6", "µg", "vitamins"), + "BIOT": ("Biotin", "µg", "vitamins"), + "FOL": ("Folat-Äquivalent", "µg", "vitamins"), + "FOLFD": ("Folat", "µg", "vitamins"), + "FOLAC": ("Folsäure, synthetisch", "µg", "vitamins"), + "VITB12": ("Vitamin B12 (Cobalamine)", "µg", "vitamins"), + "VITC": ("Vitamin C", "mg", "vitamins"), + "NACL": ("Salz (Natriumchlorid)", "g", "minerals"), + "NA": ("Natrium", "mg", "minerals"), + "CLD": ("Chlorid", "mg", "minerals"), + "K": ("Kalium", "mg", "minerals"), + "CA": ("Calcium", "mg", "minerals"), + "MG": ("Magnesium", "mg", "minerals"), + "P": ("Phosphor", "mg", "minerals"), + "S": ("Schwefel", "mg", "minerals"), + "FE": ("Eisen", "mg", "minerals"), + "ZN": ("Zink", "mg", "minerals"), + "ID": ("Iodid", "µg", "minerals"), + "CU": ("Kupfer", "µg", "minerals"), + "MN": ("Mangan", "µg", "minerals"), + "FD": ("Fluorid", "µg", "minerals"), + "CR": ("Chrom", "µg", "minerals"), + "MO": ("Molybdän", "µg", "minerals"), + "ACEAC": ("Essigsäure", "g", "organic_acids"), + "CITAC": ("Zitronensäure", "g", "organic_acids"), + "LACAC": ("Milchsäure", "g", "organic_acids"), + "MALAC": ("Äpfelsäure", "g", "organic_acids"), + "TARAC": ("Weinsäure", "g", "organic_acids"), + "MANTL": ("Mannit", "g", "polyols"), + "SORTL": ("Sorbit", "g", "polyols"), + "XYLTL": ("Xylit", "g", "polyols"), + "CHORL": ("Cholesterin", "mg", "other"), + "NT": ("Stickstoff, gesamt", "g", "other"), + "EPA": ("Eicosapentaensäure (EPA)", "g", "fatty_acids"), + "DHA": ("Docosahexaensäure (DHA)", "g", "fatty_acids"), + "DPA": ("Docosapentaensäure (DPA)", "g", "fatty_acids"), + "ALA": ("Alpha-Linolensäure (ALA)", "g", "fatty_acids"), + "OMEGA3": ("Omega-3-Fettsäuren gesamt", "g", "fatty_acids"), + "OMEGA6": ("Omega-6-Fettsäuren gesamt", "g", "fatty_acids"), +} + + +def lookup_key(raw: str | None) -> str: + return str(raw or "").strip().upper() + + +def resolve_group(attr_key: str | None, category: str | None = None, origin: str | None = None) -> tuple[str, str]: + cat = (category or "").strip().lower() + if cat in GROUP_LABELS: + return cat, GROUP_LABELS[cat] + lab = official_label(attr_key) + if lab and lab["category"] in GROUP_LABELS: + return lab["category"], GROUP_LABELS[lab["category"]] + if (origin or "") == "extension": + return "extension", GROUP_LABELS["extension"] + return "unknown", GROUP_LABELS["unknown"] + + +def annotate_attribute(row: dict[str, Any]) -> dict[str, Any]: + key = row.get("attr_key") + group_key, group_label = resolve_group(key, row.get("category"), row.get("origin")) + row["is_macro"] = key in MACRO_KEYS + row["needs_label"] = label_is_placeholder(row.get("name_de"), key) + row["group_key"] = group_key + row["group_label"] = group_label + row["group_rank"] = GROUP_RANK[group_key] + return row + + +def sort_attributes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + def key_of(row: dict[str, Any]): + return ( + row.get("group_rank", 99), + 0 if row.get("is_macro") else 1, + row.get("sort_order") if row.get("sort_order") is not None else 0, + (row.get("name_de") or row.get("attr_key") or "").casefold(), + row.get("attr_key") or "", + ) + + return sorted(rows, key=key_of) + + +def official_label(attr_key: str | None) -> dict[str, str] | None: + key = lookup_key(attr_key) + hit = _OFFICIAL.get(key) + if not hit: + hit = _OFFICIAL.get(key.replace("_", ":")) + if not hit: + return None + name_de, unit, category = hit + return {"name_de": name_de, "unit": unit, "category": category} + + +def label_is_placeholder(name_de: str | None, attr_key: str | None) -> bool: + name = (name_de or "").strip() + key = lookup_key(attr_key) + return (not name) or name.upper() == key + + +def apply_official_name(attr_key: str | None, name_de: str | None) -> str: + if not label_is_placeholder(name_de, attr_key): + return (name_de or "").strip() + lab = official_label(attr_key) + if lab: + return lab["name_de"] + return (name_de or "").strip() or lookup_key(attr_key) + + +def enrich_attributes_in_memory(attributes: list[dict[str, Any]]) -> list[dict[str, Any]]: + for a in attributes: + key = a.get("attr_key") + lab = official_label(key) + if label_is_placeholder(a.get("name_de"), key) and lab: + a["name_de"] = lab["name_de"] + a["unit"] = a.get("unit") or lab["unit"] + a["category"] = a.get("category") or lab["category"] + return attributes + + +def fill_placeholder_labels(cur) -> dict[str, int]: + cur.execute( + """ + SELECT id, attr_key, name_de, unit, category + FROM food_attributes + WHERE is_active = true + """ + ) + updated = 0 + for row in cur.fetchall(): + rec = dict(row) + lab = official_label(rec.get("attr_key")) + if not lab: + continue + need_name = label_is_placeholder(rec.get("name_de"), rec.get("attr_key")) + need_cat = not (rec.get("category") or "").strip() + if not need_name and not need_cat: + continue + cur.execute( + """ + UPDATE food_attributes + SET name_de = %s, + unit = COALESCE(NULLIF(BTRIM(COALESCE(unit, '')), ''), %s), + category = COALESCE(NULLIF(BTRIM(COALESCE(category, '')), ''), %s), + updated_at = NOW() + WHERE id = %s + """, + ( + lab["name_de"] if need_name else rec.get("name_de"), + lab["unit"], + lab["category"], + rec["id"], + ), + ) + updated += 1 + return {"updated": updated} + + +def enrich_labels_from_food_headers(cur, headers: list[dict[str, Any]] | None) -> int: + updated = 0 + for h in headers or []: + key = h.get("attr_key") + name = (h.get("name_de") or "").strip() + unit = (h.get("unit") or "").strip() or None + if not key or not name or label_is_placeholder(name, key): + lab = official_label(key) + if not lab: + continue + name = lab["name_de"] + unit = unit or lab["unit"] + cur.execute( + """ + UPDATE food_attributes + SET name_de = %s, + unit = COALESCE(%s, unit), + updated_at = NOW() + WHERE attr_key = %s + AND ( + name_de IS NULL OR BTRIM(name_de) = '' OR name_de = attr_key + ) + """, + (name, unit, key), + ) + if cur.rowcount: + updated += cur.rowcount + return updated diff --git a/backend/bls/import_service.py b/backend/bls/import_service.py index 064917a..4d408d1 100644 --- a/backend/bls/import_service.py +++ b/backend/bls/import_service.py @@ -16,6 +16,9 @@ def should_persist_value(val: dict[str, Any]) -> bool: def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]: + from bls.attribute_labels import enrich_attributes_in_memory + + attributes = enrich_attributes_in_memory(list(attributes)) inserted = updated = 0 for a in attributes: cur.execute( @@ -47,7 +50,15 @@ def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]: return {"inserted": inserted, "updated": updated, "total": len(attributes)} -def upsert_foods(cur, foods: list[dict[str, Any]], bls_version: str = "4.0") -> dict[str, int]: +def upsert_foods( + cur, + foods: list[dict[str, Any]], + bls_version: str = "4.0", + attribute_headers: list[dict[str, Any]] | None = None, +) -> dict[str, int]: + if attribute_headers: + from bls.attribute_labels import enrich_labels_from_food_headers + enrich_labels_from_food_headers(cur, attribute_headers) cur.execute("SELECT attr_key, id FROM food_attributes") attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()} codes = [f["bls_code"] for f in foods if f.get("bls_code")] diff --git a/backend/bls/jobs.py b/backend/bls/jobs.py index 286a760..d4e62d6 100644 --- a/backend/bls/jobs.py +++ b/backend/bls/jobs.py @@ -146,10 +146,15 @@ def _run_apply(job_id: str) -> None: total = len(foods) inserted = updated = values_written = 0 _update(job_id, progress={"current": 0, "total": total}) + headers = parsed.get("attribute_headers") for i in range(0, total, FOOD_CHUNK): chunk = foods[i : i + FOOD_CHUNK] with get_db() as conn: - stats = upsert_foods(get_cursor(conn), chunk) + stats = upsert_foods( + get_cursor(conn), + chunk, + attribute_headers=headers if i == 0 else None, + ) inserted += stats.get("inserted", 0) updated += stats.get("updated", 0) values_written += stats.get("values_written", 0) diff --git a/backend/bls/parser.py b/backend/bls/parser.py index 63e90ee..82b8c56 100644 --- a/backend/bls/parser.py +++ b/backend/bls/parser.py @@ -26,25 +26,34 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]: header = [_cell(c) for c in next(rows)] idx = {h.lower(): i for i, h in enumerate(header) if h} - def col(*names: str) -> int | None: + def col(*names: str, exclude: tuple[str, ...] = ()) -> int | None: for n in names: if n.lower() in idx: return idx[n.lower()] for key, i in idx.items(): + if any(ex in key for ex in exclude): + continue for n in names: if n.lower() in key: return i return None - i_code = col("code", "schlüssel", "schluessel", "attr_key", "komponente") - i_de = col("name_de", "deutsch", "bezeichnung_de", "name de") - i_en = col("name_en", "english", "bezeichnung_en", "name en") + i_code = col( + "component code", "komponentencode", "attr_key", "schlüssel", "schluessel", + "code", "komponente", + ) + i_de = col( + "name_de", "bezeichnung_de", "bezeichnung deutsch", "name de", + "deutsch", "german", "bezeichnung", + exclude=("name_en", "bezeichnung_en", "english", "englisch", "name en"), + ) + i_en = col("name_en", "bezeichnung_en", "name en", "english", "englisch") i_unit = col("unit", "einheit") i_cat = col("category", "kategorie", "gruppe") if i_code is None: i_code = 0 - if i_de is None: - i_de = 1 if len(header) > 1 else 0 + + from bls.attribute_labels import apply_official_name out = [] sort_order = 0 @@ -52,9 +61,10 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]: if not raw: continue code = _cell(raw[i_code] if i_code < len(raw) else "") - if not code or code.lower() in ("code", "schlüssel", "schluessel"): + if not code or code.lower() in ("code", "schlüssel", "schluessel", "component code"): continue - name_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") or code + raw_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") + name_de = apply_official_name(code, raw_de) name_en = _cell(raw[i_en] if i_en is not None and i_en < len(raw) else "") or None unit = _cell(raw[i_unit] if i_unit is not None and i_unit < len(raw) else "") or None category = _cell(raw[i_cat] if i_cat is not None and i_cat < len(raw) else "") or None diff --git a/backend/data_layer/food_attributes.py b/backend/data_layer/food_attributes.py index 980ce00..441fe59 100644 --- a/backend/data_layer/food_attributes.py +++ b/backend/data_layer/food_attributes.py @@ -1,8 +1,12 @@ """Catalog attribute definitions and numeric values (BLS EAV + extensions).""" from __future__ import annotations +import re from typing import Any +_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+") +_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"} + MACRO_TO_KEY = { "kcal": "ENERCC", "protein_g": "PROT625", @@ -17,9 +21,24 @@ def scale_to_per_100g(value: float, serving_g: float | None) -> float: return float(value) * (100.0 / float(serving_g)) -def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]: - q = (query or "").strip() +def list_numeric_attributes( + cur, query: str = "", limit: int = 40, keys: list[str] | None = None +) -> list[dict[str, Any]]: lim = min(max(int(limit or 40), 1), 80) + wanted = [str(k).strip() for k in (keys or []) if str(k).strip()] + if wanted: + cur.execute( + """ + SELECT id, attr_key, name_de, name_en, unit, category, origin + FROM food_attributes + WHERE is_active = true AND data_type = 'num_per_100g' + AND attr_key = ANY(%s) + ORDER BY sort_order, attr_key + """, + (wanted,), + ) + return [dict(r) for r in cur.fetchall()] + q = (query or "").strip() if q: like = f"%{q}%" cur.execute( @@ -89,6 +108,57 @@ def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) - return written +def replace_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int: + cur.execute("DELETE FROM food_attribute_values WHERE food_id = %s", (food_id,)) + return write_numeric_attributes(cur, food_id, values) + + +KEY_TO_MACRO = {v: k for k, v in MACRO_TO_KEY.items()} + + +def editor_payload_for_food(cur, food: dict[str, Any], *, editable: bool) -> dict[str, Any]: + cur.execute( + """ + SELECT a.attr_key, a.name_de, a.name_en, a.unit, a.category, a.origin, v.value_num + FROM food_attribute_values v + JOIN food_attributes a ON a.id = v.attribute_id + WHERE v.food_id = %s AND a.data_type = 'num_per_100g' + AND v.value_num IS NOT NULL AND v.is_trace = false + ORDER BY a.sort_order, a.attr_key + """, + (food["id"],), + ) + macros = {"kcal": 0, "protein_g": 0, "fat_g": 0, "carbs_g": 0} + extras = [] + for row in cur.fetchall(): + rec = dict(row) + field = KEY_TO_MACRO.get(rec["attr_key"]) + if field: + macros[field] = float(rec["value_num"]) + else: + extras.append({ + "attr": { + "attr_key": rec["attr_key"], + "name_de": rec["name_de"], + "name_en": rec["name_en"], + "unit": rec["unit"], + "category": rec["category"], + "origin": rec["origin"], + }, + "value": float(rec["value_num"]), + }) + return { + "id": str(food["id"]), + "name_de": food.get("name_de") or "", + "name_en": food.get("name_en"), + "catalog_kind": food.get("catalog_kind"), + "editable": editable, + "macros_per_100g": macros, + "attributes": extras, + "serving_g": 100, + } + + def macros_and_attributes_to_values( macros: dict[str, Any] | None, attributes: dict[str, Any] | None, @@ -126,3 +196,43 @@ def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]: (food_id, list(MACRO_TO_KEY.values())), ) return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()} + + +def normalize_attr_key(raw: str | None) -> str: + key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_") + return key[:64] + + +def normalize_attr_unit(raw: str | None) -> str: + return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g") + + +def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]: + name = (name_de or "").strip() + if not name: + raise ValueError("Name fehlt") + key = normalize_attr_key(attr_key or name) + if not key: + raise ValueError("attr_key fehlt") + unit_n = normalize_attr_unit(unit) + cur.execute( + """ + SELECT id, attr_key, name_de, name_en, unit, category, origin + FROM food_attributes + WHERE attr_key = %s AND is_active = true + """, + (key,), + ) + existing = cur.fetchone() + if existing: + return dict(existing) + cur.execute( + """ + INSERT INTO food_attributes + (attr_key, name_de, name_en, unit, category, data_type, origin, sort_order) + VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100) + RETURNING id, attr_key, name_de, name_en, unit, category, origin + """, + (key, name, None, unit_n), + ) + return dict(cur.fetchone()) diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index 2bede24..97ec9ad 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -408,14 +408,16 @@ def get_food_mapping_with_cursor( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, m.grams_per_unit, m.source_unit, - f.bls_code, f.name_de, f.catalog_kind + f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id WHERE m.source_system = %s AND m.source_name_normalized = %s AND m.profile_id = %s + AND f.is_active = true + AND (f.owner_profile_id IS NULL OR f.owner_profile_id = %s) LIMIT 1 """, - (source_system, norm, profile_id), + (source_system, norm, profile_id, profile_id), ) row = cur.fetchone() if row: @@ -424,11 +426,13 @@ def get_food_mapping_with_cursor( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, m.grams_per_unit, m.source_unit, - f.bls_code, f.name_de, f.catalog_kind + f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id WHERE m.source_system = %s AND m.source_name_normalized = %s AND m.profile_id IS NULL + AND f.is_active = true + AND f.owner_profile_id IS NULL LIMIT 1 """, (source_system, norm), @@ -448,6 +452,9 @@ def upsert_food_mapping( grams_per_unit: float | None = None, source_unit: str | None = None, ) -> int: + from data_layer.food_visibility import require_mapping_target + + require_mapping_target(cur, food_id, profile_id) norm = normalize_food_name(source_name_raw) if not norm: raise ValueError("Leerer Lebensmittelname") @@ -622,7 +629,7 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = extra_sql = " OR (" + " AND ".join(parts) + ")" cur.execute( f""" - SELECT id, bls_code, name_de, name_en, catalog_kind, food_group + SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id FROM food_catalog WHERE is_active = true AND ( @@ -642,6 +649,10 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = WHEN name_de ILIKE %s THEN 1 WHEN COALESCE(bls_code, '') ILIKE %s THEN 2 ELSE 3 END, + CASE catalog_kind + WHEN 'manual_user' THEN 0 + WHEN 'manual_admin' THEN 1 + ELSE 2 END, name_de LIMIT %s """, diff --git a/backend/data_layer/food_suggest.py b/backend/data_layer/food_suggest.py index 570945a..bd6d2ea 100644 --- a/backend/data_layer/food_suggest.py +++ b/backend/data_layer/food_suggest.py @@ -131,6 +131,8 @@ def score_name_match(query: str, name_de: str, name_en: str | None = None) -> in def _public(food: dict[str, Any], score: int) -> dict[str, Any]: + from data_layer.food_visibility import visibility_of + return { "id": str(food["id"]), "bls_code": food.get("bls_code"), @@ -138,6 +140,7 @@ def _public(food: dict[str, Any], score: int) -> dict[str, Any]: "name_en": food.get("name_en"), "catalog_kind": food.get("catalog_kind"), "food_group": food.get("food_group"), + "visibility": visibility_of(food), "score": score, } @@ -163,7 +166,7 @@ def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]: def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]: cur.execute( """ - SELECT id, bls_code, name_de, name_en, catalog_kind, food_group + SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id FROM food_catalog WHERE is_active = true AND (owner_profile_id IS NULL OR owner_profile_id = %s) @@ -245,6 +248,11 @@ def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]: return out[:MAX_CANDIDATES] if not number_tokens(query) else out +def _vis_rank(food: dict[str, Any]) -> int: + from data_layer.food_visibility import visibility_rank + return visibility_rank(food) + + def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]: q = (query or "").strip() scored: list[tuple[int, int, dict]] = [] @@ -253,7 +261,7 @@ def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[ if score < MIN_SCORE: continue scored.append((score, len(food.get("name_de") or ""), food)) - scored.sort(key=lambda x: (-x[0], x[1], x[2].get("name_de") or "")) + scored.sort(key=lambda x: (-x[0], _vis_rank(x[2]), x[1], x[2].get("name_de") or "")) top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]] ambiguous = False if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60: @@ -296,7 +304,7 @@ def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit: if score < MIN_SCORE and not number_tokens(q): continue hits.append(_public(row, score or 40)) - hits.sort(key=lambda h: (-int(h.get("score") or 0), h.get("name_de") or "")) + hits.sort(key=lambda h: (-int(h.get("score") or 0), _vis_rank(h), h.get("name_de") or "")) return hits[:limit] diff --git a/backend/data_layer/food_visibility.py b/backend/data_layer/food_visibility.py new file mode 100644 index 0000000..0fe6ce5 --- /dev/null +++ b/backend/data_layer/food_visibility.py @@ -0,0 +1,121 @@ +"""Who may see and map a catalog food. + +Scopes: +- personal — ``manual_user`` with ``owner_profile_id`` (only that profile) +- global — ``manual_admin`` (all users; no owner) +- bls — official BLS (all users; no owner) + +Name-mappings: a *user* mapping may target any food visible to that user. +A *global* mapping (``profile_id IS NULL``) may only target global/BLS foods, +never another user's private entry. +""" +from __future__ import annotations + +from typing import Any + +VIS_PERSONAL = "personal" +VIS_GLOBAL = "global" +VIS_BLS = "bls" + +SQL_VISIBLE_TO_PROFILE = "(owner_profile_id IS NULL OR owner_profile_id = %s)" +SQL_GLOBAL_ONLY = "owner_profile_id IS NULL" + +_KIND_VIS = { + "official_bls": VIS_BLS, + "manual_admin": VIS_GLOBAL, + "manual_user": VIS_PERSONAL, +} + +_VIS_RANK = {VIS_PERSONAL: 0, VIS_GLOBAL: 1, VIS_BLS: 2} + + +def same_id(a, b) -> bool: + return str(a or "").replace("-", "").lower() == str(b or "").replace("-", "").lower() + + +def visibility_of(food: dict[str, Any] | None) -> str: + if not food: + return VIS_BLS + kind = food.get("catalog_kind") + if kind in _KIND_VIS: + return _KIND_VIS[kind] + if food.get("owner_profile_id"): + return VIS_PERSONAL + return VIS_GLOBAL + + +def visibility_rank(food: dict[str, Any] | None) -> int: + return _VIS_RANK.get(visibility_of(food), 3) + + +def is_active(food: dict[str, Any] | None) -> bool: + if not food: + return False + if "is_active" not in food: + return True + return bool(food.get("is_active")) + + +def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool: + if not is_active(food): + return False + owner = food.get("owner_profile_id") + if owner is None: + return True + return profile_id is not None and same_id(owner, profile_id) + + +def is_global_mapping_target(food: dict[str, Any] | None) -> bool: + return is_active(food) and food.get("owner_profile_id") is None + + +def load_catalog_food(cur, food_id: str) -> dict[str, Any] | None: + cur.execute( + """ + SELECT id, name_de, catalog_kind, owner_profile_id, is_active, bls_code + FROM food_catalog + WHERE id = %s + """, + (food_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def require_visible_food(cur, food_id: str, profile_id: str) -> dict[str, Any]: + food = load_catalog_food(cur, food_id) + if not is_visible_to(food, profile_id): + raise LookupError("Lebensmittel nicht gefunden") + return food + + +def require_global_mapping_target(cur, food_id: str) -> dict[str, Any]: + food = load_catalog_food(cur, food_id) + if not food or not is_active(food): + raise LookupError("Lebensmittel nicht gefunden") + if not is_global_mapping_target(food): + raise PermissionError("Globales Mapping darf nur auf zentrale oder BLS-Lebensmittel zeigen") + return food + + +def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) -> dict[str, Any]: + if mapping_profile_id: + return require_visible_food(cur, food_id, mapping_profile_id) + return require_global_mapping_target(cur, food_id) + + +def can_edit_food(food: dict[str, Any] | None, profile_id: str | None, *, is_admin: bool = False) -> bool: + """Owner ändert persönlich; Admin ändert zentral (und im Admin-Katalog auch fremde Manuals).""" + if not is_active(food): + return False + kind = food.get("catalog_kind") + if kind == "manual_user": + return same_id(food.get("owner_profile_id"), profile_id) or bool(is_admin) + if kind == "manual_admin": + return bool(is_admin) + return False + + +def attach_visibility(row: dict[str, Any]) -> dict[str, Any]: + row["visibility"] = visibility_of(row) + return row diff --git a/backend/data_layer/nutrition_items.py b/backend/data_layer/nutrition_items.py index 46ec4f3..91746b3 100644 --- a/backend/data_layer/nutrition_items.py +++ b/backend/data_layer/nutrition_items.py @@ -487,3 +487,45 @@ def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) (profile_id, source_name_normalized, profile_id, source_name_normalized), ) return [r["date"] for r in cur.fetchall()] + + +def dates_using_food(cur, profile_id: str, food_id: str) -> list[str]: + cur.execute( + """ + SELECT DISTINCT date::text AS date + FROM nutrition_items + WHERE profile_id = %s AND food_id = %s + UNION + SELECT DISTINCT i.date::text AS date + FROM nutrition_items i + JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id + JOIN food_name_mappings m + ON m.profile_id = i.profile_id + AND m.source_name_normalized = ri.source_name_normalized + AND m.food_id = %s + WHERE i.profile_id = %s AND i.recipe_id IS NOT NULL + """, + (profile_id, food_id, food_id, profile_id), + ) + return [r["date"] for r in cur.fetchall() if r.get("date")] + + +def profiles_dates_using_food(cur, food_id: str) -> list[tuple[str, str]]: + cur.execute( + """ + SELECT DISTINCT profile_id::text AS profile_id, date::text AS date + FROM nutrition_items + WHERE food_id = %s + UNION + SELECT DISTINCT i.profile_id::text AS profile_id, i.date::text AS date + FROM nutrition_items i + JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id + JOIN food_name_mappings m + ON m.profile_id = i.profile_id + AND m.source_name_normalized = ri.source_name_normalized + AND m.food_id = %s + WHERE i.recipe_id IS NOT NULL + """, + (food_id, food_id), + ) + return [(r["profile_id"], r["date"]) for r in cur.fetchall() if r.get("profile_id") and r.get("date")] diff --git a/backend/migrations/069_food_catalog_visibility.sql b/backend/migrations/069_food_catalog_visibility.sql new file mode 100644 index 0000000..666b7a8 --- /dev/null +++ b/backend/migrations/069_food_catalog_visibility.sql @@ -0,0 +1,18 @@ +-- Visibility invariant: personal foods have an owner; BLS and central admin foods do not. + +UPDATE food_catalog +SET owner_profile_id = NULL +WHERE catalog_kind IN ('official_bls', 'manual_admin') + AND owner_profile_id IS NOT NULL; + +UPDATE food_catalog +SET is_active = false +WHERE catalog_kind = 'manual_user' + AND owner_profile_id IS NULL; + +ALTER TABLE food_catalog DROP CONSTRAINT IF EXISTS chk_food_catalog_owner; + +ALTER TABLE food_catalog ADD CONSTRAINT chk_food_catalog_owner CHECK ( + (catalog_kind = 'manual_user' AND owner_profile_id IS NOT NULL) + OR (catalog_kind IN ('official_bls', 'manual_admin') AND owner_profile_id IS NULL) +); diff --git a/backend/routers/admin_bls.py b/backend/routers/admin_bls.py index 8546512..d61ca0d 100644 --- a/backend/routers/admin_bls.py +++ b/backend/routers/admin_bls.py @@ -28,10 +28,20 @@ class AttributeCreate(BaseModel): enum_values: Optional[list] = None +class AttributeUpdate(BaseModel): + name_de: Optional[str] = None + name_en: Optional[str] = None + unit: Optional[str] = None + category: Optional[str] = None + sort_order: Optional[int] = None + + class ManualFoodCreate(BaseModel): name_de: str name_en: Optional[str] = None macros_per_100g: Optional[dict] = None + attributes: Optional[dict] = None + serving_g: Optional[float] = None @router.get("/status") @@ -162,7 +172,9 @@ def admin_list_foods( cur = get_cursor(conn) conds = ["is_active = true"] params: list = [] - if kind: + if kind == "manual": + conds.append("catalog_kind IN ('manual_user', 'manual_admin')") + elif kind: conds.append("catalog_kind = %s") params.append(kind) if q: @@ -172,7 +184,7 @@ def admin_list_foods( params.append(limit) cur.execute( f""" - SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version + SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version, owner_profile_id FROM food_catalog WHERE {' AND '.join(conds)} ORDER BY name_de @@ -184,7 +196,7 @@ def admin_list_foods( @router.get("/foods/{food_id}") -def admin_food_detail(food_id: str, session: dict = Depends(require_admin)): +def admin_food_detail(food_id: str, editor: bool = False, session: dict = Depends(require_admin)): from data_layer.nutrition_items import resolve_food_attributes with get_db() as conn: @@ -193,17 +205,28 @@ def admin_food_detail(food_id: str, session: dict = Depends(require_admin)): row = cur.fetchone() if not row: raise HTTPException(404, "Lebensmittel nicht gefunden") + food = dict(row) + if editor: + from data_layer.food_attributes import editor_payload_for_food + from data_layer.food_visibility import attach_visibility + + editable = food.get("catalog_kind") in ("manual_admin", "manual_user") + return attach_visibility(editor_payload_for_food(cur, food, editable=editable)) attrs = resolve_food_attributes(cur, food_id) return {**r2d(row), "attributes": attrs} @router.post("/foods/manual") def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(require_admin)): + from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes from data_layer.food_mapping import normalize_food_name + from data_layer.food_visibility import attach_visibility name = (body.name_de or "").strip() if not name: raise HTTPException(400, "Name fehlt") + serving = body.serving_g if body.serving_g and body.serving_g > 0 else None + values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving) with get_db() as conn: cur = get_cursor(conn) cur.execute( @@ -215,14 +238,70 @@ def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(req (name, body.name_en, f"man-admin-{normalize_food_name(name)[:40]}"), ) food = r2d(cur.fetchone()) - _write_manual_macros(cur, food["id"], body.macros_per_100g) + write_numeric_attributes(cur, food["id"], values) from data_layer.food_suggest import invalidate_suggest_index invalidate_suggest_index() - return food + return attach_visibility(food) + + +@router.put("/foods/manual/{food_id}") +def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict = Depends(require_admin)): + from collections import defaultdict + + from data_layer.food_attributes import ( + editor_payload_for_food, + macros_and_attributes_to_values, + replace_numeric_attributes, + ) + from data_layer.food_visibility import attach_visibility + from data_layer.nutrition_items import profiles_dates_using_food, schedule_daily_nutrient_rebuild + + name = (body.name_de or "").strip() + if not name: + raise HTTPException(400, "Name fehlt") + serving = body.serving_g if body.serving_g and body.serving_g > 0 else None + values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name_de, name_en, catalog_kind, owner_profile_id + FROM food_catalog + WHERE id = %s AND catalog_kind IN ('manual_admin', 'manual_user') AND is_active = true + """, + (food_id,), + ) + food = cur.fetchone() + if not food: + raise HTTPException(404, "Manuelles Lebensmittel nicht gefunden") + cur.execute( + """ + UPDATE food_catalog + SET name_de = %s, name_en = %s, updated_at = NOW() + WHERE id = %s + RETURNING id, name_de, name_en, catalog_kind, owner_profile_id + """, + (name, body.name_en, food_id), + ) + updated = dict(cur.fetchone()) + replace_numeric_attributes(cur, food_id, values) + pairs = profiles_dates_using_food(cur, food_id) + payload = attach_visibility(editor_payload_for_food(cur, updated, editable=True)) + from data_layer.food_suggest import invalidate_suggest_index + invalidate_suggest_index() + by_profile = defaultdict(list) + for pid, day in pairs: + by_profile[pid].append(day) + for pid, dates in by_profile.items(): + schedule_daily_nutrient_rebuild(pid, dates, f"Zentrales Lebensmittel {name}") + payload["rebuild_pending"] = len(pairs) + return payload @router.get("/attributes") def admin_list_attributes(session: dict = Depends(require_admin)): + from bls.attribute_labels import annotate_attribute, sort_attributes + with get_db() as conn: cur = get_cursor(conn) cur.execute( @@ -230,10 +309,18 @@ def admin_list_attributes(session: dict = Depends(require_admin)): SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order FROM food_attributes WHERE is_active = true - ORDER BY origin, sort_order, attr_key """ ) - return [r2d(r) for r in cur.fetchall()] + return sort_attributes([annotate_attribute(r2d(r)) for r in cur.fetchall()]) + + +@router.post("/attributes/fill-labels") +def admin_fill_attribute_labels(session: dict = Depends(require_admin)): + from bls.attribute_labels import fill_placeholder_labels + + with get_db() as conn: + cur = get_cursor(conn) + return fill_placeholder_labels(cur) @router.post("/attributes") @@ -263,6 +350,50 @@ def admin_create_attribute(body: AttributeCreate, session: dict = Depends(requir return r2d(cur.fetchone()) +@router.put("/attributes/{attr_id}") +def admin_update_attribute(attr_id: int, body: AttributeUpdate, session: dict = Depends(require_admin)): + fields = [] + params: list = [] + if body.name_de is not None: + name = body.name_de.strip() + if not name: + raise HTTPException(400, "Name fehlt") + fields.append("name_de = %s") + params.append(name) + if body.name_en is not None: + fields.append("name_en = %s") + params.append(body.name_en.strip() or None) + if body.unit is not None: + fields.append("unit = %s") + params.append(body.unit.strip() or None) + if body.category is not None: + fields.append("category = %s") + params.append(body.category.strip() or None) + if body.sort_order is not None: + fields.append("sort_order = %s") + params.append(body.sort_order) + if not fields: + raise HTTPException(400, "Keine Änderung") + fields.append("updated_at = NOW()") + params.append(attr_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + UPDATE food_attributes + SET {', '.join(fields)} + WHERE id = %s AND is_active = true + RETURNING id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order + """, + params, + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Stoff nicht gefunden") + from bls.attribute_labels import annotate_attribute + return annotate_attribute(r2d(row)) + + def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None: from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None)) diff --git a/backend/routers/admin_food_mappings.py b/backend/routers/admin_food_mappings.py index dbdfc2c..886d2f1 100644 --- a/backend/routers/admin_food_mappings.py +++ b/backend/routers/admin_food_mappings.py @@ -52,7 +52,8 @@ def list_food_mappings( q += " WHERE " + " AND ".join(conds) q += " ORDER BY m.source_name_normalized" cur.execute(q, params) - return [r2d(r) for r in cur.fetchall()] + from data_layer.food_visibility import attach_visibility + return [attach_visibility(r2d(r)) for r in cur.fetchall()] @router.get("/stats/coverage") @@ -77,9 +78,13 @@ def mapping_coverage(session: dict = Depends(require_admin)): def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require_admin)): with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT id FROM food_catalog WHERE id = %s", (body.food_id,)) - if not cur.fetchone(): - raise HTTPException(404, "Lebensmittel nicht gefunden") + from data_layer.food_visibility import require_mapping_target + try: + require_mapping_target(cur, body.food_id, body.profile_id or None) + except LookupError as e: + raise HTTPException(404, str(e)) from e + except PermissionError as e: + raise HTTPException(400, str(e)) from e mid = upsert_food_mapping( cur, source_name_raw=body.source_name, @@ -114,9 +119,17 @@ def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict row = cur.fetchone() if not row: raise HTTPException(404, "Mapping nicht gefunden") - cur.execute("SELECT id FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,)) - if not cur.fetchone(): + cur.execute("SELECT id, catalog_kind, owner_profile_id, is_active FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,)) + food = cur.fetchone() + if not food: raise HTTPException(404, "Lebensmittel nicht gefunden") + from data_layer.food_visibility import require_mapping_target + try: + require_mapping_target(cur, body.food_id, row.get("profile_id")) + except LookupError as e: + raise HTTPException(404, str(e)) from e + except PermissionError as e: + raise HTTPException(400, str(e)) from e cur.execute( """ UPDATE food_name_mappings diff --git a/backend/routers/bls.py b/backend/routers/bls.py index 8ea6384..9a7f75a 100644 --- a/backend/routers/bls.py +++ b/backend/routers/bls.py @@ -52,6 +52,12 @@ class SuggestBatchBody(BaseModel): limit: int = 3 +class UserAttributeCreate(BaseModel): + name_de: str + unit: str = "g" + attr_key: Optional[str] = None + + def _pid(session: dict, x_profile_id: Optional[str] = None) -> str: return x_profile_id or session["profile_id"] @@ -89,23 +95,41 @@ def list_food_units(session: dict = Depends(require_auth)): def search_foods( q: str = "", limit: int = 20, + scope: str = "visible", session: dict = Depends(require_auth), ): pid = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) - return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50)) + hits = suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50)) + if scope == "global": + hits = [h for h in hits if h.get("visibility") != "personal"] + return hits @router.get("/attributes") def list_food_attributes( q: str = "", + keys: str = "", limit: int = 40, session: dict = Depends(require_auth), ): from data_layer.food_attributes import list_numeric_attributes + key_list = [p.strip() for p in (keys or "").split(",") if p.strip()] with get_db() as conn: - return list_numeric_attributes(get_cursor(conn), q, limit=limit) + return list_numeric_attributes(get_cursor(conn), q, limit=limit, keys=key_list or None) + + +@router.post("/attributes") +def create_food_attribute(body: UserAttributeCreate, session: dict = Depends(require_auth)): + from data_layer.food_attributes import create_extension_attribute + try: + with get_db() as conn: + return create_extension_attribute( + get_cursor(conn), body.name_de, body.unit, body.attr_key + ) + except ValueError as e: + raise HTTPException(400, str(e)) from e @router.post("/foods/suggest-batch") @@ -152,6 +176,122 @@ def create_user_food( return food +@router.get("/foods/mine") +def list_my_manual_foods(session: dict = Depends(require_auth)): + pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name_de, catalog_kind, updated_at + FROM food_catalog + WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true + ORDER BY name_de + """, + (pid,), + ) + rows = [r2d(r) for r in cur.fetchall()] + from data_layer.food_visibility import attach_visibility + for row in rows: + row["id"] = str(row["id"]) + attach_visibility(row) + return rows + + +@router.get("/foods/shared") +def list_shared_manual_foods(session: dict = Depends(require_auth)): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name_de, catalog_kind, updated_at + FROM food_catalog + WHERE catalog_kind = 'manual_admin' AND owner_profile_id IS NULL AND is_active = true + ORDER BY name_de + """ + ) + from data_layer.food_visibility import attach_visibility + rows = [r2d(r) for r in cur.fetchall()] + for row in rows: + row["id"] = str(row["id"]) + attach_visibility(row) + return rows + + +@router.get("/foods/{food_id}") +def get_food_detail(food_id: str, session: dict = Depends(require_auth)): + from data_layer.food_attributes import editor_payload_for_food + + pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name_de, name_en, catalog_kind, owner_profile_id + FROM food_catalog + WHERE id = %s AND is_active = true + AND (owner_profile_id IS NULL OR owner_profile_id = %s) + """, + (food_id, pid), + ) + food = cur.fetchone() + if not food: + raise HTTPException(404, "Lebensmittel nicht gefunden") + food = dict(food) + from data_layer.food_visibility import attach_visibility, can_edit_food + editable = can_edit_food(food, pid, is_admin=session.get("role") == "admin") + return attach_visibility(editor_payload_for_food(cur, food, editable=editable)) + + +@router.put("/foods/manual/{food_id}") +def update_user_food(food_id: str, body: UserFoodCreate, session: dict = Depends(require_auth)): + from data_layer.food_attributes import ( + editor_payload_for_food, + macros_and_attributes_to_values, + replace_numeric_attributes, + ) + from data_layer.nutrition_items import dates_using_food + + pid = session["profile_id"] + name = (body.name_de or "").strip() + if not name: + raise HTTPException(400, "Name fehlt") + serving = body.serving_g if body.serving_g and body.serving_g > 0 else None + values = macros_and_attributes_to_values(body.macros_per_100g, body.attributes, serving) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name_de, name_en, catalog_kind, owner_profile_id + FROM food_catalog + WHERE id = %s AND catalog_kind = 'manual_user' AND owner_profile_id = %s AND is_active = true + """, + (food_id, pid), + ) + food = cur.fetchone() + if not food: + raise HTTPException(404, "Eigenes Lebensmittel nicht gefunden") + cur.execute( + """ + UPDATE food_catalog + SET name_de = %s, name_en = %s, updated_at = NOW() + WHERE id = %s + RETURNING id, name_de, name_en, catalog_kind, owner_profile_id + """, + (name, body.name_en, food_id), + ) + updated = dict(cur.fetchone()) + replace_numeric_attributes(cur, food_id, values) + dates = dates_using_food(cur, pid, food_id) + payload = editor_payload_for_food(cur, updated, editable=True) + from data_layer.food_visibility import attach_visibility + payload = attach_visibility(payload) + invalidate_suggest_index(pid) + _schedule_rebuild(pid, dates, f"Lebensmittel {name}") + payload["rebuild_pending"] = len(dates) + return payload + + @router.get("/mappings") def list_my_mappings( x_profile_id: Optional[str] = Header(default=None), @@ -172,7 +312,8 @@ def list_my_mappings( """, (pid,), ) - return [r2d(r) for r in cur.fetchall()] + from data_layer.food_visibility import attach_visibility + return [attach_visibility(r2d(r)) for r in cur.fetchall()] @router.post("/mappings") @@ -184,27 +325,26 @@ def upsert_my_mapping( pid = _pid(session, x_profile_id) with get_db() as conn: cur = get_cursor(conn) - cur.execute( - """ - SELECT id, name_de, bls_code, catalog_kind FROM food_catalog - WHERE id = %s AND is_active = true - AND (owner_profile_id IS NULL OR owner_profile_id = %s) - """, - (body.food_id, pid), - ) - food = cur.fetchone() - if not food: - raise HTTPException(404, "Lebensmittel nicht gefunden") - mid = upsert_food_mapping( - cur, - source_name_raw=body.source_name, - food_id=body.food_id, - profile_id=pid, - source="bulk", - source_system=body.source_system, - grams_per_unit=body.grams_per_unit, - source_unit=body.source_unit, - ) + from data_layer.food_visibility import require_visible_food, visibility_of + try: + food = require_visible_food(cur, body.food_id, pid) + except LookupError as e: + raise HTTPException(404, str(e)) from e + try: + mid = upsert_food_mapping( + cur, + source_name_raw=body.source_name, + food_id=body.food_id, + profile_id=pid, + source="bulk", + source_system=body.source_system, + grams_per_unit=body.grams_per_unit, + source_unit=body.source_unit, + ) + except PermissionError as e: + raise HTTPException(400, str(e)) from e + except LookupError as e: + raise HTTPException(404, str(e)) from e norm = normalize_food_name(body.source_name) n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid) apply_quantities_to_items(cur, pid, norm, body.grams_per_unit) @@ -218,6 +358,7 @@ def upsert_my_mapping( "food_name_de": food["name_de"], "bls_code": food.get("bls_code"), "catalog_kind": food.get("catalog_kind"), + "visibility": visibility_of(food), } diff --git a/backend/tests/test_attribute_labels.py b/backend/tests/test_attribute_labels.py new file mode 100644 index 0000000..aa4881f --- /dev/null +++ b/backend/tests/test_attribute_labels.py @@ -0,0 +1,57 @@ +from bls.attribute_labels import ( + annotate_attribute, + apply_official_name, + fill_placeholder_labels, + label_is_placeholder, + official_label, + resolve_group, + sort_attributes, +) + + +def test_official_macro_labels(): + assert official_label("ENERCC")["name_de"].startswith("Energie") + assert official_label("cho")["name_de"].startswith("Kohlenhydrate") + assert label_is_placeholder("ENERCC", "ENERCC") + assert label_is_placeholder("", "FAT") + assert not label_is_placeholder("Fett", "FAT") + assert apply_official_name("FAT", "FAT") == "Fett" + assert apply_official_name("FAT", "Fett gesamt") == "Fett gesamt" + + +def test_fill_placeholder_labels_skips_custom_names(): + class _Cur: + def __init__(self): + self.rows = [ + {"id": 1, "attr_key": "FAT", "name_de": "FAT", "unit": None, "category": None}, + {"id": 2, "attr_key": "EPA", "name_de": "Mein EPA", "unit": "g", "category": None}, + ] + self.updated = [] + + def execute(self, sql, params=None): + self.last = (sql, params) + if "UPDATE" in sql: + self.updated.append(params) + + def fetchall(self): + return self.rows + + cur = _Cur() + stats = fill_placeholder_labels(cur) + assert stats["updated"] == 2 + assert cur.updated[0][0] == "Fett" + assert cur.updated[0][3] == 1 + assert cur.updated[1][0] == "Mein EPA" + assert cur.updated[1][2] == "fatty_acids" + + +def test_group_and_sort_macros_first(): + assert resolve_group("ENERCC")[0] == "energy" + assert resolve_group("NA", None, None)[1] == "Mineralstoffe" + assert resolve_group("XYZ", None, "extension")[0] == "extension" + rows = sort_attributes([ + annotate_attribute({"attr_key": "NA", "name_de": "Natrium", "sort_order": 2, "origin": "official_bls"}), + annotate_attribute({"attr_key": "FAT", "name_de": "Fett", "sort_order": 9, "origin": "official_bls"}), + annotate_attribute({"attr_key": "ENERCC", "name_de": "Energie", "sort_order": 1, "origin": "official_bls"}), + ]) + assert [r["attr_key"] for r in rows] == ["ENERCC", "FAT", "NA"] diff --git a/backend/tests/test_bls_parser.py b/backend/tests/test_bls_parser.py index d399c33..9d8ffe0 100644 --- a/backend/tests/test_bls_parser.py +++ b/backend/tests/test_bls_parser.py @@ -30,6 +30,18 @@ def test_parse_components_dynamic(): assert "NA" in keys +def test_parse_components_official_bezeichnung_and_empty_name(): + data = _xlsx([ + ["Component code", "Bezeichnung", "English", "Einheit"], + ["PROT625", "", "Protein", "g/100g"], + ["FAT", "Fett", "Fat", "g/100g"], + ]) + attrs = parse_components_xlsx(data) + by_key = {a["attr_key"]: a for a in attrs} + assert by_key["PROT625"]["name_de"] == "Protein (N×6,25)" + assert by_key["FAT"]["name_de"] == "Fett" + + def test_parse_foods_keeps_bls_code(): data = _xlsx([ ["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"], diff --git a/backend/tests/test_food_attributes.py b/backend/tests/test_food_attributes.py index 7bb2883..a471217 100644 --- a/backend/tests/test_food_attributes.py +++ b/backend/tests/test_food_attributes.py @@ -1,4 +1,11 @@ -from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g +from data_layer.food_attributes import ( + create_extension_attribute, + editor_payload_for_food, + macros_and_attributes_to_values, + normalize_attr_key, + normalize_attr_unit, + scale_to_per_100g, +) def test_scale_serving_to_per_100g(): @@ -7,6 +14,28 @@ def test_scale_serving_to_per_100g(): assert scale_to_per_100g(10, None) == 10 +def test_attr_key_and_unit_normalize(): + assert normalize_attr_key("Vitamin D3") == "VITAMIN_D3" + assert normalize_attr_unit("ug") == "µg" + assert normalize_attr_unit("mcg") == "µg" + + +def test_create_extension_returns_existing(): + class _Cur: + def __init__(self): + self.calls = 0 + + def execute(self, sql, params=None): + self.calls += 1 + self._row = {"id": 1, "attr_key": "VITAMIN_D3", "name_de": "Vitamin D3", "name_en": None, "unit": "µg", "category": "extension", "origin": "extension"} if self.calls == 1 else None + + def fetchone(self): + return self._row + + row = create_extension_attribute(_Cur(), "Vitamin D3", "µg") + assert row["attr_key"] == "VITAMIN_D3" + + def test_norsan_label_epa_converts(): """Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g.""" vals = macros_and_attributes_to_values( @@ -17,3 +46,41 @@ def test_norsan_label_epa_converts(): assert abs(vals["FAT"] - 100) < 0.01 assert abs(vals["EPA"] - 13.75) < 0.01 assert abs(vals["ENERCC"] - 900) < 0.01 + + +def test_edit_payload_does_not_rescale_at_100g(): + vals = macros_and_attributes_to_values( + {"kcal": 900, "protein_g": 0, "fat_g": 100, "carbs_g": 0}, + {"EPA": 13.75}, + serving_g=100, + ) + assert vals["FAT"] == 100 + assert vals["EPA"] == 13.75 + assert vals["ENERCC"] == 900 + + +def test_editor_payload_splits_macros_and_extras(): + class _Cur: + def execute(self, sql, params=None): + pass + + def fetchall(self): + return [ + {"attr_key": "ENERCC", "name_de": "Energie", "name_en": None, "unit": "kcal", "category": "energy", "origin": "bls", "value_num": 900}, + {"attr_key": "FAT", "name_de": "Fett", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 100}, + {"attr_key": "PROT625", "name_de": "Protein", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 0}, + {"attr_key": "CHO", "name_de": "KH", "name_en": None, "unit": "g", "category": "macro", "origin": "bls", "value_num": 0}, + {"attr_key": "EPA", "name_de": "EPA", "name_en": None, "unit": "g", "category": "extension", "origin": "extension", "value_num": 13.75}, + ] + + payload = editor_payload_for_food( + _Cur(), + {"id": "abc", "name_de": "Omega-3", "name_en": None, "catalog_kind": "manual_user"}, + editable=True, + ) + assert payload["editable"] is True + assert payload["serving_g"] == 100 + assert payload["macros_per_100g"]["kcal"] == 900 + assert payload["macros_per_100g"]["fat_g"] == 100 + assert payload["attributes"][0]["attr"]["attr_key"] == "EPA" + assert payload["attributes"][0]["value"] == 13.75 diff --git a/backend/tests/test_food_suggest.py b/backend/tests/test_food_suggest.py index 3c4dc03..523bf15 100644 --- a/backend/tests/test_food_suggest.py +++ b/backend/tests/test_food_suggest.py @@ -105,3 +105,11 @@ def test_joghurt_10_ranks_above_35(): packed = suggest_for_name(index, query, limit=3) assert packed["suggestions"][0]["name_de"] == full["name_de"], query assert packed["suggestions"][0]["score"] > score_name_match(query, light["name_de"]) + + +def test_personal_food_ranks_above_bls_on_tie(): + bls = {"id": "bls", "name_de": "Omega-3 Öl", "name_en": "", "bls_code": "X", "catalog_kind": "official_bls"} + mine = {"id": "me", "name_de": "Omega-3 Öl", "name_en": "", "bls_code": None, "catalog_kind": "manual_user", "owner_profile_id": "u1"} + packed = suggest_for_name(_index(bls, mine), "Omega-3 Öl", limit=2) + assert packed["suggestions"][0]["id"] == "me" + assert packed["suggestions"][0]["visibility"] == "personal" diff --git a/backend/tests/test_food_visibility.py b/backend/tests/test_food_visibility.py new file mode 100644 index 0000000..07a1f42 --- /dev/null +++ b/backend/tests/test_food_visibility.py @@ -0,0 +1,108 @@ +from data_layer.food_visibility import ( + attach_visibility, + can_edit_food, + is_global_mapping_target, + is_visible_to, + require_mapping_target, + same_id, + visibility_of, + visibility_rank, +) + + +def test_can_edit_central_only_as_admin(): + central = {"catalog_kind": "manual_admin", "owner_profile_id": None, "is_active": True} + personal = {"catalog_kind": "manual_user", "owner_profile_id": "alice", "is_active": True} + bls = {"catalog_kind": "official_bls", "owner_profile_id": None, "is_active": True} + assert can_edit_food(central, "alice", is_admin=False) is False + assert can_edit_food(central, "alice", is_admin=True) is True + assert can_edit_food(personal, "alice", is_admin=False) is True + assert can_edit_food(personal, "bob", is_admin=False) is False + assert can_edit_food(personal, "bob", is_admin=True) is True + assert can_edit_food(bls, "alice", is_admin=True) is False + + +def test_same_id_ignores_dashes_and_case(): + assert same_id("AaBbCcDd-0000-0000-0000-000000000001", "aabbccdd000000000000000000000001") + + +def test_visibility_scopes(): + assert visibility_of({"catalog_kind": "official_bls"}) == "bls" + assert visibility_of({"catalog_kind": "manual_admin", "owner_profile_id": None}) == "global" + assert visibility_of({"catalog_kind": "manual_user", "owner_profile_id": "u1"}) == "personal" + assert visibility_rank({"catalog_kind": "manual_user"}) < visibility_rank({"catalog_kind": "manual_admin"}) + assert visibility_rank({"catalog_kind": "manual_admin"}) < visibility_rank({"catalog_kind": "official_bls"}) + + +def test_personal_food_not_visible_to_others(): + food = {"catalog_kind": "manual_user", "owner_profile_id": "alice", "is_active": True} + assert is_visible_to(food, "alice") is True + assert is_visible_to(food, "bob") is False + assert is_global_mapping_target(food) is False + + +def test_admin_and_bls_visible_to_everyone(): + admin = {"catalog_kind": "manual_admin", "owner_profile_id": None, "is_active": True} + bls = {"catalog_kind": "official_bls", "owner_profile_id": None, "is_active": True} + assert is_visible_to(admin, "bob") is True + assert is_visible_to(bls, None) is True + assert is_global_mapping_target(admin) is True + assert is_global_mapping_target(bls) is True + + +def test_attach_visibility(): + row = attach_visibility({"catalog_kind": "manual_admin"}) + assert row["visibility"] == "global" + + +def test_require_global_mapping_rejects_personal(): + class _Cur: + def execute(self, sql, params=None): + self._row = { + "id": params[0], + "name_de": "Öl", + "catalog_kind": "manual_user", + "owner_profile_id": "alice", + "is_active": True, + "bls_code": None, + } + + def fetchone(self): + return self._row + + try: + require_mapping_target(_Cur(), "fid", None) + assert False, "expected PermissionError" + except PermissionError: + pass + + +def test_require_user_mapping_allows_own_and_global(): + class _Cur: + def __init__(self, food): + self.food = food + + def execute(self, sql, params=None): + pass + + def fetchone(self): + return self.food + + own = require_mapping_target( + _Cur({ + "id": "1", "name_de": "X", "catalog_kind": "manual_user", + "owner_profile_id": "alice", "is_active": True, "bls_code": None, + }), + "1", + "alice", + ) + assert own["catalog_kind"] == "manual_user" + shared = require_mapping_target( + _Cur({ + "id": "2", "name_de": "Y", "catalog_kind": "manual_admin", + "owner_profile_id": None, "is_active": True, "bls_code": None, + }), + "2", + "alice", + ) + assert shared["catalog_kind"] == "manual_admin" diff --git a/backend/version.py b/backend/version.py index 84f4f38..6dcc44a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -8,8 +8,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH """ APP_VERSION = "0.9v" -BUILD_DATE = "2026-09-12" -DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes +BUILD_DATE = "2026-09-14" +DB_SCHEMA_VERSION = "20260914a" # 069 food catalog visibility owner check MODULE_VERSIONS = { "auth": "1.2.0", @@ -20,9 +20,10 @@ MODULE_VERSIONS = { "circumference": "1.0.1", "caliper": "1.0.1", "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement - "nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS + "nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping "tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) - "bls": "1.0.5", # GET /bls/units + "bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen) + "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", @@ -55,6 +56,9 @@ CHANGELOG = [ "Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen", "Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%", "Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g", + "Eigene Lebensmittel nachträglich bearbeiten (Name, Makros, Stoffe); BLS bleibt fest", + "Reiter Lebensmittel (Nutzer) und Admin-Katalog Manuell: Liste + Suche", + "Sichtbarkeit: persönliche Foods nur Owner, zentrale Admin-Foods für alle; Mapping trennt global vs. Nutzer", "Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern", "Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später", "Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm", diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md index 1dead09..e449738 100644 --- a/docs/issues/issue-bls-food-mapping.md +++ b/docs/issues/issue-bls-food-mapping.md @@ -19,6 +19,7 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende - Zuordnungen und Listen als JSON zwischen Instanzen übertragen - Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping - Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung +- Eigene Lebensmittel nachträglich bearbeiten (Tab Ernährung → Lebensmittel; persönlich vs. zentral; BLS unveränderlich) - Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination - Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück) - Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import) diff --git a/frontend/src/app.css b/frontend/src/app.css index f8092ee..aebbfc2 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -191,7 +191,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we /* Tabs */ .tabs { display: flex; gap: 4px; background: var(--surface2); border-radius: 10px; padding: 3px; margin-bottom: 16px; } -.tab { flex: 1; text-align: center; padding: 7px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text3); cursor: pointer; border: none; background: transparent; font-family: var(--font); } +.tab { flex: 1; text-align: center; padding: 7px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text3); cursor: pointer; border: none; background: transparent; font-family: var(--font); white-space: nowrap; } .tab.active { background: var(--surface); color: var(--text1); box-shadow: 0 1px 3px rgba(0,0,0,0.1); } /* Section */ diff --git a/frontend/src/components/FoodEditDialog.jsx b/frontend/src/components/FoodEditDialog.jsx new file mode 100644 index 0000000..aa943db --- /dev/null +++ b/frontend/src/components/FoodEditDialog.jsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react' +import { api } from '../utils/api' +import { useAuth } from '../context/AuthContext' +import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' + +function numStr(v) { + if (v == null || v === '') return '0' + const n = Number(v) + return Number.isFinite(n) ? String(n) : '0' +} + +export default function FoodEditDialog({ foodId, onClose, onSaved, zIndex, mode }) { + const { isAdmin: sessionAdmin } = useAuth() || {} + const isCreate = !foodId + const createAsAdmin = mode === 'admin' + const [name, setName] = useState('') + const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' }) + const [extras, setExtras] = useState([]) + const [servingG, setServingG] = useState('100') + const [catalogKind, setCatalogKind] = useState(null) + const [editable, setEditable] = useState(false) + const [loading, setLoading] = useState(!isCreate) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const saveAsAdmin = isCreate + ? createAsAdmin + : Boolean(sessionAdmin && catalogKind && catalogKind !== 'official_bls' && ( + catalogKind === 'manual_admin' || mode === 'admin' + )) + + useEffect(() => { + if (isCreate) { + setEditable(true) + setLoading(false) + return + } + let alive = true + const load = (sessionAdmin || mode === 'admin') + ? api.adminBlsFoodDetail(foodId, true).catch(() => api.getBlsFood(foodId)) + : api.getBlsFood(foodId) + load.then((d) => { + if (!alive) return + setName(d.name_de || '') + setCatalogKind(d.catalog_kind || null) + const m = d.macros_per_100g || {} + setMacros({ + kcal: numStr(m.kcal), + protein_g: numStr(m.protein_g), + fat_g: numStr(m.fat_g), + carbs_g: numStr(m.carbs_g), + }) + setExtras((d.attributes || []).filter((row) => row?.attr).map((row) => ({ + attr: row.attr, + value: numStr(row.value), + inputUnit: row.attr?.unit || 'g', + }))) + setServingG(numStr(d.serving_g || 100) === '0' ? '100' : numStr(d.serving_g || 100)) + const canEdit = Boolean(d.editable) || ( + sessionAdmin && d.catalog_kind && d.catalog_kind !== 'official_bls' + ) + setEditable(canEdit) + setLoading(false) + }).catch((e) => { + if (!alive) return + setError(e.message) + setLoading(false) + }) + return () => { alive = false } + }, [foodId, isCreate, sessionAdmin, mode]) + + const save = async () => { + if (!name.trim()) { + setError('Name fehlt') + return + } + setSaving(true) + setError(null) + try { + const body = foodCreatePayload(name, macros, extras, servingG) + let saved + if (saveAsAdmin) { + saved = isCreate ? await api.adminCreateManualFood(body) : await api.adminUpdateManualFood(foodId, body) + } else { + saved = isCreate ? await api.createUserFood(body) : await api.updateUserFood(foodId, body) + } + onSaved?.(saved) + onClose?.() + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + const title = isCreate + ? (createAsAdmin ? 'Zentrales Lebensmittel anlegen' : 'Lebensmittel anlegen') + : (catalogKind === 'manual_admin' ? 'Zentrales Lebensmittel bearbeiten' : 'Lebensmittel bearbeiten') + + return ( +
+
+
+

{title}

+ +
+
+ {loading &&

Lade…

} + {error &&
{error}
} + {!loading && !editable && ( +

+ {catalogKind === 'manual_admin' + ? 'Zentrale Lebensmittel ändert nur ein Admin.' + : 'Offizielle BLS-Einträge sind fest. Nur selbst angelegte Lebensmittel lassen sich ändern.'} +

+ )} + {!loading && editable && ( + <> + + setName(e.target.value)} + /> + + + )} +
+ {editable && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/FoodNutrientFields.jsx b/frontend/src/components/FoodNutrientFields.jsx index e92a670..3239356 100644 --- a/frontend/src/components/FoodNutrientFields.jsx +++ b/frontend/src/components/FoodNutrientFields.jsx @@ -1,16 +1,35 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' +const MASS = { g: 1, mg: 0.001, 'µg': 0.000001, ug: 0.000001, mcg: 0.000001 } +const QUICK_KEYS = ['EPA', 'DHA', 'DPA', 'OMEGA3'] + function parseNum(raw) { const n = parseFloat(String(raw ?? '').replace(',', '.')) return Number.isFinite(n) ? n : 0 } +function attrUnit(attr) { + const u = (attr?.unit || 'g').trim() + if (u === 'ug' || u === 'mcg') return 'µg' + return u +} + +export function convertMass(value, fromUnit, toUnit) { + const from = MASS[(fromUnit || 'g').toLowerCase()] + const to = MASS[(toUnit || 'g').toLowerCase()] + if (!from || !to) return value + return value * (from / to) +} + export function foodCreatePayload(name, macros, extras, servingG) { const attributes = {} for (const row of extras || []) { if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue - attributes[row.attr.attr_key] = parseNum(row.value) + const n = parseNum(row.value) + const target = attrUnit(row.attr) + const entered = row.inputUnit || target + attributes[row.attr.attr_key] = convertMass(n, entered, target) } const serving = parseNum(servingG) return { @@ -26,13 +45,23 @@ export function foodCreatePayload(name, macros, extras, servingG) { } } -export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) { +export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG, valuesArePer100g }) { const [q, setQ] = useState('') const [hits, setHits] = useState([]) + const [quick, setQuick] = useState([]) const [loading, setLoading] = useState(false) + const [newName, setNewName] = useState('') + const [newUnit, setNewUnit] = useState('g') + const [creating, setCreating] = useState(false) + const [createErr, setCreateErr] = useState(null) const timer = useRef(null) useEffect(() => () => clearTimeout(timer.current), []) + useEffect(() => { + api.listFoodAttributes('', 20, QUICK_KEYS.join(',')).then((rows) => { + setQuick(Array.isArray(rows) ? rows : []) + }).catch(() => {}) + }, []) const search = (term) => { clearTimeout(timer.current) @@ -55,7 +84,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra const add = (attr) => { setExtras((list) => ( - list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }] + list.some((r) => r.attr.attr_key === attr.attr_key) + ? list + : [...list, { attr, value: '', inputUnit: attrUnit(attr) }] )) setQ('') setHits([]) @@ -65,10 +96,37 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r))) } + const setInputUnit = (key, unit) => { + setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, inputUnit: unit } : r))) + } + + const createAttr = async () => { + const name = (newName || q).trim() + if (!name) { + setCreateErr('Name des Stoffs fehlt') + return + } + setCreating(true) + setCreateErr(null) + try { + const attr = await api.createFoodAttribute({ name_de: name, unit: newUnit }) + add(attr) + setNewName('') + } catch (e) { + setCreateErr(e.message) + } finally { + setCreating(false) + } + } + + const unusedQuick = quick.filter((a) => !extras.some((r) => r.attr.attr_key === a.attr_key)) + return (

- Zahlen in der Einheit des Stoffs. Bei Etikett „pro 8 ml / 8 g“ die Portionsgröße setzen — gespeichert wird immer pro 100 g. + {valuesArePer100g + ? 'Geladen sind die gespeicherten Werte je 100 g. Portion nur ändern, wenn du vom Etikett neu einträgst (sonst 100 lassen).' + : 'Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl ≈ 5 g). Gespeichert wird immer pro 100 g. Ein Öl/Supplement ist ein Lebensmittel, keine Liste — Listen können keine Listen enthalten.'}

{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => ( @@ -99,11 +157,20 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra ))}
-

Weitere Stoffe (EPA, DHA, Ballaststoffe, …)

+

Weitere Stoffe

+ {unusedQuick.length > 0 && ( +
+ {unusedQuick.map((a) => ( + + ))} +
+ )} { setQ(e.target.value); search(e.target.value) }} /> @@ -122,21 +189,72 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra ))} - {extras.map((row) => ( - - ))} +
+

Stoff nicht gefunden? Neu anlegen (wird für alle Lebensmittel nutzbar).

+ setNewName(e.target.value)} + /> +
+ + +
+ {createErr &&

{createErr}

} +
+ {extras.map((row) => { + const target = attrUnit(row.attr) + const entered = row.inputUnit || target + const mass = MASS[target.toLowerCase()] + return ( + + ) + })}
) } diff --git a/frontend/src/components/FoodRecipeDialog.jsx b/frontend/src/components/FoodRecipeDialog.jsx index 01a8bb5..841089f 100644 --- a/frontend/src/components/FoodRecipeDialog.jsx +++ b/frontend/src/components/FoodRecipeDialog.jsx @@ -1,6 +1,9 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' +import { useAuth } from '../context/AuthContext' import FoodSearchModal from './FoodSearchModal' +import FoodEditDialog from './FoodEditDialog' +import { foodVisibilityLabel } from '../utils/foodVisibility' const FALLBACK_UNITS = [ { id: 'g', label: 'g', default_g: 1, needs_unit_map: false }, @@ -111,6 +114,7 @@ export default function FoodRecipeDialog({ onSave, onClose, }) { + const { isAdmin } = useAuth() const [name, setName] = useState(defaultName || '') const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1') const [ings, setIngs] = useState( @@ -128,6 +132,7 @@ export default function FoodRecipeDialog({ const [knownNames, setKnownNames] = useState([]) const [suggestions, setSuggestions] = useState({}) const [mapFor, setMapFor] = useState(null) + const [editFoodId, setEditFoodId] = useState(null) const nameRef = useRef(null) const qtyRefs = useRef({}) const timer = useRef(null) @@ -476,21 +481,27 @@ export default function FoodRecipeDialog({ ))} {searching &&

Katalog…

} {userHits.length > 0 && ( -
Eigene Lebensmittel
+
Persönlich und zentral
)} {userHits.map((h) => ( - +
+ + {(h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')) && ( + + )} +
))} {blsHits.length > 0 && (
BLS-Katalog
@@ -623,6 +634,14 @@ export default function FoodRecipeDialog({ onSelect={(food, extras) => assignIngredient(mapFor.source_name_raw, food, extras).catch((e) => setError(e.message))} /> )} + {editFoodId && ( + setEditFoodId(null)} + onSaved={() => runCatalog(q)} + /> + )} ) } diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx index d13c0a8..10ab244 100644 --- a/frontend/src/components/FoodSearchModal.jsx +++ b/frontend/src/components/FoodSearchModal.jsx @@ -1,6 +1,9 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' +import { useAuth } from '../context/AuthContext' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' +import FoodEditDialog from './FoodEditDialog' +import { foodVisibilitySuffix } from '../utils/foodVisibility' const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse']) const UNIT_LABEL = { @@ -26,6 +29,7 @@ function detectUnit(...texts) { } export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) { + const { isAdmin } = useAuth() const [q, setQ] = useState(initialQuery || '') const [hits, setHits] = useState([]) const [loading, setLoading] = useState(false) @@ -33,6 +37,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all const [creating, setCreating] = useState(false) const [showCreate, setShowCreate] = useState(false) const [createKind, setCreateKind] = useState(null) + const [editId, setEditId] = useState(null) const [manual, setManual] = useState({ name_de: initialQuery || '', kcal: '', protein_g: '', fat_g: '', carbs_g: '', @@ -127,6 +132,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit return ( + <>
= 2 && hits.length === 0 && (

Kein Treffer im Katalog.

)} - {hits.map((h) => ( - - ))} + {hits.map((h) => { + const manual = h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin') + return ( +
+ + {manual && ( + + )} +
+ ) + })}
{!showCreate ? ( @@ -262,5 +277,14 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
+ {editId && ( + setEditId(null)} + onSaved={() => runSearch(q)} + /> + )} + ) } diff --git a/frontend/src/components/MyFoodsPanel.jsx b/frontend/src/components/MyFoodsPanel.jsx new file mode 100644 index 0000000..aacc8dc --- /dev/null +++ b/frontend/src/components/MyFoodsPanel.jsx @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useState } from 'react' +import { api } from '../utils/api' +import { useAuth } from '../context/AuthContext' +import FoodEditDialog from './FoodEditDialog' +import { foodVisibilitySuffix } from '../utils/foodVisibility' + +function filterByName(list, term) { + if (!term) return list + return list.filter((f) => (f.name_de || '').toLowerCase().includes(term)) +} + +export default function MyFoodsPanel() { + const { isAdmin } = useAuth() + const [foods, setFoods] = useState([]) + const [shared, setShared] = useState([]) + const [q, setQ] = useState('') + const [visible, setVisible] = useState(60) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [editId, setEditId] = useState(null) + const [editMode, setEditMode] = useState('user') + const [creating, setCreating] = useState(false) + + const load = async () => { + try { + const [mine, central] = await Promise.all([ + api.listMyFoods(), + api.listSharedFoods().catch(() => []), + ]) + setFoods(Array.isArray(mine) ? mine : []) + setShared(Array.isArray(central) ? central : []) + setError(null) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { load() }, []) + + const term = q.trim().toLowerCase() + const filteredMine = useMemo(() => filterByName(foods, term), [foods, term]) + const filteredShared = useMemo(() => filterByName(shared, term), [shared, term]) + const shownMine = filteredMine.slice(0, visible) + + const afterSave = (res) => { + load() + const n = res?.rebuild_pending + if (n) setNotice(`Gespeichert. Tageswerte für ${n} Tag${n === 1 ? '' : 'e'} werden nachgerechnet.`) + else setNotice('Lebensmittel gespeichert.') + } + + return ( +
+
Lebensmittel
+

+ Persönlich: nur für dich, hier änderbar. Zentral: für alle Nutzer + {isAdmin ? ' — als Admin hier ebenfalls änderbar' : ' (nur Admin ändert)'}. + BLS-Katalog suchst du beim Zuordnen. Ein Öl/Supplement bleibt ein Lebensmittel, keine Liste. +

+ {error &&
{error}
} + {notice &&
{notice}
} + +
+ +
+ + { setQ(e.target.value); setVisible(60) }} + /> + +

Persönlich ({loading ? '…' : filteredMine.length})

+ {!loading && foods.length === 0 && ( +

Noch keine eigenen Einträge. Hier anlegen oder beim Zuordnen über „Neu anlegen“.

+ )} + {!loading && foods.length > 0 && filteredMine.length === 0 && ( +

Kein persönlicher Treffer für „{q.trim()}“.

+ )} + {shownMine.map((f) => ( +
+ + +
+ ))} + {visible < filteredMine.length && ( + + )} + +

Zentral für alle ({loading ? '…' : filteredShared.length})

+ {!loading && shared.length === 0 && ( +

Noch keine zentralen Einträge vom Admin.

+ )} + {!loading && shared.length > 0 && filteredShared.length === 0 && ( +

Kein zentraler Treffer für „{q.trim()}“.

+ )} + {filteredShared.map((f) => ( +
+ {isAdmin ? ( + + ) : ( +
+
{f.name_de}
+
für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}
+
+ )} + {isAdmin && ( + + )} +
+ ))} + + {(creating || editId) && ( + { setCreating(false); setEditId(null) }} + onSaved={(res) => { + setCreating(false) + setEditId(null) + afterSave(res) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/components/NutritionFoodMap.jsx b/frontend/src/components/NutritionFoodMap.jsx index c4465d1..ca623f2 100644 --- a/frontend/src/components/NutritionFoodMap.jsx +++ b/frontend/src/components/NutritionFoodMap.jsx @@ -1,8 +1,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { api } from '../utils/api' +import { useAuth } from '../context/AuthContext' import FoodSearchModal from './FoodSearchModal' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodRecipeDialog from './FoodRecipeDialog' +import FoodEditDialog from './FoodEditDialog' +import { foodVisibilitySuffix } from '../utils/foodVisibility' const PERIODS = [ { days: 14, label: '14 Tage' }, @@ -127,7 +130,8 @@ function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWant ) } -export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList }) { +export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList, onOpenMyFoods }) { + const { isAdmin } = useAuth() const [unmapped, setUnmapped] = useState([]) const [learned, setLearned] = useState([]) const [recipes, setRecipes] = useState([]) @@ -144,6 +148,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o const [q, setQ] = useState('') const [visible, setVisible] = useState(80) const [showLearned, setShowLearned] = useState(false) + const [editFoodId, setEditFoodId] = useState(null) const [importing, setImporting] = useState(false) const [busy, setBusy] = useState(false) const listRef = useRef(null) @@ -371,6 +376,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} />
+ @@ -448,7 +454,9 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
→ {best.name_de} - {best.bls_code ? ` · ${best.bls_code}` : ' · manuell'} + + {best.bls_code ? ` · ${best.bls_code}` : foodVisibilitySuffix(best.catalog_kind, best.visibility)} +
))} @@ -510,15 +518,20 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o {showLearned && learned.map((m) => ( -
+
{m.source_name_raw}
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''} - {m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''} + {foodVisibilitySuffix(m.catalog_kind, m.visibility)}
- +
+ {m.food_id && (m.catalog_kind === 'manual_user' || (isAdmin && m.catalog_kind === 'manual_admin')) && ( + + )} + +
))} @@ -558,6 +571,19 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o {recipeFor && ( setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} /> )} + {editFoodId && ( + setEditFoodId(null)} + onSaved={(res) => { + load() + onChanged?.() + const n = res?.rebuild_pending + if (n) setNotice(`Lebensmittel gespeichert. Tageswerte für ${n} Tag${n === 1 ? '' : 'e'} werden nachgerechnet.`) + else setNotice('Lebensmittel gespeichert.') + }} + /> + )}
) } diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index 3945ea0..d22533f 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -128,7 +128,7 @@ export const ADMIN_GROUPS = [ { to: '/admin/bls-foods', label: 'Lebensmittelkatalog', - description: 'Suche inkl. BLS-Code, manuelle Einträge.', + description: 'Suche (BLS) und Reiter Manuell für selbst angelegte Einträge.', }, { to: '/admin/food-attributes', diff --git a/frontend/src/pages/AdminBlsFoodsPage.jsx b/frontend/src/pages/AdminBlsFoodsPage.jsx index c2937e9..b173fce 100644 --- a/frontend/src/pages/AdminBlsFoodsPage.jsx +++ b/frontend/src/pages/AdminBlsFoodsPage.jsx @@ -1,43 +1,180 @@ -import { useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { api } from '../utils/api' +import FoodEditDialog from '../components/FoodEditDialog' + +function FoodRow({ row, onEdit }) { + return ( +
+ + {row.catalog_kind && row.catalog_kind !== 'official_bls' && ( + + )} +
+ ) +} export default function AdminBlsFoodsPage() { + const [tab, setTab] = useState('search') const [q, setQ] = useState('') + const [manualQ, setManualQ] = useState('') const [rows, setRows] = useState([]) + const [manualRows, setManualRows] = useState([]) const [detail, setDetail] = useState(null) const [error, setError] = useState(null) + const [loadingManual, setLoadingManual] = useState(false) + const [creating, setCreating] = useState(false) + const [editId, setEditId] = useState(null) + const timer = useRef(null) - const search = async () => { + const search = async (term) => { try { - setRows(await api.adminBlsFoods(q)) + setError(null) + setRows(await api.adminBlsFoods(term, '', 50)) } catch (e) { setError(e.message) } } + const loadManual = async (term) => { + setLoadingManual(true) + try { + setError(null) + setManualRows(await api.adminBlsFoods(term, 'manual', 200)) + } catch (e) { + setError(e.message) + } finally { + setLoadingManual(false) + } + } + + useEffect(() => { + if (tab === 'manual') loadManual(manualQ) + }, [tab]) + + useEffect(() => () => clearTimeout(timer.current), []) + + const onManualQuery = (value) => { + setManualQ(value) + clearTimeout(timer.current) + timer.current = setTimeout(() => loadManual(value), 300) + } + + const shownManual = useMemo(() => manualRows, [manualRows]) + const centralRows = useMemo( + () => shownManual.filter((r) => r.catalog_kind === 'manual_admin'), + [shownManual], + ) + const personalRows = useMemo( + () => shownManual.filter((r) => r.catalog_kind === 'manual_user'), + [shownManual], + ) + + const openDetail = (id) => { + api.adminBlsFoodDetail(id).then(setDetail).catch((e) => setError(e.message)) + } + + const openEdit = (row) => { + if (row.catalog_kind === 'official_bls') { + openDetail(row.id) + return + } + setDetail(null) + setEditId(row.id) + } + + const list = tab === 'manual' ? shownManual : rows + return (

Lebensmittelkatalog

{error &&

{error}

} -
- setQ(e.target.value)} placeholder="Name oder BLS-Code" /> - -
- {rows.map((r) => ( - + +
+ + {tab === 'search' && ( + <> +

+ BLS und manuelle Einträge nach Name oder Code. Die komplette BLS-Liste ist zu groß zum Durchblättern. +

+
+ setQ(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') search(q) }} + placeholder="Name oder BLS-Code" + /> + +
+ + )} + + {tab === 'manual' && ( + <> +

+ Zwei Listen: zentral für alle Nutzer, darunter persönliche Einträge (sichtbar, weil du Admin bist). Beide sind hier änderbar. +

+
+ +
+ onManualQuery(e.target.value)} + placeholder="In manuellen Einträgen suchen…" + /> +

+ {loadingManual + ? 'Lade…' + : `${centralRows.length} zentral, ${personalRows.length} persönlich`} + {!loadingManual && shownManual.length >= 200 ? ' (Anzeige begrenzt auf 200)' : ''} +

+ + )} + + {tab === 'search' && rows.length === 0 && ( +

Suchbegriff eingeben und Suchen.

+ )} + {tab === 'manual' && !loadingManual && shownManual.length === 0 && ( +

Keine manuellen Lebensmittel{manualQ.trim() ? ` zu „${manualQ.trim()}“` : ''}.

+ )} + + {tab === 'search' && list.map((r) => ( + ))} + {tab === 'manual' && !loadingManual && shownManual.length > 0 && ( + <> +

Zentral für alle ({centralRows.length})

+ {centralRows.length === 0 &&

Noch keine zentralen Einträge.

} + {centralRows.map((r) => ( + + ))} +

Persönlich ({personalRows.length})

+ {personalRows.length === 0 &&

Keine persönlichen Einträge in dieser Auswahl.

} + {personalRows.map((r) => ( + + ))} + + )} {detail && (
-

{detail.bls_code} {detail.name_de}

+

{detail.bls_code ? `${detail.bls_code} · ` : ''}{detail.name_de}

{detail.catalog_kind} · {detail.bls_version || '—'}

@@ -52,6 +189,18 @@ export default function AdminBlsFoodsPage() {
)} + {(creating || editId) && ( + { setCreating(false); setEditId(null) }} + onSaved={() => { + setCreating(false) + setEditId(null) + if (tab === 'manual') loadManual(manualQ) + }} + /> + )}
) } diff --git a/frontend/src/pages/AdminFoodAttributesPage.jsx b/frontend/src/pages/AdminFoodAttributesPage.jsx index e57343e..aff4795 100644 --- a/frontend/src/pages/AdminFoodAttributesPage.jsx +++ b/frontend/src/pages/AdminFoodAttributesPage.jsx @@ -1,22 +1,138 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { api } from '../utils/api' +const GROUP_OPTIONS = [ + { id: 'energy', label: 'Energie' }, + { id: 'macro', label: 'Makronährstoffe' }, + { id: 'carbs', label: 'Kohlenhydrate' }, + { id: 'fiber', label: 'Ballaststoffe' }, + { id: 'fatty_acids', label: 'Fettsäuren' }, + { id: 'amino_acids', label: 'Aminosäuren' }, + { id: 'vitamins', label: 'Vitamine' }, + { id: 'minerals', label: 'Mineralstoffe' }, + { id: 'organic_acids', label: 'Organische Säuren' }, + { id: 'polyols', label: 'Zuckeralkohole' }, + { id: 'other', label: 'Sonstige' }, + { id: 'extension', label: 'Eigene Erweiterungen' }, + { id: 'unknown', label: 'Ohne Gruppe' }, +] + +const DEFAULT_OPEN = new Set(['energy', 'macro']) + +function matches(row, term, filter) { + if (filter === 'needs' && !row.needs_label) return false + if (filter !== 'all' && filter !== 'needs' && row.group_key !== filter) return false + if (!term) return true + const hay = `${row.attr_key} ${row.name_de || ''} ${row.name_en || ''} ${row.unit || ''} ${row.group_label || ''}`.toLowerCase() + return hay.includes(term) +} + +function groupRows(rows) { + const byKey = new Map() + for (const row of rows) { + const key = row.group_key || 'unknown' + if (!byKey.has(key)) { + byKey.set(key, { + key, + label: row.group_label || 'Ohne Gruppe', + rank: row.group_rank ?? 99, + items: [], + }) + } + byKey.get(key).items.push(row) + } + return [...byKey.values()].sort((a, b) => a.rank - b.rank) +} + export default function AdminFoodAttributesPage() { const [rows, setRows] = useState([]) const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' }) + const [q, setQ] = useState('') + const [filter, setFilter] = useState('all') + const [open, setOpen] = useState(() => new Set(DEFAULT_OPEN)) + const [editId, setEditId] = useState(null) + const [draft, setDraft] = useState({ name_de: '', unit: '', category: '' }) const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [busy, setBusy] = useState(false) const load = () => api.adminBlsAttributes().then(setRows).catch((e) => setError(e.message)) useEffect(() => { load() }, []) + const term = q.trim().toLowerCase() + const shown = useMemo(() => rows.filter((r) => matches(r, term, filter)), [rows, term, filter]) + const groups = useMemo(() => groupRows(shown), [shown]) + const missing = rows.filter((r) => r.needs_label).length + const groupCounts = useMemo(() => { + const counts = {} + for (const r of rows) { + const key = r.group_key || 'unknown' + counts[key] = (counts[key] || 0) + 1 + } + return counts + }, [rows]) + + useEffect(() => { + if (term || filter !== 'all') { + setOpen(new Set(groups.map((g) => g.key))) + } + }, [term, filter, groups]) + + const toggle = (key) => { + setOpen((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const startEdit = (r) => { + setEditId(r.id) + setDraft({ name_de: r.name_de || '', unit: r.unit || '', category: r.group_key || r.category || '' }) + setError(null) + setOpen((prev) => new Set(prev).add(r.group_key || 'unknown')) + } + + const saveEdit = async (id) => { + setBusy(true) + try { + const updated = await api.adminUpdateBlsAttribute(id, draft) + setRows((list) => list.map((r) => (r.id === id ? { ...r, ...updated } : r))) + setEditId(null) + setNotice('Bezeichnung gespeichert. Der Schlüssel bleibt unverändert.') + } catch (e) { + setError(e.message) + } finally { + setBusy(false) + } + } + + const fillLabels = async () => { + setBusy(true) + setError(null) + try { + const res = await api.adminFillBlsAttributeLabels() + await load() + setNotice(`${res.updated ?? 0} Bezeichnungen/Gruppen aus dem BLS-4.0-Glossar ergänzt.`) + } catch (e) { + setError(e.message) + } finally { + setBusy(false) + } + } + return (

Stoffe & Attribute

-

- Offizielle BLS-Codes kommen aus dem Import. Erweiterungen (Histamin, Gluten, …) hier anlegen — ohne Migration. +

+ Gruppiert nach BLS-Nährstoffgruppen. Innerhalb der Gruppe: zuerst die vier Makros für die Haupttabelle, dann Name. + Schlüssel bleibt fest, Bezeichnung und Gruppe sind änderbar.

{error &&

{error}

} -
+ {notice &&

{notice}

} + +
setForm({ ...form, attr_key: e.target.value })} /> setForm({ ...form, name_de: e.target.value })} /> setForm({ ...form, unit: e.target.value })} /> @@ -26,19 +142,115 @@ export default function AdminFoodAttributesPage() { - +
- {rows.map((r) => ( -
- {r.attr_key} · {r.name_de} · {r.data_type} · {r.origin} -
- ))} + +
+ + {GROUP_OPTIONS.filter((g) => groupCounts[g.id]).map((g) => ( + + ))} + + + + +
+ setQ(e.target.value)} + /> +

{shown.length} von {rows.length} Stoffen · {groups.length} Gruppen

+ + {groups.map((g) => { + const opened = open.has(g.key) + return ( +
+ + {opened && g.items.map((r) => ( +
+ {editId === r.id ? ( +
+
+ Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''} +
+
+ setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" /> + setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" /> + + + +
+
+ ) : ( +
+
+
+ {r.needs_label ? r.attr_key : (r.name_de || r.attr_key)} + {r.is_macro ? Makro : null} +
+
+ {r.attr_key} + {r.needs_label ? ' · Bezeichnung fehlt' : ''} + {r.unit ? ` · ${r.unit}` : ''} +
+
+ +
+ )} +
+ ))} +
+ ) + })}
) } diff --git a/frontend/src/pages/AdminFoodMappingsPage.jsx b/frontend/src/pages/AdminFoodMappingsPage.jsx index 9b7d45f..5598ac1 100644 --- a/frontend/src/pages/AdminFoodMappingsPage.jsx +++ b/frontend/src/pages/AdminFoodMappingsPage.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' +import { foodVisibilityLabel } from '../utils/foodVisibility' export default function AdminFoodMappingsPage() { const [rows, setRows] = useState([]) @@ -30,7 +31,7 @@ export default function AdminFoodMappingsPage() { setter([]) return } - api.searchBlsFoods(q, 20).then(setter).catch(() => setter([])) + api.searchBlsFoods(q, 20, undefined, 'global').then(setter).catch(() => setter([])) } const shown = rows.filter((r) => { @@ -59,6 +60,10 @@ export default function AdminFoodMappingsPage() {

FDDB→BLS Mappings

{error &&

{error}

} +

+ Globale Zuordnungen gelten für alle Nutzer und dürfen nur auf BLS oder zentrale Lebensmittel zeigen. + Persönliche Nutzer-Mappings bleiben beim jeweiligen Profil. +

{coverage && (

{coverage.mapped_items}/{coverage.total_items} Zeilen gemappt · @@ -77,7 +82,7 @@ export default function AdminFoodMappingsPage() {

{hits.map((h) => ( ))} ))}
diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index e26d179..6d1b3c8 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -3,6 +3,7 @@ import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react' import UsageBadge from '../components/UsageBadge' import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap' import FoodRecipeEditor from '../components/FoodRecipeEditor' +import MyFoodsPanel from '../components/MyFoodsPanel' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter @@ -957,7 +958,7 @@ export default function NutritionPage() { )} {/* Input Method Tabs */} -
+
@@ -967,6 +968,9 @@ export default function NutritionPage() { + @@ -992,9 +996,12 @@ export default function NutritionPage() { onChanged={refreshUnmappedCount} onManageLists={(id) => { setFocusListId(id || null); setInputTab('lists') }} onCreatedList={(rec) => { setFocusListId(rec?.id || null); setInputTab('lists') }} + onOpenMyFoods={() => setInputTab('foods')} /> )} + {inputTab==='foods' && } + {inputTab==='lists' && ( )} - {loading && inputTab !== 'map' && inputTab !== 'lists' &&
} + {loading && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' &&
} - {!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && ( + {!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && (

Noch keine Ernährungsdaten

Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.

@@ -1012,7 +1019,7 @@ export default function NutritionPage() { )} {/* Analysis Section */} - {!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && ( + {!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && ( <> diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index c1b296e..9f722e9 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -285,10 +285,15 @@ export const api = { deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})), listFoodUnits: () => req('/bls/units'), - searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}), + searchBlsFoods: (q, limit=20, signal, scope) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}${scope?`&scope=${encodeURIComponent(scope)}`:''}`, signal ? { signal } : {}), suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })), createUserFood: (d) => req('/bls/foods/manual', json(d)), - listFoodAttributes: (q='', limit=40) => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}`), + listMyFoods: () => req('/bls/foods/mine'), + listSharedFoods: () => req('/bls/foods/shared'), + getBlsFood: (id) => req(`/bls/foods/${encodeURIComponent(id)}`), + updateUserFood: (id, d) => req(`/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)), + listFoodAttributes: (q='', limit=40, keys='') => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}${keys ? `&keys=${encodeURIComponent(keys)}` : ''}`), + createFoodAttribute: (d) => req('/bls/attributes', json(d)), listMyFoodMappings: () => req('/bls/mappings'), upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)), deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}), @@ -300,11 +305,14 @@ export const api = { }, adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`), adminBlsImportApply: (id) => req(`/admin/bls/import/jobs/${id}/apply`, {method:'POST'}), - adminBlsFoods: (q, kind) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+kind):''}`), - adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`), + adminBlsFoods: (q, kind, limit) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+encodeURIComponent(kind)+'&'):''}${limit?('limit='+limit):''}`), + adminBlsFoodDetail: (id, editor=false) => req(`/admin/bls/foods/${encodeURIComponent(id)}${editor?'?editor=true':''}`), adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)), + adminUpdateManualFood: (id, d) => req(`/admin/bls/foods/manual/${encodeURIComponent(id)}`, jput(d)), adminBlsAttributes: () => req('/admin/bls/attributes'), adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)), + adminUpdateBlsAttribute: (id, d) => req(`/admin/bls/attributes/${id}`, jput(d)), + adminFillBlsAttributeLabels: () => req('/admin/bls/attributes/fill-labels', { method: 'POST' }), adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`), adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)), adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)), diff --git a/frontend/src/utils/foodVisibility.js b/frontend/src/utils/foodVisibility.js new file mode 100644 index 0000000..b56f2de --- /dev/null +++ b/frontend/src/utils/foodVisibility.js @@ -0,0 +1,23 @@ +const LABELS = { + personal: 'persönlich', + global: 'zentral', + bls: 'BLS', +} + +export function foodVisibility(kind, visibility) { + if (visibility && LABELS[visibility]) return visibility + if (kind === 'official_bls') return 'bls' + if (kind === 'manual_admin') return 'global' + if (kind === 'manual_user') return 'personal' + return '' +} + +export function foodVisibilityLabel(kind, visibility) { + const key = foodVisibility(kind, visibility) + return LABELS[key] || '' +} + +export function foodVisibilitySuffix(kind, visibility) { + const label = foodVisibilityLabel(kind, visibility) + return label ? ` · ${label}` : '' +}