From 44e3e216abcc7a8f13741a710d7c32531825849b Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 14 Sep 2026 13:09:44 +0200 Subject: [PATCH] feat: Stoffe nach BLS-Gruppen sortieren und aufklappbar listen Energie und Makros zuerst, restliche Naehrstoffgruppen zuklappbar, damit die Liste bedienbar bleibt. Co-authored-by: Cursor --- .claude/docs/technical/BLS_FOOD_REFERENCE.md | 2 +- backend/bls/attribute_labels.py | 67 +++++- backend/routers/admin_bls.py | 19 +- backend/tests/test_attribute_labels.py | 19 +- backend/version.py | 2 +- .../src/pages/AdminFoodAttributesPage.jsx | 216 ++++++++++++------ 6 files changed, 230 insertions(+), 95 deletions(-) diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index 62bb1dd..afbfc7c 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -33,7 +33,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - Sichtbarkeit: `data_layer/food_visibility.py`. Lookup-Mapping: User-Zeile nur wenn Food sichtbar; globale Mapping-Zeile nur `owner_profile_id IS NULL`. `upsert_food_mapping` erzwingt dasselbe. Vorschläge sortieren persönlich → zentral → BLS. - `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST /admin/bls/foods/manual` — zentrale Foods. `PUT /admin/bls/foods/manual/{id}` — zentral **und** persönlich (`attributes` + `serving_g`). `GET /admin/bls/foods/{id}?editor=true` — Editor-Payload. Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht). - Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat -- `/api/admin/bls/*` — Import, Katalog, Attribute. `PUT /admin/bls/attributes/{id}` ändert Name/Einheit (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`. +- `/api/admin/bls/*` — Import, Katalog, Attribute. `GET /admin/bls/attributes` liefert `group_key`/`group_label` (BLS-Gruppen, sortiert). `PUT /admin/bls/attributes/{id}` ändert Name/Einheit/Gruppe (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen und fehlende Gruppen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`. - `/api/admin/food-mappings` — Admin-CRUD - `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve - `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply` diff --git a/backend/bls/attribute_labels.py b/backend/bls/attribute_labels.py index efbec20..885580c 100644 --- a/backend/bls/attribute_labels.py +++ b/backend/bls/attribute_labels.py @@ -9,6 +9,24 @@ from typing import Any # Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO") +GROUP_META = ( + ("energy", "Energie"), + ("macro", "Makronährstoffe"), + ("carbs", "Kohlenhydrate"), + ("fiber", "Ballaststoffe"), + ("fatty_acids", "Fettsäuren"), + ("amino_acids", "Aminosäuren"), + ("vitamins", "Vitamine"), + ("minerals", "Mineralstoffe"), + ("organic_acids", "Organische Säuren"), + ("polyols", "Zuckeralkohole"), + ("other", "Sonstige"), + ("extension", "Eigene Erweiterungen"), + ("unknown", "Ohne Gruppe"), +) +GROUP_LABELS = {key: label for key, label in GROUP_META} +GROUP_RANK = {key: i for i, (key, _) in enumerate(GROUP_META)} + # Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026) _OFFICIAL: dict[str, tuple[str, str, str]] = { "ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"), @@ -162,6 +180,42 @@ def lookup_key(raw: str | None) -> str: return str(raw or "").strip().upper() +def resolve_group(attr_key: str | None, category: str | None = None, origin: str | None = None) -> tuple[str, str]: + cat = (category or "").strip().lower() + if cat in GROUP_LABELS: + return cat, GROUP_LABELS[cat] + lab = official_label(attr_key) + if lab and lab["category"] in GROUP_LABELS: + return lab["category"], GROUP_LABELS[lab["category"]] + if (origin or "") == "extension": + return "extension", GROUP_LABELS["extension"] + return "unknown", GROUP_LABELS["unknown"] + + +def annotate_attribute(row: dict[str, Any]) -> dict[str, Any]: + key = row.get("attr_key") + group_key, group_label = resolve_group(key, row.get("category"), row.get("origin")) + row["is_macro"] = key in MACRO_KEYS + row["needs_label"] = label_is_placeholder(row.get("name_de"), key) + row["group_key"] = group_key + row["group_label"] = group_label + row["group_rank"] = GROUP_RANK[group_key] + return row + + +def sort_attributes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + def key_of(row: dict[str, Any]): + return ( + row.get("group_rank", 99), + 0 if row.get("is_macro") else 1, + row.get("sort_order") if row.get("sort_order") is not None else 0, + (row.get("name_de") or row.get("attr_key") or "").casefold(), + row.get("attr_key") or "", + ) + + return sorted(rows, key=key_of) + + def official_label(attr_key: str | None) -> dict[str, str] | None: key = lookup_key(attr_key) hit = _OFFICIAL.get(key) @@ -211,7 +265,11 @@ def fill_placeholder_labels(cur) -> dict[str, int]: for row in cur.fetchall(): rec = dict(row) lab = official_label(rec.get("attr_key")) - if not lab or not label_is_placeholder(rec.get("name_de"), rec.get("attr_key")): + if not lab: + continue + need_name = label_is_placeholder(rec.get("name_de"), rec.get("attr_key")) + need_cat = not (rec.get("category") or "").strip() + if not need_name and not need_cat: continue cur.execute( """ @@ -222,7 +280,12 @@ def fill_placeholder_labels(cur) -> dict[str, int]: updated_at = NOW() WHERE id = %s """, - (lab["name_de"], lab["unit"], lab["category"], rec["id"]), + ( + lab["name_de"] if need_name else rec.get("name_de"), + lab["unit"], + lab["category"], + rec["id"], + ), ) updated += 1 return {"updated": updated} diff --git a/backend/routers/admin_bls.py b/backend/routers/admin_bls.py index e25dca6..d61ca0d 100644 --- a/backend/routers/admin_bls.py +++ b/backend/routers/admin_bls.py @@ -300,7 +300,7 @@ def admin_update_manual_food(food_id: str, body: ManualFoodCreate, session: dict @router.get("/attributes") def admin_list_attributes(session: dict = Depends(require_admin)): - from bls.attribute_labels import MACRO_KEYS, label_is_placeholder + from bls.attribute_labels import annotate_attribute, sort_attributes with get_db() as conn: cur = get_cursor(conn) @@ -309,17 +309,9 @@ def admin_list_attributes(session: dict = Depends(require_admin)): SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order FROM food_attributes WHERE is_active = true - ORDER BY origin, sort_order, attr_key """ ) - rows = [] - for r in cur.fetchall(): - row = r2d(r) - key = row.get("attr_key") - row["is_macro"] = key in MACRO_KEYS - row["needs_label"] = label_is_placeholder(row.get("name_de"), key) - rows.append(row) - return rows + return sort_attributes([annotate_attribute(r2d(r)) for r in cur.fetchall()]) @router.post("/attributes/fill-labels") @@ -398,11 +390,8 @@ def admin_update_attribute(attr_id: int, body: AttributeUpdate, session: dict = row = cur.fetchone() if not row: raise HTTPException(404, "Stoff nicht gefunden") - from bls.attribute_labels import MACRO_KEYS, label_is_placeholder - out = r2d(row) - out["is_macro"] = out.get("attr_key") in MACRO_KEYS - out["needs_label"] = label_is_placeholder(out.get("name_de"), out.get("attr_key")) - return out + from bls.attribute_labels import annotate_attribute + return annotate_attribute(r2d(row)) def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None: diff --git a/backend/tests/test_attribute_labels.py b/backend/tests/test_attribute_labels.py index 3207d1e..aa4881f 100644 --- a/backend/tests/test_attribute_labels.py +++ b/backend/tests/test_attribute_labels.py @@ -1,8 +1,11 @@ from bls.attribute_labels import ( + annotate_attribute, apply_official_name, fill_placeholder_labels, label_is_placeholder, official_label, + resolve_group, + sort_attributes, ) @@ -35,6 +38,20 @@ def test_fill_placeholder_labels_skips_custom_names(): cur = _Cur() stats = fill_placeholder_labels(cur) - assert stats["updated"] == 1 + assert stats["updated"] == 2 assert cur.updated[0][0] == "Fett" assert cur.updated[0][3] == 1 + assert cur.updated[1][0] == "Mein EPA" + assert cur.updated[1][2] == "fatty_acids" + + +def test_group_and_sort_macros_first(): + assert resolve_group("ENERCC")[0] == "energy" + assert resolve_group("NA", None, None)[1] == "Mineralstoffe" + assert resolve_group("XYZ", None, "extension")[0] == "extension" + rows = sort_attributes([ + annotate_attribute({"attr_key": "NA", "name_de": "Natrium", "sort_order": 2, "origin": "official_bls"}), + annotate_attribute({"attr_key": "FAT", "name_de": "Fett", "sort_order": 9, "origin": "official_bls"}), + annotate_attribute({"attr_key": "ENERCC", "name_de": "Energie", "sort_order": 1, "origin": "official_bls"}), + ]) + assert [r["attr_key"] for r in rows] == ["ENERCC", "FAT", "NA"] diff --git a/backend/version.py b/backend/version.py index 43860fc..6dcc44a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -22,7 +22,7 @@ MODULE_VERSIONS = { "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "nutrition": "1.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping "tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) - "bls": "1.0.12", # Stoff-Bezeichnungen editierbar; BLS-Glossar; Makro-Pfad dokumentiert + "bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen) "photos": "1.0.0", "insights": "1.3.0", diff --git a/frontend/src/pages/AdminFoodAttributesPage.jsx b/frontend/src/pages/AdminFoodAttributesPage.jsx index 0cc9b67..aff4795 100644 --- a/frontend/src/pages/AdminFoodAttributesPage.jsx +++ b/frontend/src/pages/AdminFoodAttributesPage.jsx @@ -1,29 +1,55 @@ import { useEffect, useMemo, useState } from 'react' import { api } from '../utils/api' -const FILTERS = [ - { id: 'all', label: 'Alle' }, - { id: 'macro', label: 'Makros' }, - { id: 'needs', label: 'Ohne Bezeichnung' }, - { id: 'official', label: 'BLS' }, - { id: 'extension', label: 'Erweiterung' }, +const GROUP_OPTIONS = [ + { id: 'energy', label: 'Energie' }, + { id: 'macro', label: 'Makronährstoffe' }, + { id: 'carbs', label: 'Kohlenhydrate' }, + { id: 'fiber', label: 'Ballaststoffe' }, + { id: 'fatty_acids', label: 'Fettsäuren' }, + { id: 'amino_acids', label: 'Aminosäuren' }, + { id: 'vitamins', label: 'Vitamine' }, + { id: 'minerals', label: 'Mineralstoffe' }, + { id: 'organic_acids', label: 'Organische Säuren' }, + { id: 'polyols', label: 'Zuckeralkohole' }, + { id: 'other', label: 'Sonstige' }, + { id: 'extension', label: 'Eigene Erweiterungen' }, + { id: 'unknown', label: 'Ohne Gruppe' }, ] +const DEFAULT_OPEN = new Set(['energy', 'macro']) + function matches(row, term, filter) { - if (filter === 'macro' && !row.is_macro) return false if (filter === 'needs' && !row.needs_label) return false - if (filter === 'official' && row.origin !== 'official_bls') return false - if (filter === 'extension' && row.origin === 'official_bls') return false + if (filter !== 'all' && filter !== 'needs' && row.group_key !== filter) return false if (!term) return true - const hay = `${row.attr_key} ${row.name_de || ''} ${row.name_en || ''} ${row.unit || ''}`.toLowerCase() + const hay = `${row.attr_key} ${row.name_de || ''} ${row.name_en || ''} ${row.unit || ''} ${row.group_label || ''}`.toLowerCase() return hay.includes(term) } +function groupRows(rows) { + const byKey = new Map() + for (const row of rows) { + const key = row.group_key || 'unknown' + if (!byKey.has(key)) { + byKey.set(key, { + key, + label: row.group_label || 'Ohne Gruppe', + rank: row.group_rank ?? 99, + items: [], + }) + } + byKey.get(key).items.push(row) + } + return [...byKey.values()].sort((a, b) => a.rank - b.rank) +} + export default function AdminFoodAttributesPage() { const [rows, setRows] = useState([]) const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' }) const [q, setQ] = useState('') const [filter, setFilter] = useState('all') + const [open, setOpen] = useState(() => new Set(DEFAULT_OPEN)) const [editId, setEditId] = useState(null) const [draft, setDraft] = useState({ name_de: '', unit: '', category: '' }) const [error, setError] = useState(null) @@ -35,13 +61,37 @@ export default function AdminFoodAttributesPage() { const term = q.trim().toLowerCase() const shown = useMemo(() => rows.filter((r) => matches(r, term, filter)), [rows, term, filter]) + const groups = useMemo(() => groupRows(shown), [shown]) const missing = rows.filter((r) => r.needs_label).length - const macros = rows.filter((r) => r.is_macro) + const groupCounts = useMemo(() => { + const counts = {} + for (const r of rows) { + const key = r.group_key || 'unknown' + counts[key] = (counts[key] || 0) + 1 + } + return counts + }, [rows]) + + useEffect(() => { + if (term || filter !== 'all') { + setOpen(new Set(groups.map((g) => g.key))) + } + }, [term, filter, groups]) + + const toggle = (key) => { + setOpen((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } const startEdit = (r) => { setEditId(r.id) - setDraft({ name_de: r.name_de || '', unit: r.unit || '', category: r.category || '' }) + setDraft({ name_de: r.name_de || '', unit: r.unit || '', category: r.group_key || r.category || '' }) setError(null) + setOpen((prev) => new Set(prev).add(r.group_key || 'unknown')) } const saveEdit = async (id) => { @@ -64,7 +114,7 @@ export default function AdminFoodAttributesPage() { try { const res = await api.adminFillBlsAttributeLabels() await load() - setNotice(`${res.updated ?? 0} Bezeichnungen aus dem BLS-4.0-Glossar ergänzt.`) + setNotice(`${res.updated ?? 0} Bezeichnungen/Gruppen aus dem BLS-4.0-Glossar ergänzt.`) } catch (e) { setError(e.message) } finally { @@ -76,26 +126,9 @@ export default function AdminFoodAttributesPage() {

Stoffe & Attribute

- Jeder Stoff hat einen festen Schlüssel (BLS/EuroFIR, z. B. ENERCC). - Die deutsche Bezeichnung ist nur Anzeige — sie darf hier geändert werden, der Schlüssel nicht. + Gruppiert nach BLS-Nährstoffgruppen. Innerhalb der Gruppe: zuerst die vier Makros für die Haupttabelle, dann Name. + Schlüssel bleibt fest, Bezeichnung und Gruppe sind änderbar.

-

- Weg in die Makro-Haupttabelle: Am Lebensmittel liegen Werte je 100 g in - {' '}food_attribute_values. Beim Gegessen-Eintrag zählt die Menge in Gramm. - Nur vier Schlüssel werden nach nutrition_log geschrieben: - Energie ENERCC → kcal, Protein PROT625 → protein_g, - Fett FAT → fat_g, Kohlenhydrate CHO → carbs_g - (Wert × Menge / 100). Alle anderen Stoffe (EPA, Ballaststoffe, Vitamine, …) landen in - {' '}nutrition_daily_nutrients, nicht in den vier Makro-Spalten. - Fehlt einer der vier Werte am zugeordneten Lebensmittel, gilt die Zeile als ungemappt - und es bleiben die FDDB-Makros des Tages. -

- {macros.length > 0 && ( -

- Makros in dieser Liste:{' '} - {macros.map((m) => `${m.attr_key} (${m.name_de || 'ohne Name'})`).join(' · ')} -

- )} {error &&

{error}

} {notice &&

{notice}

} @@ -126,65 +159,98 @@ export default function AdminFoodAttributesPage() {
- {FILTERS.map((f) => ( + + {GROUP_OPTIONS.filter((g) => groupCounts[g.id]).map((g) => ( ))} - + + +
setQ(e.target.value)} /> -

{shown.length} von {rows.length} Stoffen

+

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

- {shown.map((r) => ( -
- {editId === r.id ? ( -
-
- Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''} + {groups.map((g) => { + const opened = open.has(g.key) + return ( +
+ + {opened && g.items.map((r) => ( +
+ {editId === r.id ? ( +
+
+ Schlüssel {r.attr_key} · {r.origin}{r.is_macro ? ' · Makro → nutrition_log' : ''} +
+
+ setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" /> + setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" /> + + + +
+
+ ) : ( +
+
+
+ {r.needs_label ? r.attr_key : (r.name_de || r.attr_key)} + {r.is_macro ? Makro : null} +
+
+ {r.attr_key} + {r.needs_label ? ' · Bezeichnung fehlt' : ''} + {r.unit ? ` · ${r.unit}` : ''} +
+
+ +
+ )}
-
- setDraft({ ...draft, name_de: e.target.value })} placeholder="Name DE" /> - setDraft({ ...draft, unit: e.target.value })} placeholder="Einheit" /> - setDraft({ ...draft, category: e.target.value })} placeholder="Gruppe" /> - - -
-
- ) : ( -
-
-
- {r.needs_label ? r.attr_key : (r.name_de || r.attr_key)} - {r.is_macro ? Makro : null} -
-
- {r.attr_key} - {r.needs_label ? ' · Bezeichnung fehlt (nur Schlüssel)' : ''} - {r.unit ? ` · ${r.unit}` : ''} - {` · ${r.data_type}`} - {` · ${r.origin}`} -
-
- -
- )} -
- ))} + ))} + + ) + })}
) }