BLS Stoffgruppen, Lebensmittel verwalten, etc #108

Merged
Lars merged 6 commits from develop into main 2026-09-14 13:15:10 +02:00
30 changed files with 1195 additions and 120 deletions
Showing only changes of commit 8b1e1da5d8 - Show all commits

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
@ -38,14 +38,17 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
- 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.
- Ö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. - Ö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. - 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.
**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge anlegen/bearbeiten; zentrale nur lesen. 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** → Zentral anlegen.
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
@ -29,6 +29,9 @@ 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; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA` - `/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`; `PUT /bls/foods/manual/{id}` — nur Owner. 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/PUT /admin/bls/foods/manual` — zentrale Foods (`attributes` + `serving_g`). 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
- `/api/admin/food-mappings` — Admin-CRUD - `/api/admin/food-mappings` — Admin-CRUD
@ -36,7 +39,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
- `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. Nutzer kann fehlende Stoffe anlegen (`POST /bls/attributes`). Öl/Supplement = ein Lebensmittel, keine Liste (Listen nicht verschachtelbar). - **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel**. Admin: Katalog → Manuell → **Zentral anlegen**. 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

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

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,105 @@
"""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 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 str(owner) == str(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 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

@ -32,6 +32,8 @@ 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 +164,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 +176,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
@ -199,11 +203,15 @@ def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
@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,10 +223,64 @@ 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 = 'manual_admin' AND is_active = true
""",
(food_id,),
)
food = cur.fetchone()
if not food:
raise HTTPException(404, "Zentrales 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")

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

@ -95,12 +95,16 @@ 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")
@ -172,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)
editable = food.get("catalog_kind") == "manual_user" and str(food.get("owner_profile_id") or "") == str(pid)
from data_layer.food_visibility import attach_visibility
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),
@ -192,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")
@ -204,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)
@ -238,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

@ -1,5 +1,6 @@
from data_layer.food_attributes import ( from data_layer.food_attributes import (
create_extension_attribute, create_extension_attribute,
editor_payload_for_food,
macros_and_attributes_to_values, macros_and_attributes_to_values,
normalize_attr_key, normalize_attr_key,
normalize_attr_unit, normalize_attr_unit,
@ -45,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,90 @@
from data_layer.food_visibility import (
attach_visibility,
is_global_mapping_target,
is_visible_to,
require_mapping_target,
visibility_of,
visibility_rank,
)
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.6", # Nutzer darf Extension-Stoffe anlegen; EPA-Chips; mg/µg am Etikett "bls": "1.0.9", # Nutzer-Foods privat, Admin-Foods zentral; Mapping achtet Sichtbarkeit
"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,146 @@
import { useEffect, useState } from 'react'
import { api } from '../utils/api'
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 isCreate = !foodId
const isAdmin = 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 [editable, setEditable] = useState(false)
const [loading, setLoading] = useState(!isCreate)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (isCreate) {
setEditable(true)
setLoading(false)
return
}
let alive = true
api.getBlsFood(foodId).then((d) => {
if (!alive) return
setName(d.name_de || '')
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 || []).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))
setEditable(isAdmin ? d.catalog_kind === 'manual_admin' : Boolean(d.editable))
setLoading(false)
}).catch((e) => {
if (!alive) return
setError(e.message)
setLoading(false)
})
return () => { alive = false }
}, [foodId, isCreate, isAdmin])
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 (isAdmin) {
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)
}
}
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 }}>
{isCreate
? (isAdmin ? 'Zentrales Lebensmittel anlegen' : 'Lebensmittel anlegen')
: (isAdmin ? 'Zentrales Lebensmittel bearbeiten' : 'Lebensmittel bearbeiten')}
</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)' }}>
Offizielle BLS-Einträge sind fest. Nur selbst angelegte{isAdmin ? ' zentrale' : ''} 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

@ -45,7 +45,7 @@ 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 [quick, setQuick] = useState([])
@ -124,8 +124,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
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' }}>
Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl ≈ 5 g). Gespeichert wird immer pro 100 g. {valuesArePer100g
Ein Öl/Supplement ist ein Lebensmittel, keine Liste — Listen können keine Listen enthalten. ? '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{' '}
@ -241,6 +242,15 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
) : ( ) : (
<span style={{ alignSelf: 'center' }}>{target}</span> <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> </span>
</label> </label>
) )

View File

@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
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 },
@ -128,6 +130,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 +479,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' && (
<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 +632,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,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
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 = {
@ -33,6 +35,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 +130,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 +190,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 && h.catalog_kind !== 'official_bls' && 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 +275,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,123 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from '../utils/api'
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 [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 [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. <strong>Zentral:</strong> vom Admin für alle Nutzer.
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' }}>
<div>
<div style={{ fontWeight: 600 }}>{f.name_de}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nur du</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => 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={{ padding: '10px 0', borderTop: '1px solid var(--border)' }}>
<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>
))}
{(creating || editId) && (
<FoodEditDialog
foodId={creating ? null : editId}
onClose={() => { setCreating(false); setEditId(null) }}
onSaved={(res) => {
setCreating(false)
setEditId(null)
afterSave(res)
}}
/>
)}
</div>
)
}

View File

@ -3,6 +3,8 @@ import { api } from '../utils/api'
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 +129,7 @@ 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 [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 +146,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 +374,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 +452,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 +463,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 +516,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.catalog_kind === 'manual_user' && m.food_id && (
<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 +569,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,142 @@
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'
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 openDetail = (id) => {
api.adminBlsFoodDetail(id).then(setDetail).catch((e) => setError(e.message))
}
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' }}>
Zentral (für alle Nutzer) und persönlich (nur der jeweilige Nutzer). Zentrale Einträge hier anlegen;
persönliche bleiben beim Owner.
</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…' : `${shownManual.length} Einträge`}
{!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>
)}
{list.map((r) => (
<div key={r.id} style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1, textAlign: 'left' }}
onClick={() => openDetail(r.id)}
>
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
{r.catalog_kind === 'manual_admin' ? ' · zentral (alle)' : r.catalog_kind === 'manual_user' ? ' · persönlich' : ''}
</button>
{r.catalog_kind === 'manual_admin' && (
<button type="button" className="btn btn-secondary" onClick={() => setEditId(r.id)}>Bearbeiten</button>
)}
</div>
))} ))}
{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 +151,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,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,9 +285,13 @@ 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)),
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)}` : ''}`), listFoodAttributes: (q='', limit=40, keys='') => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}${keys ? `&keys=${encodeURIComponent(keys)}` : ''}`),
createFoodAttribute: (d) => req('/bls/attributes', json(d)), createFoodAttribute: (d) => req('/bls/attributes', json(d)),
listMyFoodMappings: () => req('/bls/mappings'), listMyFoodMappings: () => req('/bls/mappings'),
@ -301,9 +305,10 @@ 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) => req(`/admin/bls/foods/${id}`),
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)),
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`), adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),

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