Merge pull request 'BLS Stoffgruppen, Lebensmittel verwalten, etc' (#108) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m10s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s

Reviewed-on: #108
This commit is contained in:
Lars 2026-09-14 13:15:10 +02:00
commit c1f45d3ba1
37 changed files with 2288 additions and 176 deletions

View File

@ -1,6 +1,6 @@
# BLS-Lebensmittelreferenz und FDDB-Mapping # BLS-Lebensmittelreferenz und FDDB-Mapping
**Stand:** 2026-09-12 · **Status:** Phase 1 (Umsetzung) **Stand:** 2026-09-14 · **Status:** Phase 1 (Umsetzung)
## WAS ## 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-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. - 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. - 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. - Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import. - Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten. - Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
## Manuelle Lebensmittel / Supplemente ## 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. Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.

View File

@ -1,6 +1,6 @@
# BLS Food Reference – technische Spec # BLS Food Reference – technische Spec
**Stand:** 2026-09-12 · Migration **062**–**068** **Stand:** 2026-09-14 · Migration **062**–**069**
## Tabellen ## Tabellen
@ -28,15 +28,18 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
## Router ## 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 - 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/admin/food-mappings` — Admin-CRUD
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve - `/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` - `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 - `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping - 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. - 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). - 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)`. - `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)`.

View File

@ -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-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“. - **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. - **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}`. - **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 - **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. - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

View File

@ -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

View File

@ -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]: 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 inserted = updated = 0
for a in attributes: for a in attributes:
cur.execute( 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)} 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") cur.execute("SELECT attr_key, id FROM food_attributes")
attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()} 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")] codes = [f["bls_code"] for f in foods if f.get("bls_code")]

View File

@ -146,10 +146,15 @@ def _run_apply(job_id: str) -> None:
total = len(foods) total = len(foods)
inserted = updated = values_written = 0 inserted = updated = values_written = 0
_update(job_id, progress={"current": 0, "total": total}) _update(job_id, progress={"current": 0, "total": total})
headers = parsed.get("attribute_headers")
for i in range(0, total, FOOD_CHUNK): for i in range(0, total, FOOD_CHUNK):
chunk = foods[i : i + FOOD_CHUNK] chunk = foods[i : i + FOOD_CHUNK]
with get_db() as conn: 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) inserted += stats.get("inserted", 0)
updated += stats.get("updated", 0) updated += stats.get("updated", 0)
values_written += stats.get("values_written", 0) values_written += stats.get("values_written", 0)

View File

@ -26,25 +26,34 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
header = [_cell(c) for c in next(rows)] header = [_cell(c) for c in next(rows)]
idx = {h.lower(): i for i, h in enumerate(header) if h} 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: for n in names:
if n.lower() in idx: if n.lower() in idx:
return idx[n.lower()] return idx[n.lower()]
for key, i in idx.items(): for key, i in idx.items():
if any(ex in key for ex in exclude):
continue
for n in names: for n in names:
if n.lower() in key: if n.lower() in key:
return i return i
return None return None
i_code = col("code", "schlüssel", "schluessel", "attr_key", "komponente") i_code = col(
i_de = col("name_de", "deutsch", "bezeichnung_de", "name de") "component code", "komponentencode", "attr_key", "schlüssel", "schluessel",
i_en = col("name_en", "english", "bezeichnung_en", "name en") "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_unit = col("unit", "einheit")
i_cat = col("category", "kategorie", "gruppe") i_cat = col("category", "kategorie", "gruppe")
if i_code is None: if i_code is None:
i_code = 0 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 = [] out = []
sort_order = 0 sort_order = 0
@ -52,9 +61,10 @@ def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
if not raw: if not raw:
continue continue
code = _cell(raw[i_code] if i_code < len(raw) else "") 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 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 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 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 category = _cell(raw[i_cat] if i_cat is not None and i_cat < len(raw) else "") or None

View File

@ -1,8 +1,12 @@
"""Catalog attribute definitions and numeric values (BLS EAV + extensions).""" """Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
from __future__ import annotations from __future__ import annotations
import re
from typing import Any 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 = { MACRO_TO_KEY = {
"kcal": "ENERCC", "kcal": "ENERCC",
"protein_g": "PROT625", "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)) return float(value) * (100.0 / float(serving_g))
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]: def list_numeric_attributes(
q = (query or "").strip() cur, query: str = "", limit: int = 40, keys: list[str] | None = None
) -> list[dict[str, Any]]:
lim = min(max(int(limit or 40), 1), 80) 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: if q:
like = f"%{q}%" like = f"%{q}%"
cur.execute( cur.execute(
@ -89,6 +108,57 @@ def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -
return written 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( def macros_and_attributes_to_values(
macros: dict[str, Any] | None, macros: dict[str, Any] | None,
attributes: 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())), (food_id, list(MACRO_TO_KEY.values())),
) )
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()} 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())

View File

@ -408,14 +408,16 @@ def get_food_mapping_with_cursor(
""" """
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit, 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 FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id JOIN food_catalog f ON f.id = m.food_id
WHERE m.source_system = %s AND m.source_name_normalized = %s WHERE m.source_system = %s AND m.source_name_normalized = %s
AND m.profile_id = %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 LIMIT 1
""", """,
(source_system, norm, profile_id), (source_system, norm, profile_id, profile_id),
) )
row = cur.fetchone() row = cur.fetchone()
if row: 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, SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit, 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 FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id JOIN food_catalog f ON f.id = m.food_id
WHERE m.source_system = %s AND m.source_name_normalized = %s WHERE m.source_system = %s AND m.source_name_normalized = %s
AND m.profile_id IS NULL AND m.profile_id IS NULL
AND f.is_active = true
AND f.owner_profile_id IS NULL
LIMIT 1 LIMIT 1
""", """,
(source_system, norm), (source_system, norm),
@ -448,6 +452,9 @@ def upsert_food_mapping(
grams_per_unit: float | None = None, grams_per_unit: float | None = None,
source_unit: str | None = None, source_unit: str | None = None,
) -> int: ) -> 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) norm = normalize_food_name(source_name_raw)
if not norm: if not norm:
raise ValueError("Leerer Lebensmittelname") 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) + ")" extra_sql = " OR (" + " AND ".join(parts) + ")"
cur.execute( cur.execute(
f""" 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 FROM food_catalog
WHERE is_active = true WHERE is_active = true
AND ( 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 name_de ILIKE %s THEN 1
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2 WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
ELSE 3 END, ELSE 3 END,
CASE catalog_kind
WHEN 'manual_user' THEN 0
WHEN 'manual_admin' THEN 1
ELSE 2 END,
name_de name_de
LIMIT %s LIMIT %s
""", """,

View File

@ -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]: def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
from data_layer.food_visibility import visibility_of
return { return {
"id": str(food["id"]), "id": str(food["id"]),
"bls_code": food.get("bls_code"), "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"), "name_en": food.get("name_en"),
"catalog_kind": food.get("catalog_kind"), "catalog_kind": food.get("catalog_kind"),
"food_group": food.get("food_group"), "food_group": food.get("food_group"),
"visibility": visibility_of(food),
"score": score, "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]: def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
cur.execute( 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 FROM food_catalog
WHERE is_active = true WHERE is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s) 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 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]: def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
q = (query or "").strip() q = (query or "").strip()
scored: list[tuple[int, int, dict]] = [] 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: if score < MIN_SCORE:
continue continue
scored.append((score, len(food.get("name_de") or ""), food)) 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)]] top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
ambiguous = False ambiguous = False
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60: 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): if score < MIN_SCORE and not number_tokens(q):
continue continue
hits.append(_public(row, score or 40)) 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] return hits[:limit]

View File

@ -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

View File

@ -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), (profile_id, source_name_normalized, profile_id, source_name_normalized),
) )
return [r["date"] for r in cur.fetchall()] 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")]

View File

@ -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)
);

View File

@ -28,10 +28,20 @@ class AttributeCreate(BaseModel):
enum_values: Optional[list] = None 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): class ManualFoodCreate(BaseModel):
name_de: str name_de: str
name_en: Optional[str] = None name_en: Optional[str] = None
macros_per_100g: Optional[dict] = None macros_per_100g: Optional[dict] = None
attributes: Optional[dict] = None
serving_g: Optional[float] = None
@router.get("/status") @router.get("/status")
@ -162,7 +172,9 @@ def admin_list_foods(
cur = get_cursor(conn) cur = get_cursor(conn)
conds = ["is_active = true"] conds = ["is_active = true"]
params: list = [] params: list = []
if kind: if kind == "manual":
conds.append("catalog_kind IN ('manual_user', 'manual_admin')")
elif kind:
conds.append("catalog_kind = %s") conds.append("catalog_kind = %s")
params.append(kind) params.append(kind)
if q: if q:
@ -172,7 +184,7 @@ def admin_list_foods(
params.append(limit) params.append(limit)
cur.execute( cur.execute(
f""" 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 FROM food_catalog
WHERE {' AND '.join(conds)} WHERE {' AND '.join(conds)}
ORDER BY name_de ORDER BY name_de
@ -184,7 +196,7 @@ def admin_list_foods(
@router.get("/foods/{food_id}") @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 from data_layer.nutrition_items import resolve_food_attributes
with get_db() as conn: with get_db() as conn:
@ -193,17 +205,28 @@ def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Lebensmittel nicht gefunden") 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) attrs = resolve_food_attributes(cur, food_id)
return {**r2d(row), "attributes": attrs} return {**r2d(row), "attributes": attrs}
@router.post("/foods/manual") @router.post("/foods/manual")
def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(require_admin)): 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_mapping import normalize_food_name
from data_layer.food_visibility import attach_visibility
name = (body.name_de or "").strip() name = (body.name_de or "").strip()
if not name: if not name:
raise HTTPException(400, "Name fehlt") 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( 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]}"), (name, body.name_en, f"man-admin-{normalize_food_name(name)[:40]}"),
) )
food = r2d(cur.fetchone()) 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 from data_layer.food_suggest import invalidate_suggest_index
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") @router.get("/attributes")
def admin_list_attributes(session: dict = Depends(require_admin)): def admin_list_attributes(session: dict = Depends(require_admin)):
from bls.attribute_labels import annotate_attribute, sort_attributes
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( 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 SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
FROM food_attributes FROM food_attributes
WHERE is_active = true 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") @router.post("/attributes")
@ -263,6 +350,50 @@ def admin_create_attribute(body: AttributeCreate, session: dict = Depends(requir
return r2d(cur.fetchone()) 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: 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 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)) write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None))

View File

@ -52,7 +52,8 @@ def list_food_mappings(
q += " WHERE " + " AND ".join(conds) q += " WHERE " + " AND ".join(conds)
q += " ORDER BY m.source_name_normalized" q += " ORDER BY m.source_name_normalized"
cur.execute(q, params) 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") @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)): def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require_admin)):
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT id FROM food_catalog WHERE id = %s", (body.food_id,)) from data_layer.food_visibility import require_mapping_target
if not cur.fetchone(): try:
raise HTTPException(404, "Lebensmittel nicht gefunden") 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( mid = upsert_food_mapping(
cur, cur,
source_name_raw=body.source_name, source_name_raw=body.source_name,
@ -114,9 +119,17 @@ def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Mapping nicht gefunden") raise HTTPException(404, "Mapping nicht gefunden")
cur.execute("SELECT id FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,)) cur.execute("SELECT id, catalog_kind, owner_profile_id, is_active FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,))
if not cur.fetchone(): food = cur.fetchone()
if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden") 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( cur.execute(
""" """
UPDATE food_name_mappings UPDATE food_name_mappings

View File

@ -52,6 +52,12 @@ class SuggestBatchBody(BaseModel):
limit: int = 3 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: def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
return x_profile_id or session["profile_id"] return x_profile_id or session["profile_id"]
@ -89,23 +95,41 @@ def list_food_units(session: dict = Depends(require_auth)):
def search_foods( def search_foods(
q: str = "", q: str = "",
limit: int = 20, limit: int = 20,
scope: str = "visible",
session: dict = Depends(require_auth), session: dict = Depends(require_auth),
): ):
pid = session["profile_id"] pid = session["profile_id"]
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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") @router.get("/attributes")
def list_food_attributes( def list_food_attributes(
q: str = "", q: str = "",
keys: str = "",
limit: int = 40, limit: int = 40,
session: dict = Depends(require_auth), session: dict = Depends(require_auth),
): ):
from data_layer.food_attributes import list_numeric_attributes 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: 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") @router.post("/foods/suggest-batch")
@ -152,6 +176,122 @@ def create_user_food(
return 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") @router.get("/mappings")
def list_my_mappings( def list_my_mappings(
x_profile_id: Optional[str] = Header(default=None), x_profile_id: Optional[str] = Header(default=None),
@ -172,7 +312,8 @@ def list_my_mappings(
""", """,
(pid,), (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") @router.post("/mappings")
@ -184,27 +325,26 @@ def upsert_my_mapping(
pid = _pid(session, x_profile_id) pid = _pid(session, x_profile_id)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( from data_layer.food_visibility import require_visible_food, visibility_of
""" try:
SELECT id, name_de, bls_code, catalog_kind FROM food_catalog food = require_visible_food(cur, body.food_id, pid)
WHERE id = %s AND is_active = true except LookupError as e:
AND (owner_profile_id IS NULL OR owner_profile_id = %s) raise HTTPException(404, str(e)) from e
""", try:
(body.food_id, pid), mid = upsert_food_mapping(
) cur,
food = cur.fetchone() source_name_raw=body.source_name,
if not food: food_id=body.food_id,
raise HTTPException(404, "Lebensmittel nicht gefunden") profile_id=pid,
mid = upsert_food_mapping( source="bulk",
cur, source_system=body.source_system,
source_name_raw=body.source_name, grams_per_unit=body.grams_per_unit,
food_id=body.food_id, source_unit=body.source_unit,
profile_id=pid, )
source="bulk", except PermissionError as e:
source_system=body.source_system, raise HTTPException(400, str(e)) from e
grams_per_unit=body.grams_per_unit, except LookupError as e:
source_unit=body.source_unit, raise HTTPException(404, str(e)) from e
)
norm = normalize_food_name(body.source_name) norm = normalize_food_name(body.source_name)
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid) n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit) 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"], "food_name_de": food["name_de"],
"bls_code": food.get("bls_code"), "bls_code": food.get("bls_code"),
"catalog_kind": food.get("catalog_kind"), "catalog_kind": food.get("catalog_kind"),
"visibility": visibility_of(food),
} }

View File

@ -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"]

View File

@ -30,6 +30,18 @@ def test_parse_components_dynamic():
assert "NA" in keys 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(): def test_parse_foods_keeps_bls_code():
data = _xlsx([ data = _xlsx([
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"], ["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],

View File

@ -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(): 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 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(): def test_norsan_label_epa_converts():
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g.""" """Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
vals = macros_and_attributes_to_values( 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["FAT"] - 100) < 0.01
assert abs(vals["EPA"] - 13.75) < 0.01 assert abs(vals["EPA"] - 13.75) < 0.01
assert abs(vals["ENERCC"] - 900) < 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

View File

@ -105,3 +105,11 @@ def test_joghurt_10_ranks_above_35():
packed = suggest_for_name(index, query, limit=3) packed = suggest_for_name(index, query, limit=3)
assert packed["suggestions"][0]["name_de"] == full["name_de"], query assert packed["suggestions"][0]["name_de"] == full["name_de"], query
assert packed["suggestions"][0]["score"] > score_name_match(query, light["name_de"]) 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"

View File

@ -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"

View File

@ -8,8 +8,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
""" """
APP_VERSION = "0.9v" APP_VERSION = "0.9v"
BUILD_DATE = "2026-09-12" BUILD_DATE = "2026-09-14"
DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes DB_SCHEMA_VERSION = "20260914a" # 069 food catalog visibility owner check
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.0", "auth": "1.2.0",
@ -20,9 +20,10 @@ MODULE_VERSIONS = {
"circumference": "1.0.1", "circumference": "1.0.1",
"caliper": "1.0.1", "caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "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) "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", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
"prompts": "1.1.0", "prompts": "1.1.0",
@ -55,6 +56,9 @@ CHANGELOG = [
"Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen", "Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen",
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%", "Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g", "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", "Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern",
"Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später", "Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später",
"Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm", "Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm",

View File

@ -19,6 +19,7 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen - Zuordnungen und Listen als JSON zwischen Instanzen übertragen
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping - Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung - 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 - Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück) - Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück)
- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import) - Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import)

View File

@ -191,7 +191,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
/* Tabs */ /* Tabs */
.tabs { display: flex; gap: 4px; background: var(--surface2); border-radius: 10px; padding: 3px; margin-bottom: 16px; } .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); } .tab.active { background: var(--surface); color: var(--text1); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
/* Section */ /* Section */

View File

@ -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 (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zIndex || 23000,
padding: 16,
}}
>
<div
role="dialog"
aria-modal="true"
style={{
width: '100%', maxWidth: 520, maxHeight: 'min(92vh, 800px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>{title}</h2>
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Schließen</button>
</div>
<div style={{ overflowY: 'auto', padding: '12px 16px 16px', flex: 1 }}>
{loading && <p style={{ fontSize: 13 }}>Lade…</p>}
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{!loading && !editable && (
<p style={{ fontSize: 13, color: 'var(--text2)' }}>
{catalogKind === 'manual_admin'
? 'Zentrale Lebensmittel ändert nur ein Admin.'
: 'Offizielle BLS-Einträge sind fest. Nur selbst angelegte Lebensmittel lassen sich ändern.'}
</p>
)}
{!loading && editable && (
<>
<label className="form-label">Name</label>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 12 }}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FoodNutrientFields
macros={macros}
setMacros={setMacros}
extras={extras}
setExtras={setExtras}
servingG={servingG}
setServingG={setServingG}
valuesArePer100g={!isCreate}
/>
</>
)}
</div>
{editable && (
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onClose}>Abbrechen</button>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={saving || loading} onClick={save}>
{saving ? 'Speichere…' : (isCreate ? 'Anlegen' : 'Speichern')}
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -1,16 +1,35 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' 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) { function parseNum(raw) {
const n = parseFloat(String(raw ?? '').replace(',', '.')) const n = parseFloat(String(raw ?? '').replace(',', '.'))
return Number.isFinite(n) ? n : 0 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) { export function foodCreatePayload(name, macros, extras, servingG) {
const attributes = {} const attributes = {}
for (const row of extras || []) { for (const row of extras || []) {
if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue 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) const serving = parseNum(servingG)
return { 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 [q, setQ] = useState('')
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [quick, setQuick] = useState([])
const [loading, setLoading] = useState(false) 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) const timer = useRef(null)
useEffect(() => () => clearTimeout(timer.current), []) useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
api.listFoodAttributes('', 20, QUICK_KEYS.join(',')).then((rows) => {
setQuick(Array.isArray(rows) ? rows : [])
}).catch(() => {})
}, [])
const search = (term) => { const search = (term) => {
clearTimeout(timer.current) clearTimeout(timer.current)
@ -55,7 +84,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
const add = (attr) => { const add = (attr) => {
setExtras((list) => ( 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('') setQ('')
setHits([]) 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))) 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 ( return (
<div> <div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}> <p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
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.'}
</p> </p>
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}> <label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
Angabe gilt für{' '} Angabe gilt für{' '}
@ -81,7 +139,7 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
value={servingG} value={servingG}
onChange={(e) => setServingG(e.target.value)} onChange={(e) => setServingG(e.target.value)}
/> />
{' '}g (100 = pro 100 g) {' '}g (100 = pro 100 g; 5 ml Öl meist 5)
</label> </label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => ( {[['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
</label> </label>
))} ))}
</div> </div>
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe (EPA, DHA, Ballaststoffe, …)</p> <p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe</p>
{unusedQuick.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{unusedQuick.map((a) => (
<button key={a.attr_key} type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => add(a)}>
{a.attr_key}
</button>
))}
</div>
)}
<input <input
className="form-input" className="form-input"
style={{ width: '100%', textAlign: 'left' }} style={{ width: '100%', textAlign: 'left' }}
placeholder="Stoff suchen, z. B. EPA oder Omega" placeholder="Stoff suchen, z. B. EPA, Omega, Vitamin D"
value={q} value={q}
onChange={(e) => { setQ(e.target.value); search(e.target.value) }} onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
/> />
@ -122,21 +189,72 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
</span> </span>
</button> </button>
))} ))}
{extras.map((row) => ( <div style={{ marginTop: 10, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}> <p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 6px' }}>Stoff nicht gefunden? Neu anlegen (wird für alle Lebensmittel nutzbar).</p>
{row.attr.name_de}{row.attr.unit ? ` (${row.attr.unit})` : ''} <input
<input className="form-input"
className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 6 }}
type="number" placeholder="Name, z. B. Vitamin D3"
min="0" value={newName}
step="0.001" onChange={(e) => setNewName(e.target.value)}
style={{ width: '100%', textAlign: 'left', marginTop: 4 }} />
value={row.value} <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
onChange={(e) => setValue(row.attr.attr_key, e.target.value)} <select className="form-input" style={{ width: 90, textAlign: 'left' }} value={newUnit} onChange={(e) => setNewUnit(e.target.value)}>
placeholder="Wert in der Einheit oben" <option value="g">g</option>
/> <option value="mg">mg</option>
</label> <option value="µg">µg</option>
))} </select>
<button type="button" className="btn btn-secondary" disabled={creating} onClick={createAttr}>
{creating ? '…' : 'Parameter anlegen'}
</button>
</div>
{createErr && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{createErr}</p>}
</div>
{extras.map((row) => {
const target = attrUnit(row.attr)
const entered = row.inputUnit || target
const mass = MASS[target.toLowerCase()]
return (
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
{row.attr.name_de} (Katalog: {target})
<span style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<input
className="form-input"
type="number"
min="0"
step="0.001"
style={{ flex: 1, textAlign: 'left' }}
value={row.value}
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
placeholder="Wert vom Etikett"
/>
{mass ? (
<select
className="form-input"
style={{ width: 72, textAlign: 'left' }}
value={entered}
onChange={(e) => setInputUnit(row.attr.attr_key, e.target.value)}
>
<option value="g">g</option>
<option value="mg">mg</option>
<option value="µg">µg</option>
</select>
) : (
<span style={{ alignSelf: 'center' }}>{target}</span>
)}
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
onClick={() => setExtras((list) => list.filter((r) => r.attr.attr_key !== row.attr.attr_key))}
aria-label="Stoff entfernen"
>
×
</button>
</span>
</label>
)
})}
</div> </div>
) )
} }

View File

@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodSearchModal from './FoodSearchModal' import FoodSearchModal from './FoodSearchModal'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilityLabel } from '../utils/foodVisibility'
const FALLBACK_UNITS = [ const FALLBACK_UNITS = [
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false }, { id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
@ -111,6 +114,7 @@ export default function FoodRecipeDialog({
onSave, onSave,
onClose, onClose,
}) { }) {
const { isAdmin } = useAuth()
const [name, setName] = useState(defaultName || '') const [name, setName] = useState(defaultName || '')
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1') const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
const [ings, setIngs] = useState( const [ings, setIngs] = useState(
@ -128,6 +132,7 @@ export default function FoodRecipeDialog({
const [knownNames, setKnownNames] = useState([]) const [knownNames, setKnownNames] = useState([])
const [suggestions, setSuggestions] = useState({}) const [suggestions, setSuggestions] = useState({})
const [mapFor, setMapFor] = useState(null) const [mapFor, setMapFor] = useState(null)
const [editFoodId, setEditFoodId] = useState(null)
const nameRef = useRef(null) const nameRef = useRef(null)
const qtyRefs = useRef({}) const qtyRefs = useRef({})
const timer = useRef(null) const timer = useRef(null)
@ -476,21 +481,27 @@ export default function FoodRecipeDialog({
))} ))}
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog…</p>} {searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog…</p>}
{userHits.length > 0 && ( {userHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Eigene Lebensmittel</div> <div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Persönlich und zentral</div>
)} )}
{userHits.map((h) => ( {userHits.map((h) => (
<button <div key={h.id} style={{ display: 'flex', gap: 8, margin: '0 8px 8px' }}>
key={h.id} <button
type="button" type="button"
className="btn btn-secondary btn-full" className="btn btn-secondary"
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }} style={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => addCatalog(h)} onClick={() => addCatalog(h)}
> >
<span> <span>
<strong style={{ display: 'block' }}>{h.name_de}</strong> <strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{h.bls_code ? `BLS ${h.bls_code}` : 'manuell'}</span> <span style={{ fontSize: 12, color: 'var(--text3)' }}>{foodVisibilityLabel(h.catalog_kind, h.visibility)}</span>
</span> </span>
</button> </button>
{(h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')) && (
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditFoodId(h.id)}>
Bearbeiten
</button>
)}
</div>
))} ))}
{blsHits.length > 0 && ( {blsHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>BLS-Katalog</div> <div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>BLS-Katalog</div>
@ -623,6 +634,14 @@ export default function FoodRecipeDialog({
onSelect={(food, extras) => assignIngredient(mapFor.source_name_raw, food, extras).catch((e) => setError(e.message))} onSelect={(food, extras) => assignIngredient(mapFor.source_name_raw, food, extras).catch((e) => setError(e.message))}
/> />
)} )}
{editFoodId && (
<FoodEditDialog
foodId={editFoodId}
zIndex={24000}
onClose={() => setEditFoodId(null)}
onSaved={() => runCatalog(q)}
/>
)}
</div> </div>
) )
} }

View File

@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' 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 COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
const UNIT_LABEL = { const UNIT_LABEL = {
@ -26,6 +29,7 @@ function detectUnit(...texts) {
} }
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) { export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose, zIndex }) {
const { isAdmin } = useAuth()
const [q, setQ] = useState(initialQuery || '') const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -33,6 +37,7 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [createKind, setCreateKind] = useState(null) const [createKind, setCreateKind] = useState(null)
const [editId, setEditId] = useState(null)
const [manual, setManual] = useState({ const [manual, setManual] = useState({
name_de: initialQuery || '', name_de: initialQuery || '',
kcal: '', protein_g: '', fat_g: '', carbs_g: '', 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 const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
return ( return (
<>
<div <div
style={{ style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
@ -186,24 +192,33 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
{!loading && q.trim().length >= 2 && hits.length === 0 && ( {!loading && q.trim().length >= 2 && hits.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p> <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)} )}
{hits.map((h) => ( {hits.map((h) => {
<button const manual = h.catalog_kind === 'manual_user' || (isAdmin && h.catalog_kind === 'manual_admin')
key={h.id} return (
type="button" <div key={h.id} style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'stretch' }}>
className="btn btn-secondary btn-full" <button
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} type="button"
onClick={() => onSelect(h, unitExtras())} className="btn btn-secondary"
> style={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
<span> onClick={() => onSelect(h, unitExtras())}
<strong style={{ display: 'block' }}>{h.name_de}</strong> >
<span style={{ fontSize: 12, color: 'var(--text3)' }}> <span>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'} <strong style={{ display: 'block' }}>{h.name_de}</strong>
{h.food_group ? ` · Gruppe ${h.food_group}` : ''} <span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''} {h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
</span> {h.food_group ? ` · Gruppe ${h.food_group}` : ''}
</span> {foodVisibilitySuffix(h.catalog_kind, h.visibility)}
</button> </span>
))} </span>
</button>
{manual && (
<button type="button" className="btn btn-secondary" style={{ flexShrink: 0 }} onClick={() => setEditId(h.id)}>
Bearbeiten
</button>
)}
</div>
)
})}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}> <div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{!showCreate ? ( {!showCreate ? (
@ -262,5 +277,14 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, all
</div> </div>
</div> </div>
</div> </div>
{editId && (
<FoodEditDialog
foodId={editId}
zIndex={(zIndex || 20000) + 2000}
onClose={() => setEditId(null)}
onSaved={() => runSearch(q)}
/>
)}
</>
) )
} }

View File

@ -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 (
<div className="card section-gap">
<div className="card-title">Lebensmittel</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
<strong>Persönlich:</strong> nur für dich, hier änderbar. <strong>Zentral:</strong> 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.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Persönliches Lebensmittel anlegen</button>
</div>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Persönliche und zentrale Einträge suchen…"
value={q}
onChange={(e) => { setQ(e.target.value); setVisible(60) }}
/>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Persönlich ({loading ? '…' : filteredMine.length})</h3>
{!loading && foods.length === 0 && (
<p className="muted">Noch keine eigenen Einträge. Hier anlegen oder beim Zuordnen über „Neu anlegen“.</p>
)}
{!loading && foods.length > 0 && filteredMine.length === 0 && (
<p className="muted">Kein persönlicher Treffer für „{q.trim()}“.</p>
)}
{shownMine.map((f) => (
<div key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
<button
type="button"
onClick={() => { setEditMode('user'); setEditId(f.id) }}
style={{ background: 'none', border: 0, padding: 0, textAlign: 'left', color: 'inherit', cursor: 'pointer' }}
>
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nur du</div>
</button>
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('user'); setEditId(f.id) }}>Bearbeiten</button>
</div>
))}
{visible < filteredMine.length && (
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 60)}>
Weitere {Math.min(60, filteredMine.length - visible)} zeigen
</button>
)}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Zentral für alle ({loading ? '…' : filteredShared.length})</h3>
{!loading && shared.length === 0 && (
<p className="muted">Noch keine zentralen Einträge vom Admin.</p>
)}
{!loading && shared.length > 0 && filteredShared.length === 0 && (
<p className="muted">Kein zentraler Treffer für „{q.trim()}“.</p>
)}
{filteredShared.map((f) => (
<div key={f.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
{isAdmin ? (
<button
type="button"
onClick={() => { setEditMode('admin'); setEditId(f.id) }}
style={{ background: 'none', border: 0, padding: 0, textAlign: 'left', color: 'inherit', cursor: 'pointer' }}
>
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}</div>
</button>
) : (
<div>
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>für alle Nutzer{foodVisibilitySuffix(f.catalog_kind, f.visibility)}</div>
</div>
)}
{isAdmin && (
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => { setEditMode('admin'); setEditId(f.id) }}>Bearbeiten</button>
)}
</div>
))}
{(creating || editId) && (
<FoodEditDialog
mode={creating ? 'user' : editMode}
foodId={creating ? null : editId}
onClose={() => { setCreating(false); setEditId(null) }}
onSaved={(res) => {
setCreating(false)
setEditId(null)
afterSave(res)
}}
/>
)}
</div>
)
}

View File

@ -1,8 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import FoodSearchModal from './FoodSearchModal' import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeDialog from './FoodRecipeDialog' import FoodRecipeDialog from './FoodRecipeDialog'
import FoodEditDialog from './FoodEditDialog'
import { foodVisibilitySuffix } from '../utils/foodVisibility'
const PERIODS = [ const PERIODS = [
{ days: 14, label: '14 Tage' }, { 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 [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([]) const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([]) const [recipes, setRecipes] = useState([])
@ -144,6 +148,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [visible, setVisible] = useState(80) const [visible, setVisible] = useState(80)
const [showLearned, setShowLearned] = useState(false) const [showLearned, setShowLearned] = useState(false)
const [editFoodId, setEditFoodId] = useState(null)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const listRef = useRef(null) const listRef = useRef(null)
@ -371,6 +376,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
<input ref={listRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} /> <input ref={listRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
<button type="button" className="btn btn-primary" onClick={() => onManageLists?.()}>Listen verwalten</button> <button type="button" className="btn btn-primary" onClick={() => onManageLists?.()}>Listen verwalten</button>
<button type="button" className="btn btn-secondary" onClick={() => onOpenMyFoods?.()}>Meine Lebensmittel</button>
<button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}> <button type="button" className="btn btn-secondary" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'} {importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
</button> </button>
@ -448,7 +454,9 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 13 }}> <span style={{ fontSize: 13 }}>
→ <strong>{best.name_de}</strong> → <strong>{best.name_de}</strong>
<span style={{ color: 'var(--text3)' }}>{best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}</span> <span style={{ color: 'var(--text3)' }}>
{best.bls_code ? ` · ${best.bls_code}` : foodVisibilitySuffix(best.catalog_kind, best.visibility)}
</span>
</span> </span>
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}> <button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}>
Bestätigen Bestätigen
@ -457,7 +465,7 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
)} )}
{suggestions.slice(1).map((s) => ( {suggestions.slice(1).map((s) => (
<div key={s.id} style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> <div key={s.id} style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''}</span> <span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : foodVisibilitySuffix(s.catalog_kind, s.visibility)}</span>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button> <button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button>
</div> </div>
))} ))}
@ -510,15 +518,20 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
</button> </button>
</h3> </h3>
{showLearned && learned.map((m) => ( {showLearned && learned.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}> <div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)', alignItems: 'center' }}>
<div> <div>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div> <div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}> <div style={{ fontSize: 12, color: 'var(--text3)' }}>
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''} → {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''} {foodVisibilitySuffix(m.catalog_kind, m.visibility)}
</div> </div>
</div> </div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{m.food_id && (m.catalog_kind === 'manual_user' || (isAdmin && m.catalog_kind === 'manual_admin')) && (
<button type="button" className="btn btn-secondary" onClick={() => setEditFoodId(m.food_id)}>Bearbeiten</button>
)}
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
</div>
</div> </div>
))} ))}
@ -558,6 +571,19 @@ export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, o
{recipeFor && ( {recipeFor && (
<RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} /> <RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} />
)} )}
{editFoodId && (
<FoodEditDialog
foodId={editFoodId}
onClose={() => 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.')
}}
/>
)}
</div> </div>
) )
} }

View File

@ -128,7 +128,7 @@ export const ADMIN_GROUPS = [
{ {
to: '/admin/bls-foods', to: '/admin/bls-foods',
label: 'Lebensmittelkatalog', 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', to: '/admin/food-attributes',

View File

@ -1,43 +1,180 @@
import { useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import FoodEditDialog from '../components/FoodEditDialog'
function FoodRow({ row, onEdit }) {
return (
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, textAlign: 'left' }}
onClick={() => onEdit(row)}
>
{row.bls_code ? `${row.bls_code} · ` : ''}{row.name_de}
{row.catalog_kind === 'manual_admin' ? ' · zentral (alle)' : row.catalog_kind === 'manual_user' ? ' · persönlich' : ''}
</button>
{row.catalog_kind && row.catalog_kind !== 'official_bls' && (
<button type="button" className="btn btn-primary" onClick={() => onEdit(row)}>Bearbeiten</button>
)}
</div>
)
}
export default function AdminBlsFoodsPage() { export default function AdminBlsFoodsPage() {
const [tab, setTab] = useState('search')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [manualQ, setManualQ] = useState('')
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
const [manualRows, setManualRows] = useState([])
const [detail, setDetail] = useState(null) const [detail, setDetail] = useState(null)
const [error, setError] = 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 { try {
setRows(await api.adminBlsFoods(q)) setError(null)
setRows(await api.adminBlsFoods(term, '', 50))
} catch (e) { } catch (e) {
setError(e.message) 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 ( return (
<div className="card"> <div className="card">
<h1 className="page-title">Lebensmittelkatalog</h1> <h1 className="page-title">Lebensmittelkatalog</h1>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>} {error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<div className="form-row">
<input className="form-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name oder BLS-Code" /> <div className="tabs section-gap" style={{ marginBottom: 12 }}>
<button type="button" className="btn btn-primary" onClick={search}>Suchen</button> <button type="button" className={'tab' + (tab === 'search' ? ' active' : '')} onClick={() => { setTab('search'); setDetail(null) }}>
</div> Suche
{rows.map((r) => (
<button
key={r.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 6, textAlign: 'left' }}
onClick={() => api.adminBlsFoodDetail(r.id).then(setDetail).catch((e) => setError(e.message))}
>
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
{r.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</button> </button>
<button type="button" className={'tab' + (tab === 'manual' ? ' active' : '')} onClick={() => { setTab('manual'); setDetail(null) }}>
Manuell
</button>
</div>
{tab === 'search' && (
<>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
BLS und manuelle Einträge nach Name oder Code. Die komplette BLS-Liste ist zu groß zum Durchblättern.
</p>
<div className="form-row">
<input
className="form-input"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') search(q) }}
placeholder="Name oder BLS-Code"
/>
<button type="button" className="btn btn-primary" onClick={() => search(q)}>Suchen</button>
</div>
</>
)}
{tab === 'manual' && (
<>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
Zwei Listen: zentral für alle Nutzer, darunter persönliche Einträge (sichtbar, weil du Admin bist). Beide sind hier änderbar.
</p>
<div style={{ marginBottom: 10 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>Zentral anlegen</button>
</div>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
value={manualQ}
onChange={(e) => onManualQuery(e.target.value)}
placeholder="In manuellen Einträgen suchen…"
/>
<p style={{ fontSize: 13, margin: '0 0 8px' }}>
{loadingManual
? 'Lade…'
: `${centralRows.length} zentral, ${personalRows.length} persönlich`}
{!loadingManual && shownManual.length >= 200 ? ' (Anzeige begrenzt auf 200)' : ''}
</p>
</>
)}
{tab === 'search' && rows.length === 0 && (
<p className="muted">Suchbegriff eingeben und Suchen.</p>
)}
{tab === 'manual' && !loadingManual && shownManual.length === 0 && (
<p className="muted">Keine manuellen Lebensmittel{manualQ.trim() ? ` zu „${manualQ.trim()}“` : ''}.</p>
)}
{tab === 'search' && list.map((r) => (
<FoodRow key={r.id} row={r} onEdit={openEdit} />
))} ))}
{tab === 'manual' && !loadingManual && shownManual.length > 0 && (
<>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Zentral für alle ({centralRows.length})</h3>
{centralRows.length === 0 && <p className="muted">Noch keine zentralen Einträge.</p>}
{centralRows.map((r) => (
<FoodRow key={r.id} row={r} onEdit={openEdit} />
))}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Persönlich ({personalRows.length})</h3>
{personalRows.length === 0 && <p className="muted">Keine persönlichen Einträge in dieser Auswahl.</p>}
{personalRows.map((r) => (
<FoodRow key={r.id} row={r} onEdit={openEdit} />
))}
</>
)}
{detail && ( {detail && (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<h3>{detail.bls_code} {detail.name_de}</h3> <h3>{detail.bls_code ? `${detail.bls_code} · ` : ''}{detail.name_de}</h3>
<p style={{ fontSize: 12, color: 'var(--text3)' }}>{detail.catalog_kind} · {detail.bls_version || '—'}</p> <p style={{ fontSize: 12, color: 'var(--text3)' }}>{detail.catalog_kind} · {detail.bls_version || '—'}</p>
<table style={{ width: '100%', fontSize: 12 }}> <table style={{ width: '100%', fontSize: 12 }}>
<tbody> <tbody>
@ -52,6 +189,18 @@ export default function AdminBlsFoodsPage() {
</table> </table>
</div> </div>
)} )}
{(creating || editId) && (
<FoodEditDialog
mode="admin"
foodId={creating ? null : editId}
onClose={() => { setCreating(false); setEditId(null) }}
onSaved={() => {
setCreating(false)
setEditId(null)
if (tab === 'manual') loadManual(manualQ)
}}
/>
)}
</div> </div>
) )
} }

View File

@ -1,22 +1,138 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { api } from '../utils/api' 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() { export default function AdminFoodAttributesPage() {
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' }) 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 [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)) const load = () => api.adminBlsAttributes().then(setRows).catch((e) => setError(e.message))
useEffect(() => { load() }, []) 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 ( return (
<div className="card"> <div className="card">
<h1 className="page-title">Stoffe & Attribute</h1> <h1 className="page-title">Stoffe & Attribute</h1>
<p style={{ fontSize: 13, color: 'var(--text2)' }}> <p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.55, margin: '0 0 12px' }}>
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.
</p> </p>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>} {error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<div className="form-row"> {notice && <p style={{ color: 'var(--accent-dark)', fontSize: 13 }}>{notice}</p>}
<div className="form-row" style={{ marginBottom: 12 }}>
<input className="form-input" placeholder="KEY" value={form.attr_key} onChange={(e) => setForm({ ...form, attr_key: e.target.value })} /> <input className="form-input" placeholder="KEY" value={form.attr_key} onChange={(e) => setForm({ ...form, attr_key: e.target.value })} />
<input className="form-input" placeholder="Name DE" value={form.name_de} onChange={(e) => setForm({ ...form, name_de: e.target.value })} /> <input className="form-input" placeholder="Name DE" value={form.name_de} onChange={(e) => setForm({ ...form, name_de: e.target.value })} />
<input className="form-input" placeholder="Einheit" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} /> <input className="form-input" placeholder="Einheit" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} />
@ -26,19 +142,115 @@ export default function AdminFoodAttributesPage() {
<option value="text">Text</option> <option value="text">Text</option>
<option value="enum">Auswahl</option> <option value="enum">Auswahl</option>
</select> </select>
<button type="button" className="btn btn-primary" onClick={async () => { <button
try { type="button"
await api.adminCreateBlsAttribute(form) className="btn btn-primary"
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' }) disabled={busy}
load() onClick={async () => {
} catch (e) { setError(e.message) } try {
}}>Anlegen</button> await api.adminCreateBlsAttribute(form)
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
load()
} catch (e) { setError(e.message) }
}}
>
Anlegen
</button>
</div> </div>
{rows.map((r) => (
<div key={r.id} style={{ fontSize: 13, padding: '6px 0', borderTop: '1px solid var(--border)' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 10 }}>
<strong>{r.attr_key}</strong> · {r.name_de} · {r.data_type} · {r.origin} <button type="button" className={'btn ' + (filter === 'all' ? 'btn-primary' : 'btn-secondary')} style={{ fontSize: 12 }} onClick={() => { setFilter('all'); setOpen(new Set(DEFAULT_OPEN)) }}>
</div> Alle ({rows.length})
))} </button>
{GROUP_OPTIONS.filter((g) => groupCounts[g.id]).map((g) => (
<button
key={g.id}
type="button"
className={'btn ' + (filter === g.id ? 'btn-primary' : 'btn-secondary')}
style={{ fontSize: 12 }}
onClick={() => setFilter(g.id)}
>
{g.label} ({groupCounts[g.id]})
</button>
))}
<button type="button" className={'btn ' + (filter === 'needs' ? 'btn-primary' : 'btn-secondary')} style={{ fontSize: 12 }} onClick={() => setFilter('needs')}>
Ohne Bezeichnung{missing ? ` (${missing})` : ''}
</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setOpen(new Set(groups.map((g) => g.key)))}>
Alle aufklappen
</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => setOpen(new Set())}>
Alle zuklappen
</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} disabled={busy} onClick={fillLabels}>
Namen/Gruppen aus Glossar füllen
</button>
</div>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 10 }}
placeholder="In allen Gruppen suchen…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<p style={{ fontSize: 13, margin: '0 0 8px' }}>{shown.length} von {rows.length} Stoffen · {groups.length} Gruppen</p>
{groups.map((g) => {
const opened = open.has(g.key)
return (
<section key={g.key} style={{ marginBottom: 8, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<button
type="button"
onClick={() => toggle(g.key)}
style={{
width: '100%', textAlign: 'left', display: 'flex', justifyContent: 'space-between',
gap: 8, padding: '10px 12px', background: 'var(--surface2)', border: 0,
cursor: 'pointer', fontFamily: 'inherit', color: 'inherit',
}}
>
<span style={{ fontWeight: 600, fontSize: 14 }}>{g.label}</span>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{g.items.length} · {opened ? 'zuklappen' : 'aufklappen'}</span>
</button>
{opened && g.items.map((r) => (
<div key={r.id} style={{ padding: '8px 12px', borderTop: '1px solid var(--border)' }}>
{editId === r.id ? (
<div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 6 }}>
Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''}
</div>
<div className="form-row">
<input className="form-input" value={draft.name_de} onChange={(e) => setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" />
<input className="form-input" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" />
<select className="form-input" value={draft.category} onChange={(e) => setDraft({ ...draft, category: e.target.value })}>
{GROUP_OPTIONS.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => saveEdit(r.id)}>Speichern</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => setEditId(null)}>Abbrechen</button>
</div>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600 }}>
{r.needs_label ? r.attr_key : (r.name_de || r.attr_key)}
{r.is_macro ? <span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent-dark)' }}>Makro</span> : null}
</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{r.attr_key}
{r.needs_label ? ' · Bezeichnung fehlt' : ''}
{r.unit ? ` · ${r.unit}` : ''}
</div>
</div>
<button type="button" className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
</div>
)}
</div>
))}
</section>
)
})}
</div> </div>
) )
} }

View File

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { foodVisibilityLabel } from '../utils/foodVisibility'
export default function AdminFoodMappingsPage() { export default function AdminFoodMappingsPage() {
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
@ -30,7 +31,7 @@ export default function AdminFoodMappingsPage() {
setter([]) setter([])
return return
} }
api.searchBlsFoods(q, 20).then(setter).catch(() => setter([])) api.searchBlsFoods(q, 20, undefined, 'global').then(setter).catch(() => setter([]))
} }
const shown = rows.filter((r) => { const shown = rows.filter((r) => {
@ -59,6 +60,10 @@ export default function AdminFoodMappingsPage() {
<div className="card"> <div className="card">
<h1 className="page-title">FDDB→BLS Mappings</h1> <h1 className="page-title">FDDB→BLS Mappings</h1>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>} {error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<p style={{ fontSize: 13, color: 'var(--text2)', margin: '0 0 10px' }}>
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.
</p>
{coverage && ( {coverage && (
<p style={{ fontSize: 13 }}> <p style={{ fontSize: 13 }}>
{coverage.mapped_items}/{coverage.total_items} Zeilen gemappt · {coverage.mapped_items}/{coverage.total_items} Zeilen gemappt ·
@ -77,7 +82,7 @@ export default function AdminFoodMappingsPage() {
</div> </div>
{hits.map((h) => ( {hits.map((h) => (
<button key={h.id} type="button" className="btn btn-secondary" style={{ margin: 4 }} onClick={() => { setFoodId(h.id); setFoodQ(h.name_de) }}> <button key={h.id} type="button" className="btn btn-secondary" style={{ margin: 4 }} onClick={() => { setFoodId(h.id); setFoodQ(h.name_de) }}>
{h.bls_code} {h.name_de} {h.bls_code} {h.name_de}{h.catalog_kind === 'manual_admin' ? ' · zentral' : h.bls_code ? '' : ' · BLS'}
</button> </button>
))} ))}
<button <button
@ -100,7 +105,10 @@ export default function AdminFoodMappingsPage() {
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
<div> <div>
{r.source_name_raw} → {r.bls_code} {r.food_name_de} {r.source_name_raw} → {r.bls_code} {r.food_name_de}
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{r.profile_id ? 'user' : 'global'}</div> <div style={{ fontSize: 11, color: 'var(--text3)' }}>
{r.profile_id ? 'Nutzer-Mapping' : 'globales Mapping'}
{r.catalog_kind ? ` · ${foodVisibilityLabel(r.catalog_kind, r.visibility)}` : ''}
</div>
</div> </div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn btn-secondary" onClick={() => { setEditId(editId === r.id ? null : r.id); setEditQ(''); setEditHits([]) }}> <button type="button" className="btn btn-secondary" onClick={() => { setEditId(editId === r.id ? null : r.id); setEditQ(''); setEditHits([]) }}>
@ -131,7 +139,7 @@ export default function AdminFoodMappingsPage() {
disabled={saving} disabled={saving}
onClick={() => applyChange(r.id, h)} onClick={() => applyChange(r.id, h)}
> >
{h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''} {h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''} · {foodVisibilityLabel(h.catalog_kind, h.visibility)}
</button> </button>
))} ))}
</div> </div>

View File

@ -3,6 +3,7 @@ import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap' import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
import FoodRecipeEditor from '../components/FoodRecipeEditor' import FoodRecipeEditor from '../components/FoodRecipeEditor'
import MyFoodsPanel from '../components/MyFoodsPanel'
import { import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
@ -957,7 +958,7 @@ export default function NutritionPage() {
)} )}
{/* Input Method Tabs */} {/* Input Method Tabs */}
<div className="tabs section-gap" style={{marginBottom:0}}> <div className="tabs section-gap" style={{marginBottom:0, overflowX:'auto', flexWrap:'nowrap'}}>
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}> <button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
✏️ Einzelerfassung ✏️ Einzelerfassung
</button> </button>
@ -967,6 +968,9 @@ export default function NutritionPage() {
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}> <button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''} Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
</button> </button>
<button className={'tab'+(inputTab==='foods'?' active':'')} onClick={()=>setInputTab('foods')}>
Lebensmittel
</button>
<button className={'tab'+(inputTab==='lists'?' active':'')} onClick={()=>setInputTab('lists')}> <button className={'tab'+(inputTab==='lists'?' active':'')} onClick={()=>setInputTab('lists')}>
Listen Listen
</button> </button>
@ -992,9 +996,12 @@ export default function NutritionPage() {
onChanged={refreshUnmappedCount} onChanged={refreshUnmappedCount}
onManageLists={(id) => { setFocusListId(id || null); setInputTab('lists') }} onManageLists={(id) => { setFocusListId(id || null); setInputTab('lists') }}
onCreatedList={(rec) => { setFocusListId(rec?.id || null); setInputTab('lists') }} onCreatedList={(rec) => { setFocusListId(rec?.id || null); setInputTab('lists') }}
onOpenMyFoods={() => setInputTab('foods')}
/> />
)} )}
{inputTab==='foods' && <MyFoodsPanel />}
{inputTab==='lists' && ( {inputTab==='lists' && (
<FoodRecipeEditor <FoodRecipeEditor
highlightId={focusListId} highlightId={focusListId}
@ -1002,9 +1009,9 @@ export default function NutritionPage() {
/> />
)} )}
{loading && inputTab !== 'map' && inputTab !== 'lists' && <div className="empty-state"><div className="spinner"/></div>} {loading && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && ( {!loading && !hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && (
<div className="empty-state"> <div className="empty-state">
<h3>Noch keine Ernährungsdaten</h3> <h3>Noch keine Ernährungsdaten</h3>
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p> <p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
@ -1012,7 +1019,7 @@ export default function NutritionPage() {
)} )}
{/* Analysis Section */} {/* Analysis Section */}
{!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && ( {!loading && hasData && inputTab !== 'map' && inputTab !== 'lists' && inputTab !== 'foods' && (
<> <>
<OverviewCards data={corrData}/> <OverviewCards data={corrData}/>

View File

@ -285,10 +285,15 @@ export const api = {
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})), resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
listFoodUnits: () => req('/bls/units'), 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 })), suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
createUserFood: (d) => req('/bls/foods/manual', json(d)), 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'), listMyFoodMappings: () => req('/bls/mappings'),
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)), upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}), deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
@ -300,11 +305,14 @@ export const api = {
}, },
adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`), adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`),
adminBlsImportApply: (id) => req(`/admin/bls/import/jobs/${id}/apply`, {method:'POST'}), 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):''}`), adminBlsFoods: (q, kind, limit) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+encodeURIComponent(kind)+'&'):''}${limit?('limit='+limit):''}`),
adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`), adminBlsFoodDetail: (id, editor=false) => req(`/admin/bls/foods/${encodeURIComponent(id)}${editor?'?editor=true':''}`),
adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)), 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'), adminBlsAttributes: () => req('/admin/bls/attributes'),
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)), 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':''}`), adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),
adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)), adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)),
adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)), adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)),

View File

@ -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}` : ''
}