Compare commits

..

15 Commits

Author SHA1 Message Date
fa542e0fb9 feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 18:23:01 +02:00
a7e5511039 feat: Rezepte beim Zuordnen anlegen und offene Zutaten listen
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s
Lebensmittel oder Rezept wählbar; gemappte Zutaten werden übernommen, offene erscheinen in der Liste. Dazu Fettgehalt-Suche, EPA-Stoffe, Rezept-CRUD und Wechsel bestehender Zuordnungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 16:59:24 +02:00
132a364a3a feat: Zuordnen ohne Freeze und zuerst die letzten Wochen
All checks were successful
Deploy Development / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s
Katalogsuche und Bestätigen blockieren die UI nicht mehr; offene Namen starten bei den aktuellen Tagebucheinträgen, alter Ballast bleibt unter Alle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 16:14:24 +02:00
c1873a47b1 fix: Zuordnungen zuverlässig speichern und Mengen-Varianten zusammenfassen
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Mapping bleibt erhalten, auch wenn der Nährwert-Rebuild länger dauert. Offene Liste führt 1 ml/2 ml Olivenöl als ein Lebensmittel. Dazu JSON-Sicherung, eigener Katalogeintrag und Gramm-pro-Einheit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 15:49:14 +02:00
86f15e6957 feat: FDDB-Listen auflösen und Katalog nach Name suchen
All checks were successful
Deploy Development / deploy (push) Successful in 1m9s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Tagebuchzeilen eigener Rezepte werden über den Listen-Import in Zutaten zerlegt. Zuordnen erfolgt im Namens-Popup statt per BLS-Code.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 15:31:20 +02:00
4b1be3019d fix: FDDB-Zeilen im CSV-Import speichern und in Ernährung zeigen
All checks were successful
Deploy Development / deploy (push) Successful in 1m12s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
Bezeichnung/Menge auch ohne Vorlagen-Mapping, Zuordnen-Hinweis, Tagesliste mit Lebensmitteln.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 15:11:37 +02:00
a07a668de2 fix: BLS-Datenimport als Hintergrundjob gegen HTTP 504
All checks were successful
Deploy Development / deploy (push) Successful in 1m10s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 24s
Prüfung und Apply laufen serverseitig, die UI pollt nur noch den Status.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:57:13 +02:00
8e964509d7 fix: BLS-Import-UI und Timeout beim Daten-Apply
All checks were successful
Deploy Development / deploy (push) Successful in 1m30s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
Datei-Button volle Breite, Import als Aktionsbutton, Batch-Upsert statt Einzel-INSERTs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:49:47 +02:00
7ccae33844 fix: BLS-Import prüft bei Dateiauswahl, Import nur per Schalter
All checks were successful
Deploy Development / deploy (push) Successful in 1m28s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
Eine Datei pro Typ, sofortiger Dry-Run, Schalter startet den Write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:43:59 +02:00
9bb89231d0 fix: BLS-Import mit einer Datei plus Prüfen/Anwenden
All checks were successful
Deploy Development / deploy (push) Successful in 1m16s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Zwei unbeschriftete File-Inputs pro Dateityp wirkten wie doppelte Auswahl.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:41:42 +02:00
c90dabc032 fix: doppelte BLS-Admin-Imports in App.jsx entfernen
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
Vite-Build auf Dev war wegen redeklarierter Seitenimporte fehlgeschlagen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:37:54 +02:00
919c77fcf8 feat: BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch (#106)
Some checks failed
Deploy Development / deploy (push) Failing after 52s
Build Test / pytest-backend (push) Successful in 3s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Failing after 9s
Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy.
Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 14:35:57 +02:00
72d101415f fix: run activity import enforcement tests without pytest-asyncio
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
CI container only installs pytest; use asyncio.run() for async route handlers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:46:30 +02:00
ae7d0ac1d9 test: stabilize Playwright smoke and issue-audit against dev
Some checks failed
Deploy Development / deploy (push) Successful in 1m11s
Build Test / pytest-backend (push) Failing after 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Fix nav selectors (Erfassen), shared auth via global-setup to avoid login rate limits, and desktop sidebar viewport handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 16:37:36 +02:00
79ec249bc3 feat: feature enforcement imports (#37/#38) and Universal CSV validation (#71)
Some checks failed
Deploy Development / deploy (push) Successful in 1m9s
Build Test / pytest-backend (push) Failing after 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 24s
Activity and nutrition legacy CSV imports enforce tier limits with UsageBadge UI; Universal CSV gets mapping validation on copy/import, format-check parity, and structured error_details.

version: 0.9u
module: activity 1.2.1, nutrition 1.0.3, csv_import 0.4.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:59:12 +02:00
88 changed files with 7799 additions and 184 deletions

View File

@ -1,6 +1,6 @@
# Gitea Issues Landkarte (Auswertung) # Gitea Issues Landkarte (Auswertung)
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #76). **Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #75, #76, #106, 2026-09-12).
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues **URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung. Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung.
@ -82,6 +82,14 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
|---|--------| |---|--------|
| 37 | Feature-Enforcement für Activity CSV-Import | | 37 | Feature-Enforcement für Activity CSV-Import |
| 38 | Feature-Enforcement für Nutrition CSV-Import UI | | 38 | Feature-Enforcement für Nutrition CSV-Import UI |
| 71 | Universal CSV Import: Dry-Run, Mapping-Validierung, Fehler-Hints |
### Ernährung / BLS
| # | Titel |
|---|--------|
| 75 | Ernährung: Zucker/Ballaststoffe, Lebensmittelqualität, Timing (Folge nach #106) |
| 106 | BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch |
### Qualität / Sonstiges ### Qualität / Sonstiges

View File

@ -56,6 +56,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) | | Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt | | Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` | | Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
| BLS / Lebensmittel | `functional/BLS_FOOD_REFERENCE.md`, `technical/BLS_FOOD_REFERENCE.md` | Migration 062, `backend/bls/`, `data_layer/food_mapping.py` |
| **Designprinzipien (Produktfamilie)** | **`jinkendo-foundation/design-principles/README.md`** | Querschnittsmuster; Foundation für Schwester-Apps | | **Designprinzipien (Produktfamilie)** | **`jinkendo-foundation/design-principles/README.md`** | Querschnittsmuster; Foundation für Schwester-Apps |
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung | | Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement | | Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |

View File

@ -0,0 +1,55 @@
# BLS-Lebensmittelreferenz und FDDB-Mapping
**Stand:** 2026-09-12 · **Status:** Phase 1 (Umsetzung)
## WAS
Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class.
## Zuordnung (UX)
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“.
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
**Mitai speichert nur Roh-Kombinationen** — einen Namen für eine Zutatenliste ohne Kochvorgang (Müsli, Bowl, Smoothie). Summe der Zutatengramm = gegessene Gramm. Tabelle bleibt `food_recipes` (kein Rename).
**Gekochte Familienrezepte** liegen in **Tandoor**. Kein zweites Rezeptbuch in Mitai. Kochschwund (Wasserverlust beim Köcheln) darf die Rohzutaten nicht 1:1 auf die gegessene Menge skalieren.
Spätere Ausbeute (nicht in Phase 1):
- `ingredients_g` = Summe der auf Gramm aufgelösten Zutaten
- `cooked_yield_g` = gewogenes Fertiggewicht nach dem Kochen
- `yield_factor = cooked_yield_g / ingredients_g` (oft 0,50,9 bei langem Köcheln)
- Nährwerte **pro 100 g fertig** = `Summe(Zutat_Nährwerte) / cooked_yield_g × 100`
- Gegessen: `portion_g / 100 × Werte_pro_100g`
Ohne Fertiggewicht: nicht raten — FDDB-Makros oder Marke „unvollständig“. Für Listen gilt implizit `cooked_yield_g = ingredients_g` (Faktor 1). Tandoor liefert Zutaten und Schritte; die Ausbeute und die **pro-100-g**-Rechnung führt Mitai. Importiertes Gericht wird ein Katalogeintrag (`catalog_kind` z. B. `recipe_cooked`), keine zweite Wertetabelle.
## FDDB-Listen
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Listenname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Liste verknüpft → **Zutaten** zuordnen, nicht die Liste als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein.
## Fachliche Regeln
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
## Manuelle Lebensmittel / Supplemente
Eigene Einträge (z. B. Norsan Omega-3 + EPA) speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Admin → Stoffe & Attribute.
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.
## Später
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
**Tandoor:** Familienrezepte bleiben dort. Später: Import/Verknüpfung, Zutaten gegen Mitai-Mappings, `cooked_yield_g`, Katalogeintrag Fertiggericht pro 100 g. Kein Connector in Phase 1.

View File

@ -197,7 +197,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md). **Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP). **Aktuell:** Phase B abgeschlossen (Consumer-Audit 2026-04-16). **Phase C** Schreibpfad entschlackt (Sync abgestellt, Orchestrator als SSoT; Review 2026-04-16 + Regression `test_activity_insert_sql.py`). Nächster Schritt: **Phase D** (Composite-MVP).
--- ---

View File

@ -0,0 +1,44 @@
# BLS Food Reference technische Spec
**Stand:** 2026-09-12 · Migration **062** + **063**
## Tabellen
- `food_attributes` — dynamischer Stoff-/Merkmalskatalog (`attr_key`, `data_type`, `origin`)
- `food_catalog` — Lebensmittel (`bls_code` UNIQUE bei official_bls; manuell ohne BLS-Code)
- `food_attribute_values` — typisiertes EAV
- `food_name_mappings` — FDDB-Name → `food_id` (User / global)
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
- `nutrition_day_marks``fasting` | `incomplete`
- `food_recipes` / `food_recipe_ingredients` — Mitai-Listen/Kombinationen (Rohmischung, kein Kochschwund); `nutrition_items.recipe_id`. Kein Tabellen-Rename. Gekochte Gerichte später Tandoor + `cooked_yield_g`, nicht hier.
## Layer 1
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Listen-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen). Diese Skala gilt nur für Roh-Kombinationen, nicht für gekochte Gerichte.
- `bls/recipe_parser.py``produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
## Import
BLS: Admin-Upload Components + Daten-XLSX (`backend/bls/parser.py`). Upsert über `bls_code` / `attr_key`, nie Delete+Insert offizieller Zeilen.
FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / Bestätigung.
## Router
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings
- 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/food-mappings` — Admin-CRUD
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat). Tandoor-Import und `cooked_yield_g` später, kein Connector in Phase 1.
- 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}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80)
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar)
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).

View File

@ -46,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
--- ---
## 4. Bekannte Einschränkungen (Follow-up in Gitea) ## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71)
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import. - Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`).
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71). - **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern).
- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import.
- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`).
--- ---

View File

@ -0,0 +1,10 @@
**Automatischer Audit (Code + Playwright, 2026-07-23)**
Ziele-System ist im Repo umgesetzt:
- Backend: `backend/routers/goals.py`, Focus Areas, Goal Types, Progress
- Frontend: `frontend/src/pages/GoalsPage.jsx`, Nav `/goals` in `config/appNav.js`
- Spec: `docs/issues/issue-50-phase-0a-goal-system.md`
Playwright auf dev.mitai.jinkendo.de: `/goals` rendert ohne Fehler.
Verbleibende KI-Goal-Erweiterungen ggf. als neues Issue.

View File

@ -0,0 +1,9 @@
**Automatischer Audit (Code, 2026-07-23)**
Deprecated Tabelle `subscriptions` ist aus dem aktiven Schema entfernt:
- Kein Treffer in `backend/schema.sql`, `backend/migrations/`, Backend-Python
- Membership nutzt `access_grants`, `tier_limits`, etc. (Migration v9c)
Doku-Hinweis deprecated: `.claude/docs/technical/DATABASE.md`
Optional Prod-Check: `\dt subscriptions` — sollte leer/nicht vorhanden sein.

View File

@ -0,0 +1,8 @@
**Automatischer Audit (Code, 2026-07-23)**
Bug behoben: JSONB-Insert für `abilities`/`profile` nutzt `psycopg2.extras.Json()`.
- Fix-Commit: `2977050` — wrap abilities dict with Json() for JSONB insert
- Aktuell: `backend/routers/admin_training_types.py` (`create_training_type`, Zeilen 111112)
Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx`

View File

@ -0,0 +1,23 @@
**Umsetzung 2026-07-23 (nach Architektur-Abgleich)**
## Architektur-Check vor Implementierung
- **Phase C (Schreibpfad):** laut `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` bereits erledigt (Sync abgestellt, Orchestrator als SSoT, keine Aufrufer von `sync_column_backed_session_metrics`). Keine Doppel-Implementierung.
- **Feature-ID:** Issue-Vorgabe `activity_entries` (nicht separates `activity_import`) — konsistent mit `create_activity` und Universal-CSV-Modul-Check in `csv_import.py`.
- **Legacy-Endpoint bleibt:** Frontend nutzt weiterhin `POST /api/activity/import-csv` (`ActivityPage` Apple-Health-Panel); Universal-Pfad ist parallel (ARCH §8.2).
## Änderungen
**Backend** (`routers/activity.py`):
- `check_feature_access(pid, "activity_entries")` vor Legacy-Import
- HTTP 403 bei Limit (wie `nutrition/import-csv`)
- `increment_feature_usage` pro neu eingefügter Zeile (`inserted`)
**Frontend** (`ActivityPage.jsx` ImportPanel):
- `UsageBadge` am Import-Titel
- Drop-Zone deaktiviert bei Limit
- Usage-Reload nach Import
**Tests:** `tests/test_activity_import_feature_enforcement.py` (403 + Increment)
Regression INSERT-SQL: `tests/test_activity_insert_sql.py` (grün)

View File

@ -0,0 +1,22 @@
**Umsetzung 2026-07-23 (UI-Teil; Backend war bereits erledigt)**
## Ist-Zustand vorher
- Backend: `POST /api/nutrition/import-csv` mit `check_feature_access('nutrition_entries')` und HTTP 403 — **bereits implementiert**
- Frontend: FDDB-Import ohne Usage-Anzeige, Drop-Zone blieb bei Limit klickbar
## Änderungen
**Frontend** (`NutritionPage.jsx`, ImportPanel):
- `getFeatureUsage()``nutrition_entries`
- `UsageBadge` am Import-Titel
- Drop-Zone und Paste-Import deaktiviert bei Limit
- Usage-Reload nach erfolgreichem Import
Analog zu Activity #37 (geschlossen 2026-07-23).
**Playwright:** `tests/issue-audit.spec.js` — Badge am FDDB-Panel
## Hinweis
Universal-CSV (`/api/csv-import/import`) prüft zusätzlich `data_import` — Legacy FDDB-Pfad folgt dem Nutrition-Muster (nur `nutrition_entries`), konsistent mit Architektur §8.2.

View File

@ -0,0 +1,7 @@
**Automatischer Audit (Code + Playwright, 2026-07-23)**
Logout-Button im Mobile-Header neben Avatar implementiert:
- `frontend/src/App.jsx` — Button mit `title="Abmelden"`, Icon `LogOut`
- Zusätzlich Desktop: `frontend/src/components/DesktopSidebar.jsx`
Playwright auf dev.mitai.jinkendo.de: Button sichtbar und klickbar.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#42** (Enhanced Debug/Prompt Analysis UI).
Teilumsetzung existiert bereits (`Analysis.jsx` Experten-Modus, `WorkflowDebugPanel`). Weiterverfolgung unter #42.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten).
Bitte weiterverfolgen unter #55.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
Bitte weiterverfolgen unter #56.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
Bitte weiterverfolgen unter #56.

View File

@ -0,0 +1,9 @@
**Automatischer Audit (Code + Playwright, 2026-07-23)**
Nutzer-konfigurierbares Dashboard umgesetzt:
- Migration `039_dashboard_layout.sql``profiles.dashboard_layout`
- API: `backend/routers/app_dashboard.py` — GET/PUT/reset `/api/app/dashboard-layout`
- Frontend: `DashboardConfigurePage.jsx`, Widget-Registry `registerDashboardWidgets.js`
- Tests: `test_dashboard_layout_schema.py`, `test_widget_catalog.py`
Playwright: `/settings/dashboard-layout` erreichbar, Widget/Layout-UI sichtbar.

View File

@ -0,0 +1,9 @@
**Automatischer Audit (Code, 2026-07-23)**
Feature-Gate-Zuordnung aus Admin/DB implementiert:
- Migration `041_widget_feature_requirements.sql`
- Logik: `backend/widget_feature_requirements_db.py`, `dashboard_widget_entitlements.py`
- Admin-UI: `AdminWidgetFeatureAssignmentsPage.jsx`, Route `/admin/widget-features`
- Changelog: `backend/version.py` (Admin Widgets × Features)
Hardcodierter Katalog wird durch DB-Overrides ergänzt (Hybrid-Modell).

View File

@ -0,0 +1,20 @@
**Implementierung abgeschlossen (2026-07-24)**
### 1. Admin „Format prüfen“ inkl. `import_row_processing`
- `AdminCsvTemplateEditorPage`: `resolveEditorImportRowProcessing()` — dieselbe Spec wie beim Speichern
- Dry-Run/Format-Check nutzt konsistente Vorlage inkl. Aggregation (`group_by` / `aggregates`)
### 2. Profil-Mappings validieren
- `csv_import.py`: `_validate_mapping_config` / `_ensure_mapping_valid` bei **Copy** und **Import**
- Neuer Endpoint: `GET /api/csv/mappings/{id}/validate`
- `api.js`: `validateCsvMapping()`; `formatFastApiDetail` für verschachtelte Validierungsfehler
### 3. Nutzer-UI: strukturierte Fehler
- Neue Komponente `CsvImportErrorDetails.jsx` (Zeile, code, hint)
- `UniversalCsvImportPage` zeigt `error_details` statt JSON-Dump
### Tests & Doku
- `backend/tests/test_csv_mapping_validation.py` — pytest grün
- Agent-Guide: `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §4 aktualisiert
**Betroffene Dateien:** `backend/routers/csv_import.py`, `frontend/src/pages/AdminCsvTemplateEditorPage.jsx`, `frontend/src/pages/UniversalCsvImportPage.jsx`, `frontend/src/components/CsvImportErrorDetails.jsx`, `frontend/src/utils/api.js`

3
.gitignore vendored
View File

@ -58,6 +58,9 @@ coverage/
# Temp # Temp
tmp/ tmp/
*.tmp *.tmp
test-results/
screenshots/
tests/.auth/
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md) # Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
.claude/** .claude/**

View File

@ -117,6 +117,19 @@ frontend/src/
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind. - **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert). - **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
### Updates (12.09.2026 - BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch)
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Kombinationen; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
- **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.
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g.
- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. Gekochte Familienrezepte: Tandoor + später `cooked_yield_g` / Fertiggericht pro 100 g. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry) ### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75 - **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75

1
backend/bls/__init__.py Normal file
View File

@ -0,0 +1 @@
"""BLS 4.0 ingest (official MRI XLSX)."""

View File

@ -0,0 +1,142 @@
"""Apply parsed BLS 4.0 data: upsert attributes and foods, never delete official rows."""
from __future__ import annotations
from typing import Any
from psycopg2.extras import execute_values
VALUE_PAGE = 2000
FOOD_PAGE = 500
def should_persist_value(val: dict[str, Any]) -> bool:
if val.get("is_trace"):
return True
return val.get("value_num") is not None
def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]:
inserted = updated = 0
for a in attributes:
cur.execute(
"""
INSERT INTO food_attributes
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, 'official_bls', %s, NOW())
ON CONFLICT (attr_key) DO UPDATE SET
name_de = EXCLUDED.name_de,
name_en = COALESCE(EXCLUDED.name_en, food_attributes.name_en),
unit = COALESCE(EXCLUDED.unit, food_attributes.unit),
category = COALESCE(EXCLUDED.category, food_attributes.category),
sort_order = EXCLUDED.sort_order,
updated_at = NOW()
WHERE food_attributes.origin = 'official_bls'
RETURNING (xmax = 0) AS inserted
""",
(
a["attr_key"], a["name_de"], a.get("name_en"), a.get("unit"),
a.get("category"), a.get("data_type") or "num_per_100g",
a.get("sort_order") or 0,
),
)
row = cur.fetchone()
if row and row.get("inserted"):
inserted += 1
else:
updated += 1
return {"inserted": inserted, "updated": updated, "total": len(attributes)}
def upsert_foods(cur, foods: list[dict[str, Any]], bls_version: str = "4.0") -> dict[str, int]:
cur.execute("SELECT attr_key, id FROM food_attributes")
attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()}
codes = [f["bls_code"] for f in foods if f.get("bls_code")]
existing: set[str] = set()
if codes:
cur.execute("SELECT bls_code FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
existing = {r["bls_code"] for r in cur.fetchall()}
inserted = sum(1 for c in codes if c not in existing)
updated = len(codes) - inserted
food_rows = [
(
f["bls_code"], f["name_de"], f.get("name_en"), f.get("food_group"),
"official_bls", bls_version, "bls_4.0",
)
for f in foods if f.get("bls_code")
]
if food_rows:
execute_values(
cur,
"""
INSERT INTO food_catalog
(bls_code, name_de, name_en, food_group, catalog_kind, bls_version, source)
VALUES %s
ON CONFLICT (bls_code) DO UPDATE SET
name_de = EXCLUDED.name_de,
name_en = EXCLUDED.name_en,
food_group = EXCLUDED.food_group,
bls_version = EXCLUDED.bls_version,
catalog_kind = 'official_bls',
source = 'bls_4.0',
is_active = true,
updated_at = NOW()
WHERE food_catalog.catalog_kind = 'official_bls'
""",
food_rows,
page_size=FOOD_PAGE,
)
food_ids: dict[str, Any] = {}
if codes:
cur.execute("SELECT bls_code, id FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
food_ids = {r["bls_code"]: r["id"] for r in cur.fetchall()}
value_rows = []
for f in foods:
food_id = food_ids.get(f.get("bls_code"))
if not food_id:
continue
for val in f.get("values") or []:
if not should_persist_value(val):
continue
aid = attr_ids.get(val["attr_key"])
if not aid:
continue
value_rows.append((
food_id,
aid,
None if val.get("is_trace") else val.get("value_num"),
bool(val.get("is_trace")),
val.get("origin_code"),
val.get("reference_text"),
))
if value_rows:
execute_values(
cur,
"""
INSERT INTO food_attribute_values
(food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
VALUES %s
ON CONFLICT (food_id, attribute_id) DO UPDATE SET
value_num = EXCLUDED.value_num,
is_trace = EXCLUDED.is_trace,
origin_code = EXCLUDED.origin_code,
reference_text = EXCLUDED.reference_text,
updated_at = NOW()
""",
value_rows,
template="(%s, %s, %s, %s, %s, %s, NOW())",
page_size=VALUE_PAGE,
)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index()
return {
"inserted": inserted,
"updated": updated,
"foods_inserted": inserted,
"foods_updated": updated,
"values_written": len(value_rows),
"foods_total": len(foods),
}

171
backend/bls/jobs.py Normal file
View File

@ -0,0 +1,171 @@
"""In-memory BLS import jobs so HTTP requests stay short (avoid proxy 504)."""
from __future__ import annotations
import threading
import time
import uuid
from typing import Any
from bls.import_service import upsert_attributes, upsert_foods
from bls.parser import parse_components_xlsx, parse_foods_xlsx
from db import get_cursor, get_db
FOOD_CHUNK = 300
JOB_TTL_S = 2 * 60 * 60
MAX_JOBS = 12
_lock = threading.Lock()
_jobs: dict[str, dict[str, Any]] = {}
def _public(job: dict[str, Any]) -> dict[str, Any]:
return {
"id": job["id"],
"kind": job["kind"],
"status": job["status"],
"check": job.get("check"),
"progress": job.get("progress"),
"result": job.get("result"),
"error": job.get("error"),
}
def _purge_locked(now: float) -> None:
stale = [jid for jid, job in _jobs.items() if now - job["created_at"] > JOB_TTL_S]
for jid in stale:
_jobs.pop(jid, None)
if len(_jobs) <= MAX_JOBS:
return
oldest = sorted(_jobs.values(), key=lambda j: j["created_at"])
for job in oldest[: max(0, len(_jobs) - MAX_JOBS)]:
if job["status"] in ("checking", "applying"):
continue
_jobs.pop(job["id"], None)
def get_job(job_id: str) -> dict[str, Any] | None:
with _lock:
job = _jobs.get(job_id)
return _public(job) if job else None
def create_and_check(kind: str, raw: bytes) -> str:
if kind not in ("components", "foods"):
raise ValueError("kind muss components oder foods sein")
job_id = str(uuid.uuid4())
now = time.time()
with _lock:
_purge_locked(now)
_jobs[job_id] = {
"id": job_id,
"kind": kind,
"status": "checking",
"created_at": now,
"raw": raw,
"parsed": None,
"check": None,
"progress": None,
"result": None,
"error": None,
}
threading.Thread(target=_run_check, args=(job_id,), daemon=True).start()
return job_id
def start_apply(job_id: str) -> None:
with _lock:
job = _jobs.get(job_id)
if job is None:
raise KeyError(job_id)
if job["status"] != "checked":
raise ValueError("Zuerst die Prüfung abwarten")
if job.get("parsed") is None:
raise ValueError("Geparste Datei nicht mehr vorhanden — Datei neu wählen")
job["status"] = "applying"
job["error"] = None
job["progress"] = {"current": 0, "total": 0}
threading.Thread(target=_run_apply, args=(job_id,), daemon=True).start()
def _update(job_id: str, **fields: Any) -> None:
with _lock:
job = _jobs.get(job_id)
if not job:
return
job.update(fields)
def _run_check(job_id: str) -> None:
with _lock:
job = _jobs.get(job_id)
if not job:
return
kind = job["kind"]
raw = job["raw"]
try:
if kind == "components":
attrs = parse_components_xlsx(raw)
_update(
job_id,
parsed=attrs,
raw=None,
check={"attributes": len(attrs)},
status="checked",
)
return
parsed = parse_foods_xlsx(raw)
_update(
job_id,
parsed=parsed,
raw=None,
check={
"foods": len(parsed["foods"]),
"attribute_columns": len(parsed["attribute_headers"]),
},
status="checked",
)
except Exception as e:
_update(job_id, status="error", error=str(e), raw=None, parsed=None)
def _run_apply(job_id: str) -> None:
with _lock:
job = _jobs.get(job_id)
if not job:
return
kind = job["kind"]
parsed = job["parsed"]
try:
if kind == "components":
with get_db() as conn:
stats = upsert_attributes(get_cursor(conn), parsed)
_update(job_id, status="done", result=stats, parsed=None, progress={"current": stats.get("total", 0), "total": stats.get("total", 0)})
return
foods = parsed["foods"]
total = len(foods)
inserted = updated = values_written = 0
_update(job_id, progress={"current": 0, "total": total})
for i in range(0, total, FOOD_CHUNK):
chunk = foods[i : i + FOOD_CHUNK]
with get_db() as conn:
stats = upsert_foods(get_cursor(conn), chunk)
inserted += stats.get("inserted", 0)
updated += stats.get("updated", 0)
values_written += stats.get("values_written", 0)
_update(job_id, progress={"current": min(i + FOOD_CHUNK, total), "total": total})
_update(
job_id,
status="done",
parsed=None,
result={
"inserted": inserted,
"updated": updated,
"foods_inserted": inserted,
"foods_updated": updated,
"values_written": values_written,
"foods_total": total,
},
)
except Exception as e:
_update(job_id, status="error", error=str(e))

158
backend/bls/parser.py Normal file
View File

@ -0,0 +1,158 @@
"""Parse official BLS 4.0 XLSX files without a hardcoded nutrient code list."""
from __future__ import annotations
import re
from io import BytesIO
from typing import Any
from openpyxl import load_workbook
# Header like: "ENERCJ Energie (Kilojoule) [kJ/100g]"
ATTR_HEADER_RE = re.compile(
r"^([A-Z][A-Z0-9:]{1,20})\s+(.+?)(?:\s*\[([^\]]+)\])?\s*$"
)
def _cell(v: Any) -> str:
if v is None:
return ""
return str(v).strip()
def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
wb = load_workbook(filename=BytesIO(data), read_only=True, data_only=True)
ws = wb.active
rows = ws.iter_rows(values_only=True)
header = [_cell(c) for c in next(rows)]
idx = {h.lower(): i for i, h in enumerate(header) if h}
def col(*names: str) -> int | None:
for n in names:
if n.lower() in idx:
return idx[n.lower()]
for key, i in idx.items():
for n in names:
if n.lower() in key:
return i
return None
i_code = col("code", "schlüssel", "schluessel", "attr_key", "komponente")
i_de = col("name_de", "deutsch", "bezeichnung_de", "name de")
i_en = col("name_en", "english", "bezeichnung_en", "name en")
i_unit = col("unit", "einheit")
i_cat = col("category", "kategorie", "gruppe")
if i_code is None:
i_code = 0
if i_de is None:
i_de = 1 if len(header) > 1 else 0
out = []
sort_order = 0
for raw in rows:
if not raw:
continue
code = _cell(raw[i_code] if i_code < len(raw) else "")
if not code or code.lower() in ("code", "schlüssel", "schluessel"):
continue
name_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") or code
name_en = _cell(raw[i_en] if i_en is not None and i_en < len(raw) else "") or None
unit = _cell(raw[i_unit] if i_unit is not None and i_unit < len(raw) else "") or None
category = _cell(raw[i_cat] if i_cat is not None and i_cat < len(raw) else "") or None
sort_order += 1
out.append({
"attr_key": code,
"name_de": name_de,
"name_en": name_en,
"unit": unit,
"category": category,
"data_type": "num_per_100g",
"origin": "official_bls",
"sort_order": sort_order,
})
wb.close()
return out
def _parse_value_header(title: str) -> tuple[str | None, str, str | None]:
t = title.strip()
m = ATTR_HEADER_RE.match(t)
if m:
return m.group(1), m.group(2).strip(), m.group(3)
# Fallback: first token
parts = t.split()
if parts and re.match(r"^[A-Z][A-Z0-9:]{1,20}$", parts[0]):
return parts[0], " ".join(parts[1:]) or parts[0], None
return None, t, None
def parse_foods_xlsx(data: bytes) -> dict[str, Any]:
wb = load_workbook(filename=BytesIO(data), read_only=True, data_only=True)
ws = wb.active
rows = ws.iter_rows(values_only=True)
header = [_cell(c) for c in next(rows)]
if len(header) < 3:
wb.close()
raise ValueError("BLS-Datendatei: erwartet mindestens BLS-Code, Name DE, Name EN")
triples: list[dict[str, Any]] = []
i = 3
while i < len(header):
code, name_de, unit = _parse_value_header(header[i])
origin_i = i + 1 if i + 1 < len(header) else None
ref_i = i + 2 if i + 2 < len(header) else None
triples.append({
"attr_key": code or f"COL{i}",
"name_de": name_de,
"unit": unit,
"value_col": i,
"origin_col": origin_i,
"ref_col": ref_i,
})
i += 3 if (origin_i is not None and ref_i is not None) else 1
foods = []
for raw in rows:
if not raw:
continue
code = _cell(raw[0] if len(raw) else "")
if not code:
continue
name_de = _cell(raw[1] if len(raw) > 1 else "") or code
name_en = _cell(raw[2] if len(raw) > 2 else "") or None
values = []
for t in triples:
vc = t["value_col"]
raw_v = raw[vc] if vc < len(raw) else None
is_trace = False
num = None
if raw_v is None or raw_v == "" or raw_v == "-":
num = None
elif str(raw_v).strip().upper() in ("TR", "TRACE", "SPUREN"):
is_trace = True
else:
try:
num = float(str(raw_v).replace(",", "."))
except (TypeError, ValueError):
num = None
origin = ""
if t["origin_col"] is not None and t["origin_col"] < len(raw):
origin = _cell(raw[t["origin_col"]])
ref = ""
if t["ref_col"] is not None and t["ref_col"] < len(raw):
ref = _cell(raw[t["ref_col"]])
values.append({
"attr_key": t["attr_key"],
"value_num": num,
"is_trace": is_trace,
"origin_code": origin or None,
"reference_text": ref or None,
})
foods.append({
"bls_code": code,
"name_de": name_de,
"name_en": name_en,
"food_group": code[0] if code else None,
"values": values,
})
wb.close()
return {"attribute_headers": triples, "foods": foods}

View File

@ -0,0 +1,79 @@
"""Parse FDDB lists_*.csv (name;…;produkte) into recipe + ingredients."""
from __future__ import annotations
import csv
import io
import re
from typing import Any
from data_layer.food_mapping import normalize_food_name, parse_quantity_g
ING_RE = re.compile(
r"(?P<qty>\d+(?:[.,]\d+)?)\s*(?P<unit>g|kg|ml|l)\b\s*(?P<name>.+?)"
r"(?=,\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l)\b|$)",
re.IGNORECASE | re.DOTALL,
)
def parse_fddb_produkte(text: str) -> list[dict[str, Any]]:
raw = (text or "").strip().strip('"')
if not raw:
return []
out: list[dict[str, Any]] = []
for i, m in enumerate(ING_RE.finditer(raw)):
qty = float(m.group("qty").replace(",", "."))
unit = m.group("unit").lower()
name = re.sub(r"\s+", " ", m.group("name")).strip(" ,;")
if not name:
continue
grams = qty
if unit == "kg":
grams = qty * 1000.0
elif unit == "l":
grams = qty * 1000.0
elif unit == "ml":
grams = qty
out.append({
"source_name_raw": name,
"source_name_normalized": normalize_food_name(name),
"quantity_raw": f"{m.group('qty').replace(',', '.')} {unit}",
"quantity_g": round(grams, 3),
"sort_order": i,
})
return out
def parse_fddb_lists_csv(text: str) -> list[dict[str, Any]]:
if text.startswith("\ufeff"):
text = text[1:]
reader = csv.DictReader(io.StringIO(text), delimiter=";")
recipes = []
for row in reader:
name = (row.get("name") or "").strip().strip('"')
if not name:
continue
try:
portions = float(str(row.get("anzahl_portionen") or "1").replace(",", "."))
except ValueError:
portions = 1.0
if portions <= 0:
portions = 1.0
ingredients = parse_fddb_produkte(row.get("produkte") or "")
if not ingredients:
leftover = (row.get("produkte") or "").strip().strip('"')
if leftover:
ingredients = [{
"source_name_raw": leftover,
"source_name_normalized": normalize_food_name(leftover),
"quantity_raw": None,
"quantity_g": parse_quantity_g(leftover),
"sort_order": 0,
}]
recipes.append({
"name_raw": name,
"name_normalized": normalize_food_name(name),
"portions": portions,
"description": (row.get("beschreibung") or "").strip() or None,
"ingredients": ingredients,
})
return recipes

View File

@ -11,7 +11,31 @@ from typing import Any
import logging import logging
from csv_parser.core import iter_csv_dict_rows, resolve_effective_csv_delimiter from csv_parser.core import iter_csv_dict_rows, normalize_header_for_signature, resolve_effective_csv_delimiter
_FOOD_NAME_HEADERS = frozenset({
"bezeichnung", "lebensmittel", "food", "food_name", "name", "gericht",
})
_QTY_HEADERS = frozenset({
"menge", "quantity", "quantity_raw", "portion", "amount", "gramm",
})
def guess_nutrition_item_fields(csv_row: dict[str, Any] | None) -> tuple[str | None, str | None]:
"""FDDB-Spalten auch ohne Vorlagen-Mapping auf food_name / menge erkennen."""
name = qty = None
for key, val in (csv_row or {}).items():
if val is None:
continue
text = str(val).strip().strip('"')
if not text:
continue
norm = normalize_header_for_signature(str(key))
if name is None and (norm in _FOOD_NAME_HEADERS or "bezeichnung" in norm):
name = text
if qty is None and (norm in _QTY_HEADERS or norm.startswith("menge")):
qty = text
return name, qty
from csv_parser.import_row_processing import ( from csv_parser.import_row_processing import (
aggregate_mapped_rows, aggregate_mapped_rows,
resolve_import_row_processing, resolve_import_row_processing,
@ -181,6 +205,8 @@ def run_universal_csv_import(
"error_details": error_details[:50], "error_details": error_details[:50],
"new_entries": stats.get("new_entries", stats.get("inserted", 0)), "new_entries": stats.get("new_entries", stats.get("inserted", 0)),
"affected_ids": dict(affected_ids), "affected_ids": dict(affected_ids),
"items_written": stats.get("items_written", 0),
"unmapped_names": stats.get("unmapped_names", 0),
} }
return out return out
@ -203,6 +229,11 @@ def _import_nutrition(
for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header): for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header):
rows_total += 1 rows_total += 1
mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition") mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition")
guessed_name, guessed_qty = guess_nutrition_item_fields(csv_row)
if not mapped.get("food_name") and guessed_name:
mapped["food_name"] = guessed_name
if not mapped.get("quantity_raw") and guessed_qty:
mapped["quantity_raw"] = guessed_qty
d = coerce_date(mapped.get("date")) d = coerce_date(mapped.get("date"))
if d is None: if d is None:
error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"}) error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"})
@ -223,6 +254,50 @@ def _import_nutrition(
skipped_groups = sum(n.get("rows_in_group", 0) for n in (agg_notes or []) if n.get("error") == "mehrere_zeilen_pro_schluessel") skipped_groups = sum(n.get("rows_in_group", 0) for n in (agg_notes or []) if n.get("error") == "mehrere_zeilen_pro_schluessel")
item_rows = []
for mapped in mapped_rows:
name = mapped.get("food_name")
if not name:
continue
d_item = coerce_date(mapped.get("date"))
if d_item is None:
continue
item_rows.append({
"date": d_item.isoformat(),
"logged_at": mapped.get("logged_at") or mapped.get("date"),
"food_name": str(name).strip(),
"quantity_raw": mapped.get("quantity_raw"),
"kcal": mapped.get("kcal"),
"protein_g": mapped.get("protein_g"),
"fat_g": mapped.get("fat_g"),
"carbs_g": mapped.get("carbs_g"),
})
if item_rows:
from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
policy = get_import_policy(cur, profile_id)
ingest = replace_csv_items_for_dates(cur, profile_id, item_rows, policy=policy)
cur.execute(
"""
SELECT COUNT(DISTINCT source_name_normalized) AS n
FROM nutrition_items
WHERE profile_id = %s AND food_id IS NULL
""",
(profile_id,),
)
unmapped_row = cur.fetchone() or {}
return {
"rows_total": rows_total,
"inserted": ingest.get("new_log_days", 0),
"updated": max(0, ingest.get("days_written", 0) - ingest.get("new_log_days", 0)),
"skipped": skipped_groups,
"new_entries": ingest.get("new_log_days", 0),
"items_written": ingest.get("items_written", 0),
"unmapped_names": int(unmapped_row.get("n") or 0),
"conflicts": ingest.get("conflicts") or [],
"policy": ingest.get("policy"),
}
inserted = 0 inserted = 0
updated = 0 updated = 0
new_entries = 0 new_entries = 0

View File

@ -20,6 +20,8 @@ _MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = {
"protein_g": frozenset({"protein", "eiwei", "eiweiss"}), "protein_g": frozenset({"protein", "eiwei", "eiweiss"}),
"fat_g": frozenset({"fett", "fat", "lipid"}), "fat_g": frozenset({"fett", "fat", "lipid"}),
"carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}), "carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}),
"food_name": frozenset({"bezeichnung", "lebensmittel", "food", "gericht"}),
"quantity_raw": frozenset({"menge", "quantity", "portion", "gramm"}),
}, },
"weight": { "weight": {
"date": frozenset({"datum", "date", "tag", "day", "zeit"}), "date": frozenset({"datum", "date", "tag", "day", "zeit"}),

View File

@ -20,6 +20,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"}, "protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"fat_g": {"type": "float", "required": False, "min": 0, "unit": "g"}, "fat_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"carbs_g": {"type": "float", "required": False, "min": 0, "unit": "g"}, "carbs_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"food_name": {"type": "string", "required": False, "label_de": "Lebensmittel"},
"quantity_raw": {"type": "string", "required": False, "label_de": "Menge"},
}, },
"duplicate_key": ["profile_id", "date"], "duplicate_key": ["profile_id", "date"],
"duplicate_strategy": "update", "duplicate_strategy": "update",

View File

@ -0,0 +1,128 @@
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
from __future__ import annotations
from typing import Any
MACRO_TO_KEY = {
"kcal": "ENERCC",
"protein_g": "PROT625",
"fat_g": "FAT",
"carbs_g": "CHO",
}
def scale_to_per_100g(value: float, serving_g: float | None) -> float:
if not serving_g or serving_g <= 0 or serving_g == 100:
return float(value)
return float(value) * (100.0 / float(serving_g))
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]:
q = (query or "").strip()
lim = min(max(int(limit or 40), 1), 80)
if q:
like = f"%{q}%"
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
AND (
attr_key ILIKE %s OR name_de ILIKE %s
OR COALESCE(name_en, '') ILIKE %s
)
ORDER BY
CASE WHEN attr_key ILIKE %s THEN 0
WHEN name_de ILIKE %s THEN 1
ELSE 2 END,
sort_order, attr_key
LIMIT %s
""",
(like, like, like, q, f"{q}%", lim),
)
else:
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
ORDER BY sort_order, attr_key
LIMIT %s
""",
(lim,),
)
return [dict(r) for r in cur.fetchall()]
def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
if not values:
return 0
written = 0
for raw_key, raw_val in values.items():
key = str(raw_key or "").strip()
if not key or raw_val is None or raw_val == "":
continue
try:
num = float(raw_val)
except (TypeError, ValueError):
continue
cur.execute(
"""
SELECT id FROM food_attributes
WHERE attr_key = %s AND is_active = true AND data_type = 'num_per_100g'
""",
(key,),
)
row = cur.fetchone()
if not row:
continue
cur.execute(
"""
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
VALUES (%s, %s, %s, false)
ON CONFLICT (food_id, attribute_id)
DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
""",
(food_id, row["id"], num),
)
written += 1
return written
def macros_and_attributes_to_values(
macros: dict[str, Any] | None,
attributes: dict[str, Any] | None,
serving_g: float | None = None,
) -> dict[str, float]:
out: dict[str, float] = {}
for field, key in MACRO_TO_KEY.items():
if not macros or field not in macros or macros[field] is None or macros[field] == "":
continue
try:
out[key] = scale_to_per_100g(float(macros[field]), serving_g)
except (TypeError, ValueError):
continue
for key, raw in (attributes or {}).items():
if raw is None or raw == "":
continue
try:
out[str(key)] = scale_to_per_100g(float(raw), serving_g)
except (TypeError, ValueError):
continue
return out
def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
cur.execute(
"""
SELECT a.attr_key, 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
AND NOT (a.attr_key = ANY(%s))
ORDER BY a.sort_order, a.attr_key
""",
(food_id, list(MACRO_TO_KEY.values())),
)
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}

View File

@ -0,0 +1,265 @@
"""Portable JSON for user food mappings, manual foods, and FDDB lists."""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
from uuid import UUID
from data_layer.food_mapping import (
apply_mapping_to_items,
apply_quantities_to_items,
normalize_food_name,
upsert_food_mapping,
)
from data_layer.food_recipes import list_recipes, upsert_recipes
from data_layer.nutrition_items import catalog_macros_for_item, dates_for_normalized_name, rebuild_daily_nutrients
BUNDLE_FORMAT = "mitai-food-knowledge"
BUNDLE_VERSION = 1
def _jsonable(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Decimal):
return float(value)
if isinstance(value, UUID):
return str(value)
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]:
if not isinstance(data, dict):
raise ValueError("Die Datei ist kein JSON-Objekt")
if data.get("format") != BUNDLE_FORMAT:
raise ValueError("Keine Mitai-Zuordnungsdatei — bitte die exportierte JSON verwenden")
try:
version = int(data.get("version") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("Unbekannte Dateiversion") from exc
if version != BUNDLE_VERSION:
raise ValueError(f"Nicht unterstützte Dateiversion {version}")
return data
def portable_mapping(row: dict[str, Any]) -> dict[str, Any]:
return {
"source_system": row.get("source_system") or "fddb",
"source_name_raw": row.get("source_name_raw"),
"source_name_normalized": row.get("source_name_normalized"),
"bls_code": row.get("bls_code"),
"food_name_de": row.get("food_name_de") or row.get("name_de"),
"catalog_kind": row.get("catalog_kind"),
"external_key": row.get("external_key"),
"grams_per_unit": _jsonable(row.get("grams_per_unit")),
"source_unit": row.get("source_unit"),
}
def resolve_catalog_food(cur, profile_id: str, ref: dict[str, Any]) -> str | None:
bls = (ref.get("bls_code") or "").strip()
if bls:
cur.execute(
"SELECT id FROM food_catalog WHERE bls_code = %s AND is_active = true",
(bls,),
)
row = cur.fetchone()
return str(row["id"]) if row else None
kind = ref.get("catalog_kind") or "manual_user"
name = (ref.get("food_name_de") or ref.get("name_de") or "").strip()
key = (ref.get("external_key") or "").strip()
if kind == "manual_user":
if key:
cur.execute(
"""
SELECT id FROM food_catalog
WHERE owner_profile_id = %s AND external_key = %s AND is_active = true
LIMIT 1
""",
(profile_id, key),
)
row = cur.fetchone()
if row:
return str(row["id"])
if name:
cur.execute(
"""
SELECT id FROM food_catalog
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user'
AND lower(name_de) = lower(%s) AND is_active = true
LIMIT 1
""",
(profile_id, name),
)
row = cur.fetchone()
if row:
return str(row["id"])
return None
if kind == "manual_admin" and name:
cur.execute(
"""
SELECT id FROM food_catalog
WHERE catalog_kind = 'manual_admin' AND lower(name_de) = lower(%s)
AND is_active = true
LIMIT 1
""",
(name,),
)
row = cur.fetchone()
return str(row["id"]) if row else None
return None
def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
cur.execute(
"""
SELECT id, name_de, name_en, catalog_kind, external_key
FROM food_catalog
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true
ORDER BY lower(name_de)
""",
(profile_id,),
)
manuals = []
for food in cur.fetchall():
macros = catalog_macros_for_item(cur, food["id"], 100.0)
from data_layer.food_attributes import extra_attributes_for_food
manuals.append({
"name_de": food["name_de"],
"name_en": food.get("name_en"),
"catalog_kind": food["catalog_kind"],
"external_key": food.get("external_key"),
"macros_per_100g": macros,
"attributes": extra_attributes_for_food(cur, food["id"]),
})
cur.execute(
"""
SELECT m.source_system, m.source_name_raw, m.source_name_normalized,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de AS food_name_de, f.catalog_kind, f.external_key
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.profile_id = %s
ORDER BY m.source_name_normalized
""",
(profile_id,),
)
mappings = [portable_mapping(dict(r)) for r in cur.fetchall()]
recipes = []
for rec in list_recipes(cur, profile_id):
recipes.append({
"name_raw": rec.get("name_raw"),
"name_normalized": rec.get("name_normalized"),
"portions": _jsonable(rec.get("portions")),
"description": rec.get("description"),
"source": rec.get("source") or "fddb_list",
"ingredients": [
{
"source_name_raw": ing.get("source_name_raw"),
"source_name_normalized": ing.get("source_name_normalized"),
"quantity_raw": ing.get("quantity_raw"),
"quantity_g": _jsonable(ing.get("quantity_g")),
"quantity_amount": _jsonable(ing.get("quantity_amount")),
"source_unit": ing.get("source_unit"),
"sort_order": ing.get("sort_order") or 0,
}
for ing in rec.get("ingredients") or []
],
})
return _jsonable({
"format": BUNDLE_FORMAT,
"version": BUNDLE_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"manual_foods": manuals,
"mappings": mappings,
"recipes": recipes,
})
def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None:
name = (food.get("name_de") or "").strip()
if not name:
return None
existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"})
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
values = macros_and_attributes_to_values(food.get("macros_per_100g"), food.get("attributes"))
if existing:
write_numeric_attributes(cur, existing, values)
return existing
key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}"
cur.execute(
"""
INSERT INTO food_catalog
(name_de, name_en, catalog_kind, owner_profile_id, source, external_key)
VALUES (%s, %s, 'manual_user', %s, 'import', %s)
RETURNING id
""",
(name, food.get("name_en"), profile_id, key),
)
food_id = str(cur.fetchone()["id"])
write_numeric_attributes(cur, food_id, values)
return food_id
def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[str, Any]:
bundle = parse_food_knowledge_bundle(data)
foods_upserted = 0
for food in bundle.get("manual_foods") or []:
if _upsert_manual_food(cur, profile_id, food):
foods_upserted += 1
mappings_ok = mappings_skipped = items_updated = 0
skipped: list[str] = []
dates: set[str] = set()
for raw in bundle.get("mappings") or []:
name = (raw.get("source_name_raw") or "").strip()
if not name:
mappings_skipped += 1
continue
food_id = resolve_catalog_food(cur, profile_id, raw)
if not food_id:
mappings_skipped += 1
label = raw.get("bls_code") or raw.get("food_name_de") or name
skipped.append(str(label))
continue
mid = upsert_food_mapping(
cur,
source_name_raw=name,
food_id=food_id,
profile_id=profile_id,
source="import",
source_system=raw.get("source_system") or "fddb",
grams_per_unit=raw.get("grams_per_unit"),
source_unit=raw.get("source_unit"),
)
norm = normalize_food_name(name)
items_updated += apply_mapping_to_items(cur, profile_id, norm, food_id, mid)
apply_quantities_to_items(cur, profile_id, norm, raw.get("grams_per_unit"))
dates.update(dates_for_normalized_name(cur, profile_id, norm))
mappings_ok += 1
recipe_stats = {"inserted": 0, "updated": 0, "ingredients": 0, "items_linked": 0}
recipes = bundle.get("recipes") or []
if recipes:
recipe_stats = upsert_recipes(cur, profile_id, recipes)
dates.update(recipe_stats.pop("dates_linked", []) or [])
for day in dates:
rebuild_daily_nutrients(cur, profile_id, day)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index(profile_id)
return {
"ok": True,
"manual_foods": foods_upserted,
"mappings": mappings_ok,
"mappings_skipped": mappings_skipped,
"items_updated": items_updated,
"skipped_foods": skipped[:20],
**recipe_stats,
}

View File

@ -0,0 +1,525 @@
"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply."""
from __future__ import annotations
import re
import unicodedata
from datetime import date, timedelta
from typing import Any
LEADING_QTY_RE = re.compile(
r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-]*",
re.IGNORECASE,
)
MULTISPACE_RE = re.compile(r"\s+")
DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)")
def strip_leading_quantity(raw: str | None) -> str:
if not raw:
return ""
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
s = s.lstrip("!")
s = LEADING_QTY_RE.sub("", s)
return MULTISPACE_RE.sub(" ", s).strip()
def normalize_food_name(raw: str | None) -> str:
s = strip_leading_quantity(raw)
if not s:
return ""
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
return s.lower()
_LIST_COMMA_RE = re.compile(r",(?!\s*\d)")
def primary_search_query(query: str | None) -> str:
"""Use the name before a list-comma, but keep decimal commas (9,5 %)."""
q = (query or "").strip()
if not q:
return ""
return _LIST_COMMA_RE.split(q, maxsplit=1)[0].strip()
def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
merged: dict[str, dict] = {}
for row in rows:
raw = row.get("source_name_raw") or ""
key = normalize_food_name(raw) or row.get("source_name_normalized") or raw.lower()
if not key:
continue
display = strip_leading_quantity(raw) or raw
count = int(row.get("count") or 0)
if key not in merged:
item = dict(row)
item["source_name_normalized"] = key
item["source_name_raw"] = display
item["count"] = count
item["variant_count"] = 1
merged[key] = item
continue
cur = merged[key]
cur["count"] = int(cur.get("count") or 0) + count
cur["variant_count"] = int(cur.get("variant_count") or 1) + 1
if display and (not cur.get("source_name_raw") or len(display) < len(cur["source_name_raw"])):
cur["source_name_raw"] = display
if row.get("matching_recipe_id") and not cur.get("matching_recipe_id"):
cur["matching_recipe_id"] = row["matching_recipe_id"]
if row.get("sample_quantity_raw") and not cur.get("sample_quantity_raw"):
cur["sample_quantity_raw"] = row["sample_quantity_raw"]
first, last = row.get("first_date"), row.get("last_date")
if first and (not cur.get("first_date") or str(first) < str(cur["first_date"])):
cur["first_date"] = first
if last and (not cur.get("last_date") or str(last) > str(cur["last_date"])):
cur["last_date"] = last
return list(merged.values())
def as_iso_date(value: Any) -> str | None:
if value is None or value == "":
return None
if hasattr(value, "isoformat"):
return str(value.isoformat())[:10]
text = str(value).strip()
return text[:10] if len(text) >= 10 else None
def filter_unmapped_since(rows: list[dict], since_days: int, today: date | None = None) -> list[dict]:
"""Keep names last eaten in the window. Rows without last_date drop out (old recipe leftovers)."""
days = int(since_days or 0)
if days <= 0:
return list(rows)
cutoff = ((today or date.today()) - timedelta(days=days)).isoformat()
out = []
for row in rows:
last = as_iso_date(row.get("last_date"))
if last and last >= cutoff:
out.append(row)
return out
def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]:
rows = list(rows)
if int(since_days or 0) > 0:
rows.sort(
key=lambda x: (
as_iso_date(x.get("last_date")) or "",
int(x.get("count") or 0),
),
reverse=True,
)
else:
rows.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
return rows
UNIT_ALIASES = {
"g": "g", "gr": "g", "gramm": "g",
"kg": "kg",
"ml": "ml",
"l": "l", "liter": "l", "lt": "l",
"stück": "stück", "stk": "stück", "st": "stück", "st.": "stück", "pcs": "stück",
"el": "el", "esslöffel": "el",
"tl": "tl", "teelöffel": "tl",
"prise": "prise",
"scheibe": "scheibe",
"portion": "portion", "portionen": "portion",
"becher": "becher",
"tasse": "tasse",
"msp": "msp", "msp.": "msp",
}
MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0}
DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0}
COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"})
UNIT_LABELS = {
"g": "g", "kg": "kg", "ml": "ml", "l": "l",
"el": "EL", "tl": "TL", "prise": "Prise", "msp": "Msp.",
"stück": "Stück", "scheibe": "Scheibe", "portion": "Portion",
"becher": "Becher", "tasse": "Tasse",
}
UNIT_ORDER = (
"g", "ml", "el", "tl", "prise", "msp",
"stück", "scheibe", "portion", "becher", "tasse", "kg", "l",
)
QTY_PARSE_RE = re.compile(
r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$",
re.IGNORECASE,
)
def _canon_unit(raw: str | None) -> str | None:
if not raw:
return None
return UNIT_ALIASES.get(raw.strip().lower().rstrip("."))
def parse_quantity(raw: str | None, grams_per_unit: float | None = None) -> dict[str, Any]:
empty = {"value": None, "unit": None, "quantity_g": None, "needs_unit_map": False}
if raw is None or str(raw).strip() == "":
return empty
text = str(raw).strip().replace(",", ".")
m = QTY_PARSE_RE.match(text)
if not m:
return empty
value = float(m.group(1))
unit = _canon_unit(m.group(2))
if unit is None:
return {"value": value, "unit": "g", "quantity_g": round(value, 3), "needs_unit_map": False}
if unit in MASS_VOLUME_TO_G:
return {
"value": value,
"unit": unit,
"quantity_g": round(value * MASS_VOLUME_TO_G[unit], 3),
"needs_unit_map": False,
}
factor = grams_per_unit if grams_per_unit is not None else DEFAULT_UNIT_G.get(unit)
if factor is not None:
return {
"value": value,
"unit": unit,
"quantity_g": round(value * float(factor), 3),
"needs_unit_map": unit in COUNT_UNITS,
}
return {"value": value, "unit": unit, "quantity_g": None, "needs_unit_map": True}
def parse_quantity_g(raw: str | None, grams_per_unit: float | None = None) -> float | None:
return parse_quantity(raw, grams_per_unit)["quantity_g"]
def detect_quantity_unit(*texts: str | None) -> str | None:
for text in texts:
unit = parse_quantity(text).get("unit")
if unit and unit not in MASS_VOLUME_TO_G:
return unit
return None
def format_quantity_raw(value: float | None, unit: str | None) -> str | None:
if value is None:
return None
label = UNIT_LABELS.get(unit or "g", unit or "g")
num = int(value) if float(value) == int(value) else value
return f"{num} {label}".replace(".", ",")
def list_quantity_units() -> list[dict[str, Any]]:
out = []
for uid in UNIT_ORDER:
default_g = MASS_VOLUME_TO_G.get(uid)
if default_g is None:
default_g = DEFAULT_UNIT_G.get(uid)
out.append({
"id": uid,
"label": UNIT_LABELS[uid],
"default_g": default_g,
"needs_unit_map": uid in COUNT_UNITS,
})
return out
def resolve_quantity(
*,
quantity_raw: str | None = None,
quantity_amount: float | None = None,
source_unit: str | None = None,
quantity_g: float | None = None,
grams_per_unit: float | None = None,
) -> dict[str, Any]:
"""Amount + unit → quantity_g. Standard unit is grams; conversion uses mapping or defaults."""
amount = None
if quantity_amount not in (None, ""):
try:
amount = float(quantity_amount)
except (TypeError, ValueError):
amount = None
explicit_g = None
if quantity_g not in (None, ""):
try:
explicit_g = float(quantity_g)
except (TypeError, ValueError):
explicit_g = None
if amount is not None:
unit = _canon_unit(source_unit) or "g"
raw = format_quantity_raw(amount, unit)
parsed = parse_quantity(raw.replace(",", "."), grams_per_unit)
elif quantity_raw:
parsed = parse_quantity(quantity_raw, grams_per_unit)
elif explicit_g is not None:
parsed = {"value": explicit_g, "unit": "g", "quantity_g": explicit_g, "needs_unit_map": False}
else:
parsed = {"value": None, "unit": _canon_unit(source_unit), "quantity_g": None, "needs_unit_map": False}
qty_g = parsed.get("quantity_g")
if qty_g is None and explicit_g is not None:
qty_g = explicit_g
unit = parsed.get("unit")
value = parsed.get("value")
return {
"quantity_amount": value,
"source_unit": unit,
"quantity_raw": format_quantity_raw(value, unit) or (str(quantity_raw).strip() if quantity_raw else None),
"quantity_g": qty_g,
"needs_unit_map": bool(parsed.get("needs_unit_map")),
}
def get_food_mapping_with_cursor(
cur,
source_name: str,
profile_id: str | None = None,
source_system: str = "fddb",
) -> dict[str, Any] | None:
norm = normalize_food_name(source_name)
if not norm:
return None
if profile_id:
cur.execute(
"""
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de, f.catalog_kind
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.source_system = %s AND m.source_name_normalized = %s
AND m.profile_id = %s
LIMIT 1
""",
(source_system, norm, profile_id),
)
row = cur.fetchone()
if row:
return dict(row)
cur.execute(
"""
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de, f.catalog_kind
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.source_system = %s AND m.source_name_normalized = %s
AND m.profile_id IS NULL
LIMIT 1
""",
(source_system, norm),
)
row = cur.fetchone()
return dict(row) if row else None
def upsert_food_mapping(
cur,
*,
source_name_raw: str,
food_id: str,
profile_id: str | None,
source: str = "bulk",
source_system: str = "fddb",
grams_per_unit: float | None = None,
source_unit: str | None = None,
) -> int:
norm = normalize_food_name(source_name_raw)
if not norm:
raise ValueError("Leerer Lebensmittelname")
if profile_id:
cur.execute(
"""
SELECT id FROM food_name_mappings
WHERE source_system = %s AND source_name_normalized = %s AND profile_id = %s
""",
(source_system, norm, profile_id),
)
else:
cur.execute(
"""
SELECT id FROM food_name_mappings
WHERE source_system = %s AND source_name_normalized = %s AND profile_id IS NULL
""",
(source_system, norm),
)
existing = cur.fetchone()
raw = source_name_raw.strip()
unit = _canon_unit(source_unit) if source_unit else None
if existing:
cur.execute(
"""
UPDATE food_name_mappings
SET food_id = %s, source_name_raw = %s, source = %s,
grams_per_unit = %s, source_unit = %s, updated_at = NOW()
WHERE id = %s
""",
(food_id, raw, source, grams_per_unit, unit, existing["id"]),
)
return int(existing["id"])
cur.execute(
"""
INSERT INTO food_name_mappings
(source_system, source_name_raw, source_name_normalized, food_id, profile_id,
source, grams_per_unit, source_unit, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(source_system, raw, norm, food_id, profile_id, source, grams_per_unit, unit),
)
return int(cur.fetchone()["id"])
def apply_quantities_to_items(cur, profile_id: str, source_name_normalized: str, grams_per_unit: float | None) -> int:
if not grams_per_unit:
return 0
cur.execute(
"""
SELECT id, quantity_raw, source_name_raw
FROM nutrition_items
WHERE profile_id = %s AND source_name_normalized = %s
""",
(profile_id, source_name_normalized),
)
n = 0
for row in cur.fetchall():
qty = parse_quantity_g(row.get("quantity_raw") or row.get("source_name_raw"), grams_per_unit)
if qty is None:
continue
cur.execute(
"UPDATE nutrition_items SET quantity_g = %s, updated_at = NOW() WHERE id = %s",
(qty, row["id"]),
)
n += 1
return n
def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int:
origin = _value_origin_for_food(cur, food_id)
cur.execute(
"""
UPDATE nutrition_items
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
AND recipe_id IS NULL
""",
(food_id, mapping_id, origin, profile_id, source_name_normalized),
)
n = cur.rowcount or 0
cur.execute(
"""
SELECT id, source_name_raw
FROM nutrition_items
WHERE profile_id = %s AND recipe_id IS NULL AND food_id IS NULL
""",
(profile_id,),
)
extra = [
row["id"] for row in cur.fetchall()
if normalize_food_name(row.get("source_name_raw")) == source_name_normalized
]
if extra:
cur.execute(
"""
UPDATE nutrition_items
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
WHERE id = ANY(%s)
""",
(food_id, mapping_id, origin, extra),
)
n += cur.rowcount or 0
return n
def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int:
cur.execute(
"""
UPDATE nutrition_items
SET food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
""",
(profile_id, source_name_normalized),
)
return cur.rowcount or 0
def _value_origin_for_food(cur, food_id: str) -> str:
cur.execute("SELECT catalog_kind FROM food_catalog WHERE id = %s", (food_id,))
row = cur.fetchone()
if not row:
return "fddb"
kind = row["catalog_kind"]
if kind == "official_bls":
return "bls"
return "manual_catalog"
_SEARCH_NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
def _catalog_must_tokens(query: str) -> list[list[str]]:
"""AND-groups for catalog search: first word plus each number (with 9,5/10 aliases)."""
text = normalize_food_name(primary_search_query(query))
nums = [m.replace(",", ".") for m in _SEARCH_NUM_RE.findall(text)]
words = [t for t in re.split(r"[^a-z0-9äöüß]+", _SEARCH_NUM_RE.sub(" ", text)) if len(t) >= 2]
groups: list[list[str]] = []
if words:
groups.append([words[0]])
for num in nums[:3]:
variants = {num, num.replace(".", ",")}
try:
value = float(num)
except ValueError:
value = None
if value is not None and (abs(value - 10) <= 0.6 or abs(value - 9.5) <= 0.6):
variants.update({"10", "9.5", "9,5"})
groups.append(list(variants))
return groups
def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
q = (query or "").strip()
if not q:
return []
primary = primary_search_query(q) or q
like_full = f"%{q}%"
like_primary = f"%{primary}%"
prefix = f"{primary}%"
norm = normalize_food_name(primary)
extra_sql = ""
extra_params: list[str] = []
must = _catalog_must_tokens(q)
if len(must) >= 2:
parts = []
for group in must:
ors = " OR ".join(["name_de ILIKE %s"] * len(group))
parts.append(f"({ors})")
extra_params.extend(f"%{v}%" for v in group)
extra_sql = " OR (" + " AND ".join(parts) + ")"
cur.execute(
f"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
FROM food_catalog
WHERE is_active = true
AND (
owner_profile_id IS NULL
OR owner_profile_id = %s
)
AND (
name_de ILIKE %s OR name_de ILIKE %s
OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s
OR COALESCE(bls_code, '') ILIKE %s
OR lower(name_de) = %s
{extra_sql}
)
ORDER BY
CASE
WHEN lower(name_de) = %s THEN 0
WHEN name_de ILIKE %s THEN 1
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
ELSE 3 END,
name_de
LIMIT %s
""",
(
profile_id,
like_full, like_primary, like_full, like_primary, like_full, norm,
*extra_params,
norm, prefix, q, limit,
),
)
return [dict(r) for r in cur.fetchall()]

View File

@ -0,0 +1,323 @@
"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
from __future__ import annotations
import uuid
from typing import Any
from data_layer.food_mapping import (
get_food_mapping_with_cursor,
normalize_food_name,
resolve_quantity,
)
from data_layer.nutrition_items import catalog_macros_for_item, _f
def _resolved_ingredient_qty(cur, profile_id: str, source_name_raw: str, ing: dict[str, Any]) -> dict[str, Any]:
mapping = get_food_mapping_with_cursor(cur, source_name_raw, profile_id)
gpu = ing.get("grams_per_unit")
if gpu in (None, ""):
gpu = mapping.get("grams_per_unit") if mapping else None
try:
gpu = float(gpu) if gpu not in (None, "") else None
except (TypeError, ValueError):
gpu = None
return resolve_quantity(
quantity_raw=ing.get("quantity_raw"),
quantity_amount=ing.get("quantity_amount"),
source_unit=ing.get("source_unit"),
quantity_g=ing.get("quantity_g"),
grams_per_unit=gpu,
)
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
inserted = updated = ingredients = 0
for rec in recipes:
norm = rec.get("name_normalized") or normalize_food_name(rec.get("name_raw"))
if not norm:
continue
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
row = cur.fetchone()
if row:
rid = row["id"]
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s
""",
(rec["name_raw"], rec.get("portions") or 1, rec.get("description"), rid),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (rid,))
updated += 1
else:
rid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO food_recipes
(id, profile_id, name_raw, name_normalized, portions, description, source)
VALUES (%s,%s,%s,%s,%s,%s,'fddb_list')
""",
(rid, profile_id, rec["name_raw"], norm, rec.get("portions") or 1, rec.get("description")),
)
inserted += 1
for ing in rec.get("ingredients") or []:
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
if not inorm:
continue
raw_name = ing.get("source_name_raw") or inorm
qty = _resolved_ingredient_qty(cur, profile_id, raw_name, ing)
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, raw_name, inorm,
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"],
ing.get("sort_order") or 0,
),
)
ingredients += 1
linked, dates = link_recipes_to_items(cur, profile_id)
return {
"inserted": inserted,
"updated": updated,
"ingredients": ingredients,
"items_linked": linked,
"dates_linked": dates,
}
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
cur.execute(
"""
SELECT DISTINCT i.date::text AS date
FROM nutrition_items i
JOIN food_recipes r ON r.profile_id = i.profile_id
AND i.source_name_normalized = r.name_normalized
WHERE i.profile_id = %s AND i.food_id IS NULL
""",
(profile_id,),
)
dates = [r["date"] for r in cur.fetchall()]
cur.execute(
"""
UPDATE nutrition_items i
SET recipe_id = r.id, updated_at = NOW()
FROM food_recipes r
WHERE i.profile_id = %s AND r.profile_id = %s
AND i.source_name_normalized = r.name_normalized
AND i.food_id IS NULL
""",
(profile_id, profile_id),
)
return cur.rowcount or 0, dates
def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT id, name_raw, name_normalized, portions, description, source
FROM food_recipes
WHERE profile_id = %s
ORDER BY name_normalized
""",
(profile_id,),
)
recipes = [dict(r) for r in cur.fetchall()]
if not recipes:
return []
ids = [str(r["id"]) for r in recipes]
cur.execute(
"""
SELECT recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order
FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s::uuid[])
ORDER BY sort_order, source_name_raw
""",
(ids,),
)
by_r: dict[str, list] = {str(i): [] for i in ids}
for row in cur.fetchall():
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
for rec in recipes:
rec["id"] = str(rec["id"])
rec["ingredients"] = by_r.get(rec["id"], [])
return recipes
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
for rec in list_recipes(cur, profile_id):
if rec["id"] == str(recipe_id):
return rec
return None
def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]:
name_raw = (rec.get("name_raw") or "").strip()
norm = rec.get("name_normalized") or normalize_food_name(name_raw)
if not name_raw or not norm:
raise ValueError("Rezeptname fehlt")
try:
portions = float(rec.get("portions") or 1)
except (TypeError, ValueError):
portions = 1.0
if portions <= 0:
portions = 1.0
description = rec.get("description")
source = (rec.get("source") or "manual").strip() or "manual"
if recipe_id:
cur.execute(
"SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
cur.execute(
"""
SELECT id FROM food_recipes
WHERE profile_id = %s AND name_normalized = %s AND id <> %s
""",
(profile_id, norm, recipe_id),
)
if cur.fetchone():
raise ValueError("Ein Rezept mit diesem Namen existiert bereits")
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, name_normalized=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s AND profile_id=%s
""",
(name_raw, norm, portions, description, recipe_id, profile_id),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (recipe_id,))
rid = recipe_id
else:
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
existing = cur.fetchone()
if existing:
return save_recipe(cur, profile_id, rec, str(existing["id"]))
rid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO food_recipes
(id, profile_id, name_raw, name_normalized, portions, description, source)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(rid, profile_id, name_raw, norm, portions, description, source),
)
for i, ing in enumerate(rec.get("ingredients") or []):
raw = (ing.get("source_name_raw") or "").strip()
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
if not inorm:
continue
qty = _resolved_ingredient_qty(cur, profile_id, raw or inorm, ing)
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, raw or inorm, inorm,
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i,
),
)
link_recipes_to_items(cur, profile_id)
saved = get_recipe(cur, profile_id, rid)
if not saved:
raise ValueError("Rezept konnte nicht gelesen werden")
return saved
def delete_recipe(cur, profile_id: str, recipe_id: str) -> None:
cur.execute(
"DELETE FROM food_recipes WHERE id = %s AND profile_id = %s RETURNING id",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
cur.execute(
"""
UPDATE nutrition_items
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
""",
(recipe_id, profile_id, source_name_normalized),
)
return cur.rowcount or 0
def mapped_ingredient_quantities(
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
) -> list[dict[str, Any]] | None:
cur.execute(
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
rec = cur.fetchone()
if not rec:
return None
cur.execute(
"""
SELECT source_name_raw, quantity_g, quantity_raw, quantity_amount, source_unit
FROM food_recipe_ingredients
WHERE recipe_id = %s
ORDER BY sort_order
""",
(recipe_id,),
)
ings = cur.fetchall()
if not ings:
return None
resolved_ings = []
for ing in ings:
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
if not mapping:
return None
qty = resolve_quantity(
quantity_raw=ing.get("quantity_raw"),
quantity_amount=ing.get("quantity_amount"),
source_unit=ing.get("source_unit"),
quantity_g=ing.get("quantity_g"),
grams_per_unit=mapping.get("grams_per_unit"),
)
grams = _f(qty.get("quantity_g"))
if grams <= 0:
return None
resolved_ings.append({"food_id": mapping["food_id"], "quantity_g": grams})
total_g = sum(i["quantity_g"] for i in resolved_ings)
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
scale = float(eaten_qty_g) / total_g
else:
portions = float(rec.get("portions") or 1) or 1.0
scale = 1.0 / portions
return [{**i, "quantity_g": i["quantity_g"] * scale} for i in resolved_ings]
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
if not parts:
return None
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
for part in parts:
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
if not cat:
return None
for k in acc:
acc[k] += cat[k]
return acc

View File

@ -0,0 +1,311 @@
"""In-memory catalog suggestions: Haferflocken → Hafer Flocken, without opening a dialog."""
from __future__ import annotations
import re
import time
from typing import Any
from data_layer.food_mapping import normalize_food_name, primary_search_query
_INDEX_TTL_SEC = 300
_index_cache: dict[str, tuple[float, dict[str, Any]]] = {}
MAX_CANDIDATES = 60
MAX_BUCKET = 40
SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+")
COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]")
NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
NUM_TOKEN_RE = re.compile(r"^\d+(?:\.\d+)?$")
MIN_SCORE = 45
FAT_CLOSE = 0.6
def collapse_key(raw: str | None) -> str:
return COLLAPSE_RE.sub("", normalize_food_name(raw))
def number_tokens(raw: str | None) -> list[str]:
return [m.replace(",", ".") for m in NUM_RE.findall(normalize_food_name(raw))]
def is_number_token(tok: str) -> bool:
return bool(NUM_TOKEN_RE.fullmatch(tok or ""))
def number_search_aliases(tok: str) -> list[str]:
"""9.5 and 10 are the same fat class in dairy; keep both searchable."""
if not is_number_token(tok):
return [tok]
aliases = {tok}
try:
value = float(tok)
except ValueError:
return [tok]
if abs(value - 10) <= FAT_CLOSE or abs(value - 9.5) <= FAT_CLOSE:
aliases.update({"9.5", "10"})
if value == int(value):
aliases.add(str(int(value)))
return list(aliases)
def numbers_compatible(query_nums: list[str], name_nums: list[str]) -> bool | None:
if not query_nums:
return None
if not name_nums:
return False
for qn in query_nums:
try:
qv = float(qn)
except ValueError:
continue
for nn in name_nums:
try:
if abs(qv - float(nn)) <= FAT_CLOSE:
return True
except ValueError:
if qn == nn:
return True
return False
def name_tokens(raw: str | None) -> list[str]:
text = normalize_food_name(raw)
nums = number_tokens(text)
words = [t for t in SPLIT_RE.split(NUM_RE.sub(" ", text)) if len(t) >= 2]
return words + nums
def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int:
qn = normalize_food_name(primary_search_query(query))
nn = normalize_food_name(name_de)
if not qn or not nn:
return 0
qc, nc = collapse_key(qn), collapse_key(nn)
q_nums, n_nums = number_tokens(qn), number_tokens(nn)
fat_ok = numbers_compatible(q_nums, n_nums)
if qn == nn:
return 100
if qc and qc == nc:
return 95
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
q_words = {t for t in qt if not is_number_token(t)}
n_words = {t for t in nt if not is_number_token(t)}
words_overlap = bool(q_words and n_words and (q_words & n_words or q_words <= n_words))
if fat_ok and words_overlap:
return 96
if fat_ok is False:
base = 0
if qc and nc.startswith(qc) and len(qc) >= 4:
base = 82
elif nc and qc.startswith(nc) and len(nc) >= 4:
base = 78
elif qt and qt <= nt:
base = 72
elif nt and nt <= qt:
base = 68
elif qt and nt:
overlap = len(qt & nt) / len(qt | nt)
if overlap >= 0.5:
base = 50 + int(overlap * 20)
elif qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
base = 55 if abs(len(qc) - len(nc)) <= 8 else 46
return min(base, 52) if base else 0
if qc and nc.startswith(qc) and len(qc) >= 4:
return 82
if nc and qc.startswith(nc) and len(nc) >= 4:
return 78
if qt and qt <= nt:
return 72
if nt and nt <= qt:
return 68
if qt and nt:
overlap = len(qt & nt) / len(qt | nt)
if overlap >= 0.5:
return 50 + int(overlap * 20)
if qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
return 55 if abs(len(qc) - len(nc)) <= 8 else 46
en = normalize_food_name(name_en or "")
if en and (qn == en or collapse_key(en) == qc):
return 88
return 0
def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
return {
"id": str(food["id"]),
"bls_code": food.get("bls_code"),
"name_de": food.get("name_de"),
"name_en": food.get("name_en"),
"catalog_kind": food.get("catalog_kind"),
"food_group": food.get("food_group"),
"score": score,
}
def invalidate_suggest_index(profile_id: str | None = None) -> None:
if profile_id is None:
_index_cache.clear()
return
_index_cache.pop(str(profile_id), None)
_index_cache.pop("global", None)
def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
key = str(profile_id or "global")
hit = _index_cache.get(key)
if hit and (time.monotonic() - hit[0]) < _INDEX_TTL_SEC:
return hit[1]
index = load_suggest_index(cur, profile_id)
_index_cache[key] = (time.monotonic(), index)
return index
def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
cur.execute(
"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
FROM food_catalog
WHERE is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
""",
(profile_id,),
)
foods = [dict(r) for r in cur.fetchall()]
by_collapse: dict[str, list] = {}
by_token: dict[str, list] = {}
by_prefix: dict[str, list] = {}
by_suffix: dict[str, list] = {}
for food in foods:
food["_c"] = collapse_key(food.get("name_de"))
food["_t"] = name_tokens(food.get("name_de"))
if food["_c"]:
by_collapse.setdefault(food["_c"], []).append(food)
by_prefix.setdefault(food["_c"][:4], []).append(food)
if len(food["_c"]) >= 4:
by_suffix.setdefault(food["_c"][-4:], []).append(food)
for tok in food["_t"]:
by_token.setdefault(tok, []).append(food)
return {
"foods": foods,
"by_collapse": by_collapse,
"by_token": by_token,
"by_prefix": by_prefix,
"by_suffix": by_suffix,
}
def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
qc = collapse_key(query)
seen: set[str] = set()
out: list[dict[str, Any]] = []
def add(food: dict[str, Any]) -> None:
fid = str(food["id"])
if fid in seen:
return
seen.add(fid)
out.append(food)
if qc:
for food in index["by_collapse"].get(qc, []):
add(food)
if len(qc) >= 4:
prefix_hits = index["by_prefix"].get(qc[:4], [])
if len(prefix_hits) > MAX_BUCKET:
prefix_hits = [
food for food in prefix_hits
if (food.get("_c") or "").startswith(qc) or qc.startswith(food.get("_c") or "")
]
for food in prefix_hits[:MAX_CANDIDATES]:
add(food)
suffix_hits = index["by_suffix"].get(qc[-4:], [])
if len(suffix_hits) <= MAX_BUCKET:
for food in suffix_hits:
add(food)
q_words = [t for t in name_tokens(query) if not is_number_token(t)]
seen_tok: set[str] = set()
for tok in name_tokens(query):
probes = number_search_aliases(tok) if is_number_token(tok) else [tok]
for probe in probes:
if probe in seen_tok:
continue
seen_tok.add(probe)
token_hits = index["by_token"].get(probe, [])
if is_number_token(probe) and q_words:
token_hits = [
food for food in token_hits
if set(q_words) & set(food.get("_t") or [])
]
elif not is_number_token(probe) and len(token_hits) > MAX_BUCKET:
token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET]
for food in token_hits:
add(food)
if len(out) >= MAX_CANDIDATES and not any(is_number_token(t) for t in name_tokens(query)):
break
return out[:MAX_CANDIDATES] if not number_tokens(query) else out
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
q = (query or "").strip()
scored: list[tuple[int, int, dict]] = []
for food in _candidate_foods(index, q):
score = score_name_match(q, food.get("name_de") or "", food.get("name_en"))
if score < MIN_SCORE:
continue
scored.append((score, len(food.get("name_de") or ""), food))
scored.sort(key=lambda x: (-x[0], x[1], x[2].get("name_de") or ""))
top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
ambiguous = False
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60:
ambiguous = True
elif len(top) >= 2 and top[0]["score"] < 90:
ambiguous = True
return {
"suggestions": top[:limit],
"suggestion_count": len(top),
"ambiguous": ambiguous,
}
def attach_suggestions(index: dict[str, Any], rows: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]:
for row in rows:
q = row.get("source_name_raw") or row.get("source_name_normalized") or ""
packed = suggest_for_name(index, q, limit=limit)
row["suggestions"] = packed["suggestions"]
row["suggestion_count"] = packed["suggestion_count"]
row["ambiguous"] = packed["ambiguous"]
return rows
def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
q = (query or "").strip()
if len(q) < 2:
return []
index = get_suggest_index(cur, profile_id)
packed = suggest_for_name(index, q, limit=max(limit, 8))
hits = list(packed["suggestions"])
from data_layer.food_mapping import suggest_catalog_foods
if number_tokens(q) or not hits:
seen = {str(h["id"]) for h in hits}
for row in suggest_catalog_foods(cur, q, profile_id, limit=max(limit, 20)):
fid = str(row["id"])
if fid in seen:
continue
seen.add(fid)
score = score_name_match(q, row.get("name_de") or "", row.get("name_en"))
if score < MIN_SCORE and not number_tokens(q):
continue
hits.append(_public(row, score or 40))
hits.sort(key=lambda h: (-int(h.get("score") or 0), h.get("name_de") or ""))
return hits[:limit]
def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]:
index = get_suggest_index(cur, profile_id)
out = {}
for raw in names[:100]:
key = (raw or "").strip()
if not key or key in out:
continue
out[key] = suggest_for_name(index, key, limit=limit)
return out

View File

@ -0,0 +1,466 @@
"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from typing import Any
from data_layer.food_mapping import (
get_food_mapping_with_cursor,
normalize_food_name,
parse_quantity_g,
)
MACRO_ATTR_KEYS = {
"kcal": "ENERCC",
"protein_g": "PROT625",
"fat_g": "FAT",
"carbs_g": "CHO",
}
POLICIES = frozenset({"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing"})
def _f(v: Any) -> float:
if v is None or v == "":
return 0.0
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def _round_macros(d: dict[str, float]) -> dict[str, float]:
return {
"kcal": round(_f(d.get("kcal")), 1),
"protein_g": round(_f(d.get("protein_g")), 1),
"fat_g": round(_f(d.get("fat_g")), 1),
"carbs_g": round(_f(d.get("carbs_g")), 1),
}
def macros_differ(a: dict[str, float], b: dict[str, float]) -> bool:
aa, bb = _round_macros(a), _round_macros(b)
return any(aa[k] != bb[k] for k in aa)
def get_import_policy(cur, profile_id: str) -> str:
cur.execute(
"SELECT nutrition_import_conflict_policy FROM profiles WHERE id = %s",
(profile_id,),
)
row = cur.fetchone()
if not row:
return "prompt"
pol = row.get("nutrition_import_conflict_policy") or "prompt"
return pol if pol in POLICIES else "prompt"
def catalog_macros_for_item(cur, food_id: str | None, quantity_g: float | None) -> dict[str, float] | None:
if not food_id or quantity_g is None or quantity_g <= 0:
return None
cur.execute(
"""
SELECT a.attr_key, v.value_num, v.is_trace
FROM food_attribute_values v
JOIN food_attributes a ON a.id = v.attribute_id
WHERE v.food_id = %s AND a.attr_key = ANY(%s) AND a.data_type = 'num_per_100g'
""",
(food_id, list(MACRO_ATTR_KEYS.values())),
)
by_key = {r["attr_key"]: r for r in cur.fetchall()}
if not by_key:
return None
out = {}
factor = float(quantity_g) / 100.0
missing = False
for field, key in MACRO_ATTR_KEYS.items():
row = by_key.get(key)
if not row or row.get("is_trace") or row.get("value_num") is None:
missing = True
break
out[field] = float(row["value_num"]) * factor
return None if missing else out
def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, Any]:
cur.execute(
"""
SELECT kcal, protein_g, fat_g, carbs_g, macro_origin, has_items
FROM nutrition_log WHERE profile_id = %s AND date = %s
""",
(profile_id, day),
)
existing = cur.fetchone()
existing_macros = (
_round_macros(existing)
if existing
else None
)
cur.execute(
"""
SELECT food_id, recipe_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin
FROM nutrition_items
WHERE profile_id = %s AND date = %s
""",
(profile_id, day),
)
items = cur.fetchall()
fddb = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
catalog = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
mapped = unmapped = 0
used_bls = used_fddb = False
for it in items:
fddb["kcal"] += _f(it.get("fddb_kcal"))
fddb["protein_g"] += _f(it.get("fddb_protein_g"))
fddb["fat_g"] += _f(it.get("fddb_fat_g"))
fddb["carbs_g"] += _f(it.get("fddb_carbs_g"))
if it.get("recipe_id") and not it.get("food_id"):
from data_layer.food_recipes import catalog_macros_for_recipe
cat = catalog_macros_for_recipe(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
else:
cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g"))
if cat:
mapped += 1
used_bls = True
for k in catalog:
catalog[k] += cat[k]
else:
unmapped += 1
used_fddb = True
catalog["kcal"] += _f(it.get("fddb_kcal"))
catalog["protein_g"] += _f(it.get("fddb_protein_g"))
catalog["fat_g"] += _f(it.get("fddb_fat_g"))
catalog["carbs_g"] += _f(it.get("fddb_carbs_g"))
origin = "mixed"
if used_bls and not used_fddb:
origin = "bls"
elif used_fddb and not used_bls:
origin = "fddb"
if not items:
origin = "manual"
return {
"existing": existing_macros,
"fddb": _round_macros(fddb) if items else None,
"catalog": _round_macros(catalog) if items else None,
"mapped_item_count": mapped,
"unmapped_item_count": unmapped,
"has_items": bool(items),
"catalog_origin": origin,
"has_log": existing is not None,
"macro_origin": existing["macro_origin"] if existing else None,
}
def apply_nutrition_day_macros(
cur,
profile_id: str,
day: date | str,
macros: dict[str, float],
*,
macro_origin: str,
source: str = "csv",
confirm: bool = False,
) -> str:
m = _round_macros(macros)
cur.execute("SELECT id FROM nutrition_log WHERE profile_id = %s AND date = %s", (profile_id, day))
row = cur.fetchone()
counts = compute_day_macro_sums(cur, profile_id, day)
extra = (
counts["mapped_item_count"],
counts["unmapped_item_count"],
counts["has_items"],
)
confirmed = datetime.utcnow() if confirm else None
if row:
cur.execute(
"""
UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source=%s,
macro_origin=%s, mapped_item_count=%s, unmapped_item_count=%s,
has_items=%s, last_import_at=NOW(), macros_confirmed_at=COALESCE(%s, macros_confirmed_at)
WHERE profile_id=%s AND date=%s
""",
(
m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
macro_origin, extra[0], extra[1], extra[2], confirmed, profile_id, day,
),
)
return "updated"
eid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO nutrition_log (
id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source,
macro_origin, mapped_item_count, unmapped_item_count, has_items,
last_import_at, macros_confirmed_at, created
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),%s,CURRENT_TIMESTAMP)
""",
(
eid, profile_id, day, m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
macro_origin, extra[0], extra[1], extra[2], confirmed,
),
)
return "created"
def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantity_g: float) -> None:
if not food_id or not quantity_g or quantity_g <= 0:
return
cur.execute(
"""
SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
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
""",
(food_id,),
)
factor = float(quantity_g) / 100.0
for row in cur.fetchall():
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
cur.execute(
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
(profile_id, day),
)
cur.execute(
"""
SELECT food_id, recipe_id, quantity_g
FROM nutrition_items
WHERE profile_id = %s AND date = %s
""",
(profile_id, day),
)
acc: dict[int, list[float]] = {}
from data_layer.food_recipes import mapped_ingredient_quantities
for it in cur.fetchall():
if it.get("food_id"):
_accumulate_food_qty(cur, acc, it["food_id"], _f(it.get("quantity_g")))
continue
if it.get("recipe_id"):
parts = mapped_ingredient_quantities(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
if not parts:
continue
for part in parts:
_accumulate_food_qty(cur, acc, part["food_id"], part["quantity_g"])
for attr_id, vals in acc.items():
cur.execute(
"""
INSERT INTO nutrition_daily_nutrients
(profile_id, date, attribute_id, value, contributing_item_count, updated_at)
VALUES (%s, %s, %s, %s, %s, NOW())
""",
(profile_id, day, attr_id, round(sum(vals), 6), len(vals)),
)
def _item_value_origin(mapping: dict | None) -> str:
if not mapping:
return "fddb"
kind = mapping.get("catalog_kind")
if kind == "official_bls":
return "bls"
if kind in ("manual_admin", "manual_user"):
return "manual_catalog"
return "fddb"
def replace_csv_items_for_dates(
cur,
profile_id: str,
rows: list[dict[str, Any]],
*,
policy: str,
policy_override: str | None = None,
) -> dict[str, Any]:
"""
Replace csv-sourced items for the dates present in rows.
Returns conflicts when policy is prompt and existing macros differ.
"""
effective = policy_override if policy_override in POLICIES else policy
by_date: dict[str, list[dict]] = {}
for row in rows:
d = row.get("date")
if hasattr(d, "isoformat"):
iso = d.isoformat()
else:
iso = str(d)[:10]
if not iso:
continue
by_date.setdefault(iso, []).append(row)
conflicts = []
days_written = 0
items_written = 0
new_log_days = 0
for iso, day_rows in by_date.items():
cur.execute(
"""
DELETE FROM nutrition_items
WHERE profile_id = %s AND date = %s AND source = 'csv'
""",
(profile_id, iso),
)
for raw in day_rows:
name = (raw.get("food_name") or raw.get("source_name_raw") or "").strip()
if not name:
continue
qty_raw = raw.get("quantity_raw")
qty_g = parse_quantity_g(
qty_raw if qty_raw is not None else name,
mapping.get("grams_per_unit") if mapping else None,
)
mapping = get_food_mapping_with_cursor(cur, name, profile_id)
logged_at = raw.get("logged_at")
cur.execute(
"""
INSERT INTO nutrition_items (
id, profile_id, date, logged_at, source_name_raw, source_name_normalized,
source_system, quantity_raw, quantity_g,
fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g,
food_id, mapping_id, value_origin, source, recipe_id
) VALUES (
%s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s
)
""",
(
str(uuid.uuid4()),
profile_id,
iso,
logged_at,
name,
normalize_food_name(name),
str(qty_raw) if qty_raw is not None else None,
qty_g,
_f(raw.get("fddb_kcal") if raw.get("fddb_kcal") is not None else raw.get("kcal")),
_f(raw.get("fddb_protein_g") if raw.get("fddb_protein_g") is not None else raw.get("protein_g")),
_f(raw.get("fddb_fat_g") if raw.get("fddb_fat_g") is not None else raw.get("fat_g")),
_f(raw.get("fddb_carbs_g") if raw.get("fddb_carbs_g") is not None else raw.get("carbs_g")),
mapping["food_id"] if mapping else None,
mapping["mapping_id"] if mapping else None,
_item_value_origin(mapping),
None,
),
)
items_written += 1
from data_layer.food_recipes import link_recipes_to_items
link_recipes_to_items(cur, profile_id)
days_written += 1
sums = compute_day_macro_sums(cur, profile_id, iso)
rebuild_daily_nutrients(cur, profile_id, iso)
if not sums["has_items"]:
continue
if not sums["has_log"]:
apply_nutrition_day_macros(
cur, profile_id, iso, sums["catalog"],
macro_origin=sums["catalog_origin"], source="csv",
)
new_log_days += 1
continue
existing = sums["existing"]
catalog = sums["catalog"]
fddb = sums["fddb"]
differ = macros_differ(existing, catalog) or macros_differ(existing, fddb)
if effective == "keep_existing":
_touch_item_counts(cur, profile_id, iso, sums)
continue
if effective == "overwrite_catalog":
apply_nutrition_day_macros(
cur, profile_id, iso, catalog,
macro_origin=sums["catalog_origin"], source="csv",
)
continue
if effective == "overwrite_fddb":
apply_nutrition_day_macros(
cur, profile_id, iso, fddb, macro_origin="fddb", source="csv",
)
continue
# prompt
if differ:
conflicts.append({
"date": iso,
"existing": existing,
"fddb": fddb,
"catalog": catalog,
"catalog_origin": sums["catalog_origin"],
"mapped_item_count": sums["mapped_item_count"],
"unmapped_item_count": sums["unmapped_item_count"],
})
else:
apply_nutrition_day_macros(
cur, profile_id, iso, catalog,
macro_origin=sums["catalog_origin"], source="csv",
)
return {
"days_written": days_written,
"items_written": items_written,
"new_log_days": new_log_days,
"conflicts": conflicts,
"policy": effective,
}
def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None:
cur.execute(
"""
UPDATE nutrition_log
SET mapped_item_count=%s, unmapped_item_count=%s, has_items=%s, last_import_at=NOW()
WHERE profile_id=%s AND date=%s
""",
(
sums["mapped_item_count"],
sums["unmapped_item_count"],
sums["has_items"],
profile_id,
day,
),
)
def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]:
if choice == "existing":
return sums["existing"], "user_confirmed"
if choice == "fddb":
return sums["fddb"], "fddb"
if choice == "catalog":
return sums["catalog"], sums.get("catalog_origin") or "mixed"
raise ValueError("Ungültige Wahl (existing|fddb|catalog)")
def resolve_food_attributes(cur, food_id: str) -> list[dict]:
cur.execute(
"""
SELECT a.attr_key, a.name_de, a.unit, a.category, a.data_type, a.origin AS attr_origin,
v.value_num, v.value_bool, v.value_text, v.is_trace, v.origin_code
FROM food_attributes a
LEFT JOIN food_attribute_values v
ON v.attribute_id = a.id AND v.food_id = %s
WHERE a.is_active = true
ORDER BY a.sort_order, a.attr_key
""",
(food_id,),
)
return [dict(r) for r in cur.fetchall()]
def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) -> list[str]:
cur.execute(
"""
SELECT DISTINCT date::text AS date
FROM nutrition_items
WHERE profile_id = %s AND source_name_normalized = %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
WHERE i.profile_id = %s AND ri.source_name_normalized = %s
""",
(profile_id, source_name_normalized, profile_id, source_name_normalized),
)
return [r["date"] for r in cur.fetchall()]

View File

@ -38,6 +38,7 @@ from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout
from routers import reports # Strukturierter PDF-Bericht (Profil v1) from routers import reports # Strukturierter PDF-Bericht (Profil v1)
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping
# ── App Configuration ───────────────────────────────────────────────────────── # ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -133,6 +134,9 @@ app.include_router(csv_import.router) # /api/csv/* (Issue #21)
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21) app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
app.include_router(bls.router) # /api/bls/*
app.include_router(admin_bls.router) # /api/admin/bls/*
app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
# ── Health Check ────────────────────────────────────────────────────────────── # ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")

View File

@ -0,0 +1,205 @@
-- Migration 062: BLS/food catalog, typed attributes, FDDB mapping, nutrition items, day marks
-- Additive only. Official BLS rows upsert by bls_code / attr_key — never delete+reinsert.
CREATE TABLE IF NOT EXISTS food_attributes (
id SERIAL PRIMARY KEY,
attr_key VARCHAR(64) NOT NULL,
name_de VARCHAR(255) NOT NULL,
name_en VARCHAR(255),
unit VARCHAR(40),
category VARCHAR(80),
data_type VARCHAR(20) NOT NULL DEFAULT 'num_per_100g'
CHECK (data_type IN ('num_per_100g', 'boolean', 'text', 'enum')),
enum_values JSONB,
origin VARCHAR(20) NOT NULL DEFAULT 'official_bls'
CHECK (origin IN ('official_bls', 'extension')),
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_food_attributes_key UNIQUE (attr_key)
);
CREATE INDEX IF NOT EXISTS idx_food_attributes_origin ON food_attributes (origin);
CREATE TABLE IF NOT EXISTS food_catalog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bls_code VARCHAR(16),
external_key VARCHAR(80),
name_de VARCHAR(500) NOT NULL,
name_en VARCHAR(500),
food_group VARCHAR(8),
catalog_kind VARCHAR(20) NOT NULL DEFAULT 'official_bls'
CHECK (catalog_kind IN ('official_bls', 'manual_admin', 'manual_user')),
owner_profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
bls_version VARCHAR(16),
source VARCHAR(40) NOT NULL DEFAULT 'bls_4.0',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_food_catalog_bls_code UNIQUE (bls_code),
CONSTRAINT chk_food_catalog_bls_code CHECK (
(catalog_kind = 'official_bls' AND bls_code IS NOT NULL)
OR (catalog_kind <> 'official_bls' AND bls_code IS NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_food_catalog_name_de ON food_catalog (lower(name_de));
CREATE INDEX IF NOT EXISTS idx_food_catalog_kind ON food_catalog (catalog_kind);
CREATE INDEX IF NOT EXISTS idx_food_catalog_owner ON food_catalog (owner_profile_id)
WHERE owner_profile_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS food_attribute_values (
id BIGSERIAL PRIMARY KEY,
food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE,
attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE,
value_num DOUBLE PRECISION,
value_bool BOOLEAN,
value_text TEXT,
is_trace BOOLEAN NOT NULL DEFAULT false,
origin_code VARCHAR(80),
reference_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_food_attribute_value UNIQUE (food_id, attribute_id),
CONSTRAINT chk_food_attr_one_value CHECK (
(
(value_num IS NOT NULL)::int
+ (value_bool IS NOT NULL)::int
+ (value_text IS NOT NULL)::int
) <= 1
)
);
CREATE INDEX IF NOT EXISTS idx_fav_food ON food_attribute_values (food_id);
CREATE INDEX IF NOT EXISTS idx_fav_attr ON food_attribute_values (attribute_id);
CREATE TABLE IF NOT EXISTS food_name_mappings (
id SERIAL PRIMARY KEY,
source_system VARCHAR(20) NOT NULL DEFAULT 'fddb',
source_name_raw VARCHAR(500) NOT NULL,
source_name_normalized VARCHAR(500) NOT NULL,
food_id UUID NOT NULL REFERENCES food_catalog(id) ON DELETE CASCADE,
profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
source VARCHAR(20) NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'bulk', 'admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_global
ON food_name_mappings (source_system, source_name_normalized)
WHERE profile_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_food_map_user
ON food_name_mappings (source_system, source_name_normalized, profile_id)
WHERE profile_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_food_map_food ON food_name_mappings (food_id);
CREATE TABLE IF NOT EXISTS nutrition_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
logged_at TIMESTAMPTZ,
source_name_raw VARCHAR(500) NOT NULL,
source_name_normalized VARCHAR(500) NOT NULL,
source_system VARCHAR(20) NOT NULL DEFAULT 'fddb',
quantity_raw VARCHAR(80),
quantity_g NUMERIC(10,3),
fddb_kcal NUMERIC(8,2),
fddb_protein_g NUMERIC(8,2),
fddb_fat_g NUMERIC(8,2),
fddb_carbs_g NUMERIC(8,2),
food_id UUID REFERENCES food_catalog(id) ON DELETE SET NULL,
mapping_id INT REFERENCES food_name_mappings(id) ON DELETE SET NULL,
value_origin VARCHAR(20) NOT NULL DEFAULT 'fddb'
CHECK (value_origin IN ('fddb', 'bls', 'manual_catalog')),
source VARCHAR(20) NOT NULL DEFAULT 'csv'
CHECK (source IN ('csv', 'manual')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_nutrition_items_profile_date
ON nutrition_items (profile_id, date DESC);
CREATE INDEX IF NOT EXISTS idx_nutrition_items_unmapped
ON nutrition_items (profile_id, source_name_normalized)
WHERE food_id IS NULL;
CREATE TABLE IF NOT EXISTS nutrition_daily_nutrients (
id BIGSERIAL PRIMARY KEY,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
attribute_id INT NOT NULL REFERENCES food_attributes(id) ON DELETE CASCADE,
value DOUBLE PRECISION NOT NULL,
contributing_item_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_nutrition_daily_nutrient UNIQUE (profile_id, date, attribute_id)
);
CREATE INDEX IF NOT EXISTS idx_ndn_profile_date
ON nutrition_daily_nutrients (profile_id, date DESC);
CREATE TABLE IF NOT EXISTS nutrition_day_marks (
id SERIAL PRIMARY KEY,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
mark_type VARCHAR(20) NOT NULL CHECK (mark_type IN ('fasting', 'incomplete')),
note TEXT,
source VARCHAR(20) NOT NULL DEFAULT 'manual',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_nutrition_day_mark UNIQUE (profile_id, date)
);
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macro_origin VARCHAR(20);
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS mapped_item_count INT;
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS unmapped_item_count INT;
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS has_items BOOLEAN DEFAULT false;
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS last_import_at TIMESTAMPTZ;
ALTER TABLE nutrition_log ADD COLUMN IF NOT EXISTS macros_confirmed_at TIMESTAMPTZ;
DELETE FROM nutrition_log a
USING nutrition_log b
WHERE a.profile_id = b.profile_id
AND a.date = b.date
AND (a.created < b.created OR (a.created = b.created AND a.id::text < b.id::text));
CREATE UNIQUE INDEX IF NOT EXISTS uq_nutrition_log_profile_date
ON nutrition_log (profile_id, date);
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS nutrition_import_conflict_policy VARCHAR(30) DEFAULT 'prompt';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_nutrition_import_policy'
) THEN
ALTER TABLE profiles ADD CONSTRAINT chk_nutrition_import_policy
CHECK (nutrition_import_conflict_policy IN (
'prompt', 'overwrite_catalog', 'overwrite_fddb', 'keep_existing'
));
END IF;
END $$;
UPDATE csv_field_mappings
SET field_mappings = COALESCE(field_mappings, '{}'::jsonb)
|| '{"bezeichnung": "food_name", "menge": "quantity_raw"}'::jsonb,
updated_at = NOW()
WHERE is_system = true
AND module = 'nutrition'
AND (
mapping_name ILIKE '%FDDB%'
OR mapping_name ILIKE '%fddb%'
);
COMMENT ON TABLE food_catalog IS 'BLS 4.0 official foods + manual catalog extensions';
COMMENT ON COLUMN food_catalog.bls_code IS 'Stable official BLS identity; upsert key';
COMMENT ON TABLE food_name_mappings IS 'Learned FDDB name -> food_catalog; user overrides global';
COMMENT ON TABLE nutrition_day_marks IS 'Explicit fasting/incomplete; import must not delete';
DO $$
BEGIN
RAISE NOTICE 'Migration 062: BLS food catalog, mappings, nutrition items, day marks';
END $$;

View File

@ -0,0 +1,43 @@
-- Migration 063: FDDB-Listen/Rezepte + Verknüpfung an nutrition_items
CREATE TABLE IF NOT EXISTS food_recipes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name_raw VARCHAR(500) NOT NULL,
name_normalized VARCHAR(500) NOT NULL,
portions NUMERIC(8,2) NOT NULL DEFAULT 1,
description TEXT,
source VARCHAR(20) NOT NULL DEFAULT 'fddb_list',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_food_recipe_profile_name UNIQUE (profile_id, name_normalized)
);
CREATE INDEX IF NOT EXISTS idx_food_recipes_profile ON food_recipes (profile_id);
CREATE TABLE IF NOT EXISTS food_recipe_ingredients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recipe_id UUID NOT NULL REFERENCES food_recipes(id) ON DELETE CASCADE,
source_name_raw VARCHAR(500) NOT NULL,
source_name_normalized VARCHAR(500) NOT NULL,
quantity_raw VARCHAR(80),
quantity_g NUMERIC(10,3),
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_recipe ON food_recipe_ingredients (recipe_id);
CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_norm ON food_recipe_ingredients (source_name_normalized);
ALTER TABLE nutrition_items
ADD COLUMN IF NOT EXISTS recipe_id UUID REFERENCES food_recipes(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_nutrition_items_recipe
ON nutrition_items (recipe_id)
WHERE recipe_id IS NOT NULL;
COMMENT ON TABLE food_recipes IS 'FDDB-Listen/Rezepte; Tagebuchzeile kann statt Einzel-BLS auf ein Rezept zeigen';
DO $$
BEGIN
RAISE NOTICE 'Migration 063: FDDB recipes + nutrition_items.recipe_id';
END $$;

View File

@ -0,0 +1,18 @@
-- Migration 064: Mengeneinheiten am Mapping + source=import erlaubt
ALTER TABLE food_name_mappings
ADD COLUMN IF NOT EXISTS grams_per_unit NUMERIC(10,3),
ADD COLUMN IF NOT EXISTS source_unit VARCHAR(20);
ALTER TABLE food_name_mappings DROP CONSTRAINT IF EXISTS food_name_mappings_source_check;
ALTER TABLE food_name_mappings
ADD CONSTRAINT food_name_mappings_source_check
CHECK (source IN ('manual', 'bulk', 'admin', 'import'));
COMMENT ON COLUMN food_name_mappings.grams_per_unit IS 'Gramm pro Quell-Einheit (Stück, EL, …); NULL = Masse/Volumen bereits in g';
DO $$
BEGIN
RAISE NOTICE 'Migration 064: mapping units + source import';
END $$;

View File

@ -0,0 +1,13 @@
-- Common supplement fatty acids if BLS did not import these exact keys.
-- ON CONFLICT keeps official BLS rows unchanged.
INSERT INTO food_attributes
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
VALUES
('EPA', 'Eicosapentaensäure (EPA)', 'EPA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8001),
('DHA', 'Docosahexaensäure (DHA)', 'DHA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8002),
('DPA', 'Docosapentaensäure (DPA)', 'DPA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8003),
('ALA', 'Alpha-Linolensäure (ALA)', 'ALA', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8004),
('OMEGA3', 'Omega-3-Fettsäuren gesamt', 'Omega-3', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8005),
('OMEGA6', 'Omega-6-Fettsäuren gesamt', 'Omega-6', 'g', 'fatty_acids', 'num_per_100g', 'extension', 8006)
ON CONFLICT (attr_key) DO NOTHING;

View File

@ -0,0 +1,18 @@
-- Menge + Einheit an Rezeptzutaten; quantity_g bleibt die Standardeinheit (Gramm).
ALTER TABLE food_recipe_ingredients
ADD COLUMN IF NOT EXISTS quantity_amount NUMERIC(10,3),
ADD COLUMN IF NOT EXISTS source_unit VARCHAR(20);
COMMENT ON COLUMN food_recipe_ingredients.quantity_amount IS 'Menge in source_unit (z. B. 2 TL)';
COMMENT ON COLUMN food_recipe_ingredients.source_unit IS 'Kanonische Einheit: g, ml, el, tl, prise, stück, …';
COMMENT ON COLUMN food_recipe_ingredients.quantity_g IS 'Umrechnung auf Gramm, wenn Faktor bekannt';
UPDATE food_recipe_ingredients
SET quantity_amount = quantity_g, source_unit = 'g'
WHERE quantity_g IS NOT NULL AND quantity_amount IS NULL AND source_unit IS NULL;
DO $$
BEGIN
RAISE NOTICE 'Migration 066: recipe ingredient amount + unit';
END $$;

View File

@ -30,6 +30,7 @@ class ProfileUpdate(BaseModel):
goal_bf_pct: Optional[float] = None goal_bf_pct: Optional[float] = None
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück
nutrition_import_conflict_policy: Optional[str] = None
# ── Tracking Models ─────────────────────────────────────────────────────────── # ── Tracking Models ───────────────────────────────────────────────────────────

View File

@ -12,3 +12,4 @@ python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
matplotlib==3.8.4 matplotlib==3.8.4
reportlab==4.2.0 reportlab==4.2.0
openpyxl==3.1.5

View File

@ -617,6 +617,24 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
Persistenz läuft über activity_persistence_orchestrator gleiche Schicht wie Universal-CSV. Persistenz läuft über activity_persistence_orchestrator gleiche Schicht wie Universal-CSV.
""" """
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Feature-Enforcement (wie manueller Create + nutrition/import-csv): activity_entries
access = check_feature_access(pid, "activity_entries")
log_feature_usage(pid, "activity_entries", access, "import_csv")
if not access["allowed"]:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=(
f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten "
f"({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
),
)
raw = await file.read() raw = await file.read()
try: text = raw.decode('utf-8') try: text = raw.decode('utf-8')
except: text = raw.decode('latin-1') except: text = raw.decode('latin-1')
@ -729,4 +747,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
except Exception as e: except Exception as e:
logger.warning(f"Import row failed: {e}") logger.warning(f"Import row failed: {e}")
skipped+=1 skipped+=1
for _ in range(inserted):
increment_feature_usage(pid, "activity_entries")
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}

View File

@ -0,0 +1,268 @@
"""Admin BLS catalog import and attribute/food maintenance."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from auth import require_admin
from bls.import_service import upsert_attributes, upsert_foods
from bls.jobs import create_and_check, get_job, start_apply
from bls.parser import parse_components_xlsx, parse_foods_xlsx
from db import get_cursor, get_db, r2d
MAX_IMPORT_BYTES = 50 * 1024 * 1024
router = APIRouter(prefix="/api/admin/bls", tags=["admin", "bls"])
class AttributeCreate(BaseModel):
attr_key: str
name_de: str
name_en: Optional[str] = None
unit: Optional[str] = None
category: Optional[str] = None
data_type: str = "num_per_100g"
enum_values: Optional[list] = None
class ManualFoodCreate(BaseModel):
name_de: str
name_en: Optional[str] = None
macros_per_100g: Optional[dict] = None
@router.get("/status")
def bls_status(session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind = 'official_bls'")
foods = cur.fetchone()["n"]
cur.execute("SELECT COUNT(*) AS n FROM food_attributes WHERE origin = 'official_bls'")
attrs = cur.fetchone()["n"]
cur.execute("SELECT COUNT(*) AS n FROM food_catalog WHERE catalog_kind <> 'official_bls'")
manual = cur.fetchone()["n"]
cur.execute("SELECT MAX(updated_at) AS last_updated FROM food_catalog WHERE catalog_kind = 'official_bls'")
last = cur.fetchone()["last_updated"]
return {
"official_foods": foods,
"official_attributes": attrs,
"manual_foods": manual,
"last_updated": last,
"source": "Max Rubner-Institut, BLS 4.0 (frei verfügbar)",
}
@router.post("/import/components")
async def import_components(
file: UploadFile = File(...),
dry_run: bool = True,
session: dict = Depends(require_admin),
):
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
attrs = await run_in_threadpool(parse_components_xlsx, raw)
except Exception as e:
raise HTTPException(400, f"Components-Datei unlesbar: {e}") from e
if dry_run:
return {"dry_run": True, "attributes": len(attrs), "sample": attrs[:8]}
def apply():
with get_db() as conn:
cur = get_cursor(conn)
return upsert_attributes(cur, attrs)
stats = await run_in_threadpool(apply)
return {"dry_run": False, **stats}
@router.post("/import/foods")
async def import_foods(
file: UploadFile = File(...),
dry_run: bool = True,
session: dict = Depends(require_admin),
):
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
parsed = await run_in_threadpool(parse_foods_xlsx, raw)
except Exception as e:
raise HTTPException(400, f"Datendatei unlesbar: {e}") from e
foods = parsed["foods"]
if dry_run:
return {
"dry_run": True,
"foods": len(foods),
"attribute_columns": len(parsed["attribute_headers"]),
"sample": [
{"bls_code": f["bls_code"], "name_de": f["name_de"]}
for f in foods[:8]
],
}
def apply():
with get_db() as conn:
cur = get_cursor(conn)
return upsert_foods(cur, foods)
stats = await run_in_threadpool(apply)
return {"dry_run": False, **stats}
@router.post("/import/jobs")
async def start_import_job(
kind: str,
file: UploadFile = File(...),
session: dict = Depends(require_admin),
):
if kind not in ("components", "foods"):
raise HTTPException(400, "kind muss components oder foods sein")
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
if len(raw) > MAX_IMPORT_BYTES:
raise HTTPException(400, "Datei größer als 50 MB")
job_id = create_and_check(kind, raw)
return {"id": job_id, "status": "checking"}
@router.get("/import/jobs/{job_id}")
def import_job_status(job_id: str, session: dict = Depends(require_admin)):
job = get_job(job_id)
if not job:
raise HTTPException(404, "Import-Job nicht gefunden")
return job
@router.post("/import/jobs/{job_id}/apply")
def apply_import_job(job_id: str, session: dict = Depends(require_admin)):
try:
start_apply(job_id)
except KeyError:
raise HTTPException(404, "Import-Job nicht gefunden") from None
except ValueError as e:
raise HTTPException(409, str(e)) from e
return get_job(job_id)
@router.get("/foods")
def admin_list_foods(
q: Optional[str] = None,
kind: Optional[str] = None,
limit: int = 50,
session: dict = Depends(require_admin),
):
limit = min(max(limit, 1), 200)
with get_db() as conn:
cur = get_cursor(conn)
conds = ["is_active = true"]
params: list = []
if kind:
conds.append("catalog_kind = %s")
params.append(kind)
if q:
conds.append("(name_de ILIKE %s OR COALESCE(name_en,'') ILIKE %s OR COALESCE(bls_code,'') ILIKE %s)")
like = f"%{q}%"
params.extend([like, like, like])
params.append(limit)
cur.execute(
f"""
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, bls_version
FROM food_catalog
WHERE {' AND '.join(conds)}
ORDER BY name_de
LIMIT %s
""",
params,
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/foods/{food_id}")
def admin_food_detail(food_id: str, session: dict = Depends(require_admin)):
from data_layer.nutrition_items import resolve_food_attributes
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM food_catalog WHERE id = %s", (food_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Lebensmittel nicht gefunden")
attrs = resolve_food_attributes(cur, food_id)
return {**r2d(row), "attributes": attrs}
@router.post("/foods/manual")
def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(require_admin)):
from data_layer.food_mapping import normalize_food_name
name = (body.name_de or "").strip()
if not name:
raise HTTPException(400, "Name fehlt")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
INSERT INTO food_catalog (name_de, name_en, catalog_kind, source, external_key)
VALUES (%s, %s, 'manual_admin', 'manual', %s)
RETURNING *
""",
(name, body.name_en, f"man-admin-{normalize_food_name(name)[:40]}"),
)
food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index()
return food
@router.get("/attributes")
def admin_list_attributes(session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, data_type, origin, sort_order
FROM food_attributes
WHERE is_active = true
ORDER BY origin, sort_order, attr_key
"""
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/attributes")
def admin_create_attribute(body: AttributeCreate, session: dict = Depends(require_admin)):
key = body.attr_key.strip().upper().replace(" ", "_")
if not key:
raise HTTPException(400, "attr_key fehlt")
if body.data_type not in ("num_per_100g", "boolean", "text", "enum"):
raise HTTPException(400, "Ungültiger data_type")
with get_db() as conn:
cur = get_cursor(conn)
try:
cur.execute(
"""
INSERT INTO food_attributes
(attr_key, name_de, name_en, unit, category, data_type, enum_values, origin, sort_order)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'extension', 9000)
RETURNING *
""",
(
key, body.name_de, body.name_en, body.unit, body.category,
body.data_type, None if not body.enum_values else body.enum_values,
),
)
except Exception as e:
raise HTTPException(409, f"Attribut existiert bereits oder ist ungültig: {e}") from e
return r2d(cur.fetchone())
def _write_manual_macros(cur, food_id: str, macros: dict | None) -> None:
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
write_numeric_attributes(cur, food_id, macros_and_attributes_to_values(macros, None))

View File

@ -0,0 +1,153 @@
"""Admin CRUD for food_name_mappings."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import require_admin
from data_layer.food_mapping import apply_mapping_to_items, upsert_food_mapping
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
from db import get_cursor, get_db, r2d
router = APIRouter(prefix="/api/admin/food-mappings", tags=["admin", "food-mappings"])
class FoodMappingCreate(BaseModel):
source_name: str
food_id: str
profile_id: Optional[str] = None
source_system: str = "fddb"
class FoodMappingUpdate(BaseModel):
food_id: str
grams_per_unit: Optional[float] = None
source_unit: Optional[str] = None
@router.get("")
def list_food_mappings(
profile_id: Optional[str] = None,
global_only: bool = False,
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
q = """
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id,
m.profile_id, m.source, m.created_at, m.updated_at,
f.name_de AS food_name_de, f.bls_code, f.catalog_kind
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
"""
conds, params = [], []
if global_only:
conds.append("m.profile_id IS NULL")
elif profile_id:
conds.append("m.profile_id = %s")
params.append(profile_id)
if conds:
q += " WHERE " + " AND ".join(conds)
q += " ORDER BY m.source_name_normalized"
cur.execute(q, params)
return [r2d(r) for r in cur.fetchall()]
@router.get("/stats/coverage")
def mapping_coverage(session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT
COUNT(*) AS total_items,
COUNT(food_id) AS mapped_items,
COUNT(*) - COUNT(food_id) AS unmapped_items,
COUNT(DISTINCT source_name_normalized) AS unique_names,
COUNT(DISTINCT CASE WHEN food_id IS NULL THEN source_name_normalized END) AS unmapped_names
FROM nutrition_items
"""
)
return r2d(cur.fetchone())
@router.post("")
def create_food_mapping(body: FoodMappingCreate, session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM food_catalog WHERE id = %s", (body.food_id,))
if not cur.fetchone():
raise HTTPException(404, "Lebensmittel nicht gefunden")
mid = upsert_food_mapping(
cur,
source_name_raw=body.source_name,
food_id=body.food_id,
profile_id=body.profile_id or None,
source="admin",
source_system=body.source_system,
)
cur.execute(
"""
SELECT m.*, f.name_de AS food_name_de, f.bls_code
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.id = %s
""",
(mid,),
)
return r2d(cur.fetchone())
@router.put("/{mapping_id}")
def update_food_mapping(mapping_id: int, body: FoodMappingUpdate, session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, source_name_raw, source_name_normalized, profile_id
FROM food_name_mappings WHERE id = %s
""",
(mapping_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Mapping nicht gefunden")
cur.execute("SELECT id FROM food_catalog WHERE id = %s AND is_active = true", (body.food_id,))
if not cur.fetchone():
raise HTTPException(404, "Lebensmittel nicht gefunden")
cur.execute(
"""
UPDATE food_name_mappings
SET food_id = %s, grams_per_unit = COALESCE(%s, grams_per_unit),
source_unit = COALESCE(%s, source_unit), source = 'admin', updated_at = NOW()
WHERE id = %s
""",
(body.food_id, body.grams_per_unit, body.source_unit, mapping_id),
)
if row.get("profile_id"):
apply_mapping_to_items(cur, str(row["profile_id"]), row["source_name_normalized"], body.food_id, mapping_id)
dates = dates_for_normalized_name(cur, str(row["profile_id"]), row["source_name_normalized"])
for d in dates:
rebuild_daily_nutrients(cur, str(row["profile_id"]), d)
cur.execute(
"""
SELECT m.*, f.name_de AS food_name_de, f.bls_code
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.id = %s
""",
(mapping_id,),
)
return r2d(cur.fetchone())
@router.delete("/{mapping_id}")
def delete_food_mapping(mapping_id: int, session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM food_name_mappings WHERE id = %s RETURNING id", (mapping_id,))
if not cur.fetchone():
raise HTTPException(404, "Mapping nicht gefunden")
return {"ok": True}

252
backend/routers/bls.py Normal file
View File

@ -0,0 +1,252 @@
"""Authenticated catalog search and user-owned foods / mappings."""
from __future__ import annotations
import logging
import threading
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from auth import require_auth
from data_layer.food_mapping import (
apply_mapping_to_items,
apply_quantities_to_items,
clear_mapping_from_items,
list_quantity_units,
normalize_food_name,
upsert_food_mapping,
)
from data_layer.food_suggest import (
invalidate_suggest_index,
suggest_batch,
suggest_catalog_foods_ranked,
)
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
from db import get_cursor, get_db, r2d
from routers.profiles import get_pid
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/bls", tags=["bls"])
class UserFoodCreate(BaseModel):
name_de: str
name_en: Optional[str] = None
macros_per_100g: Optional[dict] = None
attributes: Optional[dict] = None
serving_g: Optional[float] = None
class MappingUpsert(BaseModel):
source_name: str
food_id: str
source_system: str = "fddb"
grams_per_unit: Optional[float] = None
source_unit: Optional[str] = None
class SuggestBatchBody(BaseModel):
names: list[str]
limit: int = 3
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
return x_profile_id or session["profile_id"]
def _rebuild_days(cur, profile_id: str, dates: list[str]) -> None:
for d in dates:
rebuild_daily_nutrients(cur, profile_id, d)
def _rebuild_days_bg(profile_id: str, dates: list[str], context: str) -> None:
if not dates:
return
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), profile_id, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context)
def _schedule_rebuild(profile_id: str, dates: list[str], context: str) -> None:
threading.Thread(
target=_rebuild_days_bg,
args=(profile_id, list(dates), context),
daemon=True,
name="nutrition-rebuild",
).start()
@router.get("/units")
def list_food_units(session: dict = Depends(require_auth)):
return list_quantity_units()
@router.get("/foods")
def search_foods(
q: str = "",
limit: int = 20,
session: dict = Depends(require_auth),
):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
@router.get("/attributes")
def list_food_attributes(
q: str = "",
limit: int = 40,
session: dict = Depends(require_auth),
):
from data_layer.food_attributes import list_numeric_attributes
with get_db() as conn:
return list_numeric_attributes(get_cursor(conn), q, limit=limit)
@router.post("/foods/suggest-batch")
def suggest_foods_batch(
body: SuggestBatchBody,
session: dict = Depends(require_auth),
):
names = [n for n in (body.names or []) if isinstance(n, str)][:80]
limit = min(max(body.limit or 3, 1), 5)
with get_db() as conn:
cur = get_cursor(conn)
return suggest_batch(cur, session["profile_id"], names, limit=limit)
@router.post("/foods/manual")
def create_user_food(
body: UserFoodCreate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
import uuid
pid = _pid(session, x_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(
"""
INSERT INTO food_catalog
(name_de, name_en, catalog_kind, owner_profile_id, source, external_key)
VALUES (%s, %s, 'manual_user', %s, 'manual', %s)
RETURNING *
""",
(name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:32]}-{uuid.uuid4().hex[:8]}"),
)
food = r2d(cur.fetchone())
write_numeric_attributes(cur, food["id"], values)
invalidate_suggest_index(pid)
return food
@router.get("/mappings")
def list_my_mappings(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id, m.source,
m.grams_per_unit, m.source_unit,
f.name_de AS food_name_de, f.bls_code, f.catalog_kind
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.profile_id = %s
ORDER BY m.source_name_normalized
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/mappings")
def upsert_my_mapping(
body: MappingUpsert,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name_de, bls_code, catalog_kind FROM food_catalog
WHERE id = %s AND is_active = true
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
""",
(body.food_id, pid),
)
food = cur.fetchone()
if not food:
raise HTTPException(404, "Lebensmittel nicht gefunden")
mid = upsert_food_mapping(
cur,
source_name_raw=body.source_name,
food_id=body.food_id,
profile_id=pid,
source="bulk",
source_system=body.source_system,
grams_per_unit=body.grams_per_unit,
source_unit=body.source_unit,
)
norm = normalize_food_name(body.source_name)
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
dates = dates_for_normalized_name(cur, pid, norm)
_schedule_rebuild(pid, dates, f"Mapping {norm}")
return {
"mapping_id": mid,
"items_updated": n,
"source_name_normalized": norm,
"food_id": body.food_id,
"food_name_de": food["name_de"],
"bls_code": food.get("bls_code"),
"catalog_kind": food.get("catalog_kind"),
}
@router.delete("/mappings/{mapping_id}")
def delete_my_mapping(
mapping_id: int,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT source_name_normalized FROM food_name_mappings
WHERE id = %s AND profile_id = %s
""",
(mapping_id, pid),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Mapping nicht gefunden")
norm = row["source_name_normalized"]
dates = dates_for_normalized_name(cur, pid, norm)
clear_mapping_from_items(cur, pid, norm)
cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
_schedule_rebuild(pid, dates, "Mapping-Löschen")
return {"ok": True}
# keep get_pid imported for consistency with other routers
_ = get_pid

View File

@ -35,6 +35,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp
from csv_parser.field_units import source_unit_choices_for_field from csv_parser.field_units import source_unit_choices_for_field
from csv_parser.import_errors import enrich_row_error from csv_parser.import_errors import enrich_row_error
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
from csv_parser.template_validator import validate_csv_template
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
@ -50,6 +51,38 @@ def _load_import_limits() -> dict[str, int]:
return get_csv_import_limits(r2d(row) if row else None) return get_csv_import_limits(r2d(row) if row else None)
def _mapping_column_signature(m: dict) -> list[str] | None:
sig = m.get("column_signature")
if not sig:
return None
return list(sig)
def _validate_mapping_config(cur, m: dict) -> dict:
"""Strukturelle Vorlagen-Prüfung (wie Admin validate / Create)."""
return validate_csv_template(
m["module"],
m.get("field_mappings") or {},
m.get("type_conversions"),
m.get("import_row_processing"),
_mapping_column_signature(m),
cur=cur,
)
def _ensure_mapping_valid(cur, m: dict) -> dict:
report = _validate_mapping_config(cur, m)
if not report.get("valid"):
raise HTTPException(
status_code=422,
detail={
"message": "CSV-Vorlage ist strukturell ungültig.",
"validation": report,
},
)
return report
def _mapping_to_summary(m: dict) -> dict: def _mapping_to_summary(m: dict) -> dict:
return { return {
"id": m["id"], "id": m["id"],
@ -184,6 +217,16 @@ def copy_csv_mapping(
n += 1 n += 1
name = f"{base_name} {n}" name = f"{base_name} {n}"
validation = _validate_mapping_config(cur, src)
if not validation.get("valid"):
raise HTTPException(
status_code=422,
detail={
"message": "Quell-Vorlage ist ungültig — Kopie abgebrochen.",
"validation": validation,
},
)
cur.execute( cur.execute(
""" """
INSERT INTO csv_field_mappings ( INSERT INTO csv_field_mappings (
@ -213,7 +256,21 @@ def copy_csv_mapping(
), ),
) )
new_id = cur.fetchone()["id"] new_id = cur.fetchone()["id"]
return {"new_mapping_id": new_id, "mapping_name": name} return {"new_mapping_id": new_id, "mapping_name": name, "validation": validation}
@router.get("/mappings/{mapping_id}/validate")
def validate_csv_mapping(
mapping_id: int,
session: dict = Depends(require_auth),
):
"""Strukturprüfung einer gespeicherten Vorlage (System oder eigenes Profil-Mapping)."""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
m = _fetch_mapping_row(cur, mapping_id, pid)
report = _validate_mapping_config(cur, m)
return {"mapping_id": mapping_id, "mapping_name": m.get("mapping_name"), **report}
@router.post("/analyze") @router.post("/analyze")
@ -538,6 +595,7 @@ async def csv_import_execute(
) )
_check_module_feature_access(pid, exec_module) _check_module_feature_access(pid, exec_module)
_ensure_mapping_valid(cur, m)
cur.execute( cur.execute(
""" """
@ -654,6 +712,8 @@ async def csv_import_execute(
"updated": result["rows_updated"], "updated": result["rows_updated"],
"skipped": result["rows_skipped"], "skipped": result["rows_skipped"],
"errors": result["rows_errors"], "errors": result["rows_errors"],
"items_written": result.get("items_written", 0),
"unmapped_names": result.get("unmapped_names", 0),
}, },
"error_details": result["error_details"], "error_details": result["error_details"],
} }

View File

@ -11,6 +11,8 @@ from typing import Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from fastapi.responses import Response
from pydantic import BaseModel
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
@ -22,6 +24,22 @@ router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RecipeIngredientIn(BaseModel):
source_name_raw: str
quantity_raw: Optional[str] = None
quantity_g: Optional[float] = None
quantity_amount: Optional[float] = None
source_unit: Optional[str] = None
grams_per_unit: Optional[float] = None
class RecipeIn(BaseModel):
name_raw: str
portions: float = 1
description: Optional[str] = None
ingredients: list[RecipeIngredientIn] = []
# ── Helper ──────────────────────────────────────────────────────────────────── # ── Helper ────────────────────────────────────────────────────────────────────
def _pf(s): def _pf(s):
"""Parse float from string (handles comma decimal separator).""" """Parse float from string (handles comma decimal separator)."""
@ -31,8 +49,13 @@ def _pf(s):
# ── Endpoints ───────────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/import-csv") @router.post("/import-csv")
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): async def import_nutrition_csv(
"""Import FDDB nutrition CSV.""" file: UploadFile = File(...),
overwrite_existing: bool = False,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""Import FDDB nutrition CSV (optional item persist + conflict policy)."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 4: Check feature access and ENFORCE
@ -56,48 +79,97 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
except: text = raw.decode('latin-1') except: text = raw.decode('latin-1')
if text.startswith('\ufeff'): text = text[1:] if text.startswith('\ufeff'): text = text[1:]
if not text.strip(): raise HTTPException(400,"Leere Datei") if not text.strip(): raise HTTPException(400,"Leere Datei")
from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
overwrite = bool(overwrite_existing)
reader = csv.DictReader(io.StringIO(text), delimiter=';') reader = csv.DictReader(io.StringIO(text), delimiter=';')
item_rows = []
days: dict = {} days: dict = {}
count = 0 count = 0
for row in reader: for row in reader:
rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"') rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
if not rd: continue if not rd: continue
try: try:
p = rd.split(' ')[0].split('.') parts = rd.split(' ')
p = parts[0].split('.')
iso = f"{p[2]}-{p[1]}-{p[0]}" iso = f"{p[2]}-{p[1]}-{p[0]}"
except: continue logged_at = None
days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0}) if len(parts) > 1:
days[iso]['kcal'] += _pf(row.get('kj',0))/4.184 try:
days[iso]['fat_g'] += _pf(row.get('fett_g',0)) logged_at = datetime.strptime(rd.strip(), '%d.%m.%Y %H:%M')
days[iso]['carbs_g'] += _pf(row.get('kh_g',0)) except ValueError:
days[iso]['protein_g'] += _pf(row.get('protein_g',0)) logged_at = None
count+=1 except Exception:
inserted=0 continue
new_entries=0 kcal = _pf(row.get('kj', 0)) / 4.184
fat = _pf(row.get('fett_g', 0))
carbs = _pf(row.get('kh_g', 0))
prot = _pf(row.get('protein_g', 0))
days.setdefault(iso, {'kcal': 0, 'fat_g': 0, 'carbs_g': 0, 'protein_g': 0})
days[iso]['kcal'] += kcal
days[iso]['fat_g'] += fat
days[iso]['carbs_g'] += carbs
days[iso]['protein_g'] += prot
name = (row.get('bezeichnung') or '').strip().strip('"')
if name:
item_rows.append({
"date": iso,
"logged_at": logged_at,
"food_name": name,
"quantity_raw": (row.get('menge') or '').strip() or None,
"kcal": kcal,
"protein_g": prot,
"fat_g": fat,
"carbs_g": carbs,
})
count += 1
inserted = 0
new_entries = 0
ingest_result = {"conflicts": [], "items_written": 0, "policy": "prompt"}
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
for iso,vals in days.items(): policy = get_import_policy(cur, pid)
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1) override = "overwrite_catalog" if overwrite else None
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1) if item_rows:
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso)) ingest_result = replace_csv_items_for_dates(
is_new = not cur.fetchone() cur, pid, item_rows, policy=policy, policy_override=override,
if not is_new: )
# UPDATE existing new_entries = ingest_result.get("new_log_days") or 0
cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s", inserted = ingest_result.get("days_written") or 0
(kcal,prot,fat,carbs,pid,iso)) else:
else: for iso, vals in days.items():
# INSERT new kcal = round(vals['kcal'], 1)
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)", fat = round(vals['fat_g'], 1)
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs)) carbs = round(vals['carbs_g'], 1)
new_entries += 1 prot = round(vals['protein_g'], 1)
inserted+=1 cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, iso))
is_new = not cur.fetchone()
if not is_new:
if policy in ("overwrite_catalog", "overwrite_fddb") or overwrite:
cur.execute(
"UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s,source='csv',macro_origin='fddb' WHERE profile_id=%s AND date=%s",
(kcal, prot, fat, carbs, pid, iso),
)
else:
cur.execute(
"INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,macro_origin,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv','fddb',CURRENT_TIMESTAMP)",
(str(uuid.uuid4()), pid, iso, kcal, prot, fat, carbs),
)
new_entries += 1
inserted += 1
# Phase 2: Increment usage counter for each new entry created
for _ in range(new_entries): for _ in range(new_entries):
increment_feature_usage(pid, 'nutrition_entries') increment_feature_usage(pid, 'nutrition_entries')
return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries, return {
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} "rows_parsed": count,
"days_imported": inserted,
"new_entries": new_entries,
"items_written": ingest_result.get("items_written", 0),
"conflicts": ingest_result.get("conflicts") or [],
"policy": ingest_result.get("policy", "prompt"),
"date_range": {"from": min(days) if days else None, "to": max(days) if days else None},
}
@router.post("") @router.post("")
@ -122,7 +194,7 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
# UPDATE existing entry # UPDATE existing entry
cur.execute(""" cur.execute("""
UPDATE nutrition_log UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual' SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
WHERE id=%s AND profile_id=%s WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid)) """, (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
return {"success": True, "mode": "updated", "id": existing['id']} return {"success": True, "mode": "updated", "id": existing['id']}
@ -145,8 +217,8 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car
# INSERT new entry # INSERT new entry
new_id = str(uuid.uuid4()) new_id = str(uuid.uuid4())
cur.execute(""" cur.execute("""
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created) INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, macro_origin, created)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP) VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', 'manual', CURRENT_TIMESTAMP)
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1))) """, (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
# Phase 2: Increment usage counter # Phase 2: Increment usage counter
@ -162,8 +234,34 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
"SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) """
return [r2d(r) for r in cur.fetchall()] SELECT n.*, m.mark_type, m.note AS mark_note
FROM nutrition_log n
LEFT JOIN nutrition_day_marks m
ON m.profile_id = n.profile_id AND m.date = n.date
WHERE n.profile_id=%s
ORDER BY n.date DESC
LIMIT %s
""",
(pid, limit),
)
rows = [r2d(r) for r in cur.fetchall()]
dates = [r["date"] for r in rows if r.get("date") is not None]
counts = {}
if dates:
cur.execute(
"""
SELECT date, COUNT(*) AS item_count
FROM nutrition_items
WHERE profile_id = %s AND date = ANY(%s)
GROUP BY date
""",
(pid, dates),
)
counts = {str(r["date"]): int(r["item_count"] or 0) for r in cur.fetchall()}
for r in rows:
r["item_count"] = counts.get(str(r.get("date")), 0)
return rows
@router.get("/by-date/{date}") @router.get("/by-date/{date}")
@ -228,6 +326,382 @@ def import_history(x_profile_id: Optional[str]=Header(default=None), session: di
return [r2d(r) for r in cur.fetchall()] return [r2d(r) for r in cur.fetchall()]
@router.get("/items")
def list_nutrition_items(
date: Optional[str] = None,
limit: int = 200,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
if date:
cur.execute(
"""
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
FROM nutrition_items i
LEFT JOIN food_catalog f ON f.id = i.food_id
WHERE i.profile_id=%s AND i.date=%s
ORDER BY i.logged_at NULLS LAST, i.source_name_raw
""",
(pid, date),
)
else:
cur.execute(
"""
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
FROM nutrition_items i
LEFT JOIN food_catalog f ON f.id = i.food_id
WHERE i.profile_id=%s
ORDER BY i.date DESC, i.logged_at NULLS LAST
LIMIT %s
""",
(pid, min(limit, 500)),
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/unmapped")
def list_unmapped_foods(
since_days: int = 0,
count_only: bool = False,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_mapping import (
as_iso_date,
filter_unmapped_since,
merge_unmapped_rows,
normalize_food_name,
sort_unmapped_rows,
)
pid = x_profile_id or session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT source_name_normalized
FROM food_name_mappings
WHERE profile_id = %s
""",
(pid,),
)
mapped = {r["source_name_normalized"] for r in cur.fetchall()}
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
MIN(r.id::text) AS matching_recipe_id,
MIN(i.quantity_raw) AS sample_quantity_raw
FROM nutrition_items i
LEFT JOIN food_recipes r
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized
ORDER BY count DESC, i.source_name_normalized
""",
(pid,),
)
diary = [r2d(r) | {"kind": "diary"} for r in cur.fetchall()]
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
COUNT(*) AS count, u.first_used AS first_date, u.last_used AS last_date,
MIN(i.quantity_raw) AS sample_quantity_raw
FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m
ON m.profile_id = r.profile_id
AND m.source_name_normalized = i.source_name_normalized
LEFT JOIN (
SELECT r2.id AS recipe_id, MIN(ni.date) AS first_used, MAX(ni.date) AS last_used
FROM food_recipes r2
JOIN nutrition_items ni
ON ni.profile_id = r2.profile_id
AND (ni.recipe_id = r2.id OR ni.source_name_normalized = r2.name_normalized)
WHERE r2.profile_id = %s
GROUP BY r2.id
) u ON u.recipe_id = r.id
WHERE r.profile_id = %s AND m.id IS NULL
GROUP BY i.source_name_raw, i.source_name_normalized, u.first_used, u.last_used
ORDER BY count DESC, i.source_name_normalized
""",
(pid, pid),
)
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
merged = merge_unmapped_rows(diary + ings)
out = []
for row in merged:
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
if key in mapped:
continue
row["last_date"] = as_iso_date(row.get("last_date"))
row["first_date"] = as_iso_date(row.get("first_date"))
out.append(row)
days = max(0, min(int(since_days or 0), 3650))
recent = sort_unmapped_rows(filter_unmapped_since(out, days), days)
if count_only:
return {"count": len(recent), "total": len(out), "since_days": days}
return recent if days else sort_unmapped_rows(out, 0)
@router.get("/recipes")
def list_food_recipes(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import list_recipes
pid = get_pid(x_profile_id)
with get_db() as conn:
return list_recipes(get_cursor(conn), pid)
@router.post("/recipes")
def create_food_recipe(
body: RecipeIn,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import save_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
return save_recipe(get_cursor(conn), pid, body.model_dump())
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.put("/recipes/{recipe_id}")
def update_food_recipe(
recipe_id: str,
body: RecipeIn,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import save_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
return save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id)
except KeyError:
raise HTTPException(404, "Rezept nicht gefunden") from None
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.delete("/recipes/{recipe_id}")
def delete_food_recipe(
recipe_id: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_recipes import delete_recipe
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
delete_recipe(get_cursor(conn), pid, recipe_id)
except KeyError:
raise HTTPException(404, "Rezept nicht gefunden") from None
return {"ok": True}
@router.post("/recipes/import-fddb-lists")
async def import_fddb_lists(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from bls.recipe_parser import parse_fddb_lists_csv
from data_layer.food_recipes import upsert_recipes
pid = get_pid(x_profile_id)
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError:
text = raw.decode("latin-1")
recipes = parse_fddb_lists_csv(text)
if not recipes:
raise HTTPException(400, "Keine Rezepte in der Datei erkannt")
with get_db() as conn:
cur = get_cursor(conn)
stats = upsert_recipes(cur, pid, recipes)
from data_layer.nutrition_items import rebuild_daily_nutrients
for d in stats.pop("dates_linked", []) or []:
rebuild_daily_nutrients(cur, pid, d)
return {"ok": True, "recipes": len(recipes), **stats}
@router.post("/recipes/{recipe_id}/apply")
def apply_recipe_name(
recipe_id: str,
body: dict,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.food_mapping import normalize_food_name
from data_layer.food_recipes import apply_recipe_to_items
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
pid = get_pid(x_profile_id)
source_name = (body.get("source_name") or "").strip()
if not source_name:
raise HTTPException(400, "source_name fehlt")
norm = normalize_food_name(source_name)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s", (recipe_id, pid))
if not cur.fetchone():
raise HTTPException(404, "Rezept nicht gefunden")
n = apply_recipe_to_items(cur, pid, norm, recipe_id)
for d in dates_for_normalized_name(cur, pid, norm):
rebuild_daily_nutrients(cur, pid, d)
return {"ok": True, "items_updated": n}
@router.get("/food-knowledge")
def export_food_knowledge_file(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
import json
from data_layer.food_knowledge import export_food_knowledge
pid = get_pid(x_profile_id)
try:
with get_db() as conn:
bundle = export_food_knowledge(get_cursor(conn), pid)
body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str)
except Exception:
logger.exception("Export food-knowledge fehlgeschlagen")
raise HTTPException(500, "Export fehlgeschlagen") from None
stamp = datetime.now().strftime("%Y-%m-%d")
return Response(
content=body.encode("utf-8"),
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="mitai-food-knowledge-{stamp}.json"'},
)
@router.post("/food-knowledge")
async def import_food_knowledge_file(
file: UploadFile = File(...),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
import json
from data_layer.food_knowledge import import_food_knowledge, parse_food_knowledge_bundle
pid = get_pid(x_profile_id)
raw = await file.read()
if not raw:
raise HTTPException(400, "Leere Datei")
try:
data = json.loads(raw.decode("utf-8-sig"))
parse_food_knowledge_bundle(data)
except ValueError as e:
raise HTTPException(400, str(e)) from e
except Exception as e:
raise HTTPException(400, f"Ungültiges JSON: {e}") from e
with get_db() as conn:
return import_food_knowledge(get_cursor(conn), pid, data)
@router.post("/import-conflicts/resolve")
def resolve_import_conflicts(
body: dict,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from data_layer.nutrition_items import (
apply_nutrition_day_macros,
compute_day_macro_sums,
resolve_choice_macros,
)
pid = get_pid(x_profile_id)
decisions = body.get("decisions") or []
applied = 0
with get_db() as conn:
cur = get_cursor(conn)
for dec in decisions:
day = dec.get("date")
choice = dec.get("choice")
if not day or not choice:
continue
sums = compute_day_macro_sums(cur, pid, day)
if not sums.get("existing") and choice == "existing":
continue
macros, origin = resolve_choice_macros(sums, choice)
apply_nutrition_day_macros(
cur, pid, day, macros, macro_origin=origin, source="csv", confirm=True,
)
applied += 1
return {"applied": applied}
@router.put("/days/{day}/mark")
def upsert_day_mark(
day: str,
body: dict,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
mark_type = (body or {}).get("mark_type")
note = (body or {}).get("note")
if mark_type not in ("fasting", "incomplete"):
raise HTTPException(400, "mark_type muss fasting oder incomplete sein")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
INSERT INTO nutrition_day_marks (profile_id, date, mark_type, note, source, updated_at)
VALUES (%s, %s, %s, %s, 'manual', NOW())
ON CONFLICT (profile_id, date)
DO UPDATE SET mark_type = EXCLUDED.mark_type, note = EXCLUDED.note, updated_at = NOW()
RETURNING *
""",
(pid, day, mark_type, note),
)
return r2d(cur.fetchone())
@router.delete("/days/{day}/mark")
def delete_day_mark(
day: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM nutrition_day_marks WHERE profile_id=%s AND date=%s",
(pid, day),
)
return {"ok": True}
@router.get("/marks")
def list_day_marks(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT * FROM nutrition_day_marks WHERE profile_id=%s ORDER BY date DESC",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.put("/{entry_id}") @router.put("/{entry_id}")
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float, def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
@ -242,7 +716,7 @@ def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float,
cur.execute(""" cur.execute("""
UPDATE nutrition_log UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual', macro_origin='manual'
WHERE id=%s AND profile_id=%s WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid)) """, (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))

View File

@ -109,9 +109,17 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
data["verification_expires"] = None data["verification_expires"] = None
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
allowed_nutrition_policy = {
"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing",
}
for k, v in patch.items(): for k, v in patch.items():
if k == "email": if k == "email":
continue continue
if k == "nutrition_import_conflict_policy":
if v not in allowed_nutrition_policy:
raise HTTPException(400, "Ungültige Import-Policy")
data[k] = v
continue
if v is None and k in nullable_keys: if v is None and k in nullable_keys:
data[k] = None data[k] = None
elif v is not None: elif v is not None:

View File

@ -0,0 +1,110 @@
"""Feature-Enforcement für Legacy Activity CSV-Import (#37)."""
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from routers.activity import import_activity_csv
def test_import_activity_csv_blocks_when_limit_reached():
upload = AsyncMock()
upload.read = AsyncMock(return_value=b"Workout Type,Start\n")
with patch("routers.activity.get_pid", return_value="profile-1"), patch(
"routers.activity.check_feature_access",
return_value={
"allowed": False,
"reason": "limit_exceeded",
"used": 30,
"limit": 30,
},
), patch("routers.activity.log_feature_usage"):
with pytest.raises(HTTPException) as exc:
asyncio.run(
import_activity_csv(
file=upload,
x_profile_id=None,
session={"profile_id": "profile-1"},
)
)
assert exc.value.status_code == 403
def test_import_activity_csv_increments_usage_per_insert(monkeypatch):
upload = AsyncMock()
upload.read = AsyncMock(
return_value=(
"Workout Type,Start,Duration,Aktive Energie (kJ),Ruheeinträge (kJ),"
"Durchschn. Herzfrequenz (count/min),Max. Herzfrequenz (count/min),Distanz (km),End\n"
"Running,2026-07-22 10:00:00 +0200,0:45:00,1000,500,140,160,5.0,\n"
).encode("utf-8")
)
increments = []
class FakeCursor:
def execute(self, *args, **kwargs):
return None
def fetchone(self):
return None
class FakeConn:
def __enter__(self):
return self
def __exit__(self, *args):
return False
fake_cur = FakeCursor()
monkeypatch.setattr("routers.activity.get_pid", lambda _h: "profile-1")
monkeypatch.setattr(
"routers.activity.check_feature_access",
lambda *_a, **_k: {"allowed": True, "reason": "ok", "used": 0, "limit": 100},
)
monkeypatch.setattr("routers.activity.log_feature_usage", lambda *_a, **_k: None)
monkeypatch.setattr("routers.activity.get_db", lambda: FakeConn())
monkeypatch.setattr("routers.activity.get_cursor", lambda _c: fake_cur)
monkeypatch.setattr(
"routers.activity.normalize_activity_start",
lambda _s: ("2026-07-22", "10:00:00"),
)
monkeypatch.setattr(
"routers.activity.get_training_type_for_activity",
lambda *_a, **_k: (1, "cardio", None),
)
monkeypatch.setattr(
"routers.activity.find_activity_duplicate_id",
lambda *_a, **_k: None,
)
monkeypatch.setattr(
"routers.activity.new_activity_id",
lambda: "new-id",
)
monkeypatch.setattr(
"routers.activity.insert_activity_csv_minimal",
lambda *_a, **_k: None,
)
monkeypatch.setattr(
"routers.activity.run_activity_post_write_hooks_import",
lambda *_a, **_k: None,
)
monkeypatch.setattr(
"routers.activity.increment_feature_usage",
lambda _pid, feature: increments.append(feature),
)
result = asyncio.run(
import_activity_csv(
file=upload,
x_profile_id=None,
session={"profile_id": "profile-1"},
)
)
assert result["inserted"] == 1
assert increments == ["activity_entries"]

View File

@ -0,0 +1,65 @@
import time
from io import BytesIO
from openpyxl import Workbook
from bls.import_service import should_persist_value
from bls.jobs import create_and_check, get_job
from bls.parser import parse_components_xlsx, parse_foods_xlsx
def _xlsx(rows):
wb = Workbook()
ws = wb.active
for row in rows:
ws.append(row)
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def test_parse_components_dynamic():
data = _xlsx([
["Code", "Name_DE", "Name_EN", "Einheit"],
["ENERCC", "Energie", "Energy", "kcal/100g"],
["NA", "Natrium", "Sodium", "mg/100g"],
])
attrs = parse_components_xlsx(data)
keys = {a["attr_key"] for a in attrs}
assert "ENERCC" in keys
assert "NA" in keys
def test_parse_foods_keeps_bls_code():
data = _xlsx([
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
["C131000", "Hafer roh", "Oats raw", 350, "Analyse", "MRI"],
])
parsed = parse_foods_xlsx(data)
assert parsed["foods"][0]["bls_code"] == "C131000"
assert parsed["foods"][0]["name_de"] == "Hafer roh"
vals = {v["attr_key"]: v["value_num"] for v in parsed["foods"][0]["values"]}
assert vals.get("ENERCC") == 350
def test_persist_only_numeric_or_trace():
assert should_persist_value({"value_num": 1.2, "is_trace": False})
assert should_persist_value({"value_num": None, "is_trace": True})
assert not should_persist_value({"value_num": None, "is_trace": False})
def test_import_job_check_does_not_need_http():
data = _xlsx([
["BLS Code", "Name", "Food name", "ENERCC Energie [kcal/100g]", "ENERCC Herkunft", "ENERCC Referenz"],
["C131000", "Hafer roh", "Oats raw", 350, "Analyse", "MRI"],
])
job_id = create_and_check("foods", data)
job = None
for _ in range(80):
job = get_job(job_id)
if job and job["status"] in ("checked", "error"):
break
time.sleep(0.05)
assert job is not None
assert job["status"] == "checked", job.get("error")
assert job["check"]["foods"] == 1

View File

@ -0,0 +1,26 @@
"""Tests für CSV-Vorlagen-Validierung (#71)."""
from csv_parser.template_validator import validate_csv_template
def test_validate_csv_template_rejects_invalid_row_processing():
report = validate_csv_template(
"nutrition",
{"Datum": "date", "kcal": "kcal"},
None,
{"group_by": ["date"], "aggregates": {"kcal": "not_an_op"}},
["Datum", "kcal"],
)
assert report["valid"] is False
assert any(e.get("code") == "invalid_import_row_processing" for e in report["errors"])
def test_validate_csv_template_accepts_nutrition_aggregation():
report = validate_csv_template(
"nutrition",
{"Datum": "date", "kcal": "kcal", "protein": "protein_g"},
None,
{"group_by": ["date"], "aggregates": {"kcal": "sum", "protein_g": "sum"}},
["Datum", "kcal", "protein"],
)
assert report["valid"] is True

View File

@ -0,0 +1,19 @@
from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g
def test_scale_serving_to_per_100g():
assert scale_to_per_100g(1.1, 8) == 1.1 * (100 / 8)
assert scale_to_per_100g(10, 100) == 10
assert scale_to_per_100g(10, None) == 10
def test_norsan_label_epa_converts():
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
vals = macros_and_attributes_to_values(
{"kcal": 72, "protein_g": 0, "fat_g": 8, "carbs_g": 0},
{"EPA": 1.1},
serving_g=8,
)
assert abs(vals["FAT"] - 100) < 0.01
assert abs(vals["EPA"] - 13.75) < 0.01
assert abs(vals["ENERCC"] - 900) < 0.01

View File

@ -0,0 +1,57 @@
from decimal import Decimal
from uuid import uuid4
from data_layer.food_knowledge import (
BUNDLE_FORMAT,
_jsonable,
parse_food_knowledge_bundle,
portable_mapping,
)
def test_parse_rejects_unknown_format():
try:
parse_food_knowledge_bundle({"format": "other", "version": 1})
except ValueError as e:
assert "Mitai-Zuordnungsdatei" in str(e)
else:
raise AssertionError("expected ValueError")
def test_parse_accepts_bundle():
data = parse_food_knowledge_bundle({
"format": BUNDLE_FORMAT,
"version": 1,
"mappings": [],
"recipes": [],
})
assert data["version"] == 1
def test_portable_mapping_drops_ids():
row = portable_mapping({
"id": 99,
"food_id": "uuid-here",
"source_system": "fddb",
"source_name_raw": "Haferflocken, Großblatt",
"source_name_normalized": "haferflocken großblatt",
"bls_code": "C131000",
"food_name_de": "Hafer roh",
"catalog_kind": "official_bls",
"external_key": None,
})
assert "id" not in row
assert "food_id" not in row
assert row["bls_code"] == "C131000"
assert row["source_name_raw"] == "Haferflocken, Großblatt"
def test_jsonable_export_types():
payload = _jsonable({
"grams": Decimal("1.50"),
"id": uuid4(),
"nested": [Decimal("2")],
})
assert isinstance(payload["grams"], float)
assert isinstance(payload["id"], str)
assert payload["nested"] == [2.0]

View File

@ -0,0 +1,89 @@
from datetime import date
from csv_parser.executor import guess_nutrition_item_fields
from data_layer.food_mapping import (
filter_unmapped_since,
list_quantity_units,
merge_unmapped_rows,
normalize_food_name,
parse_quantity,
parse_quantity_g,
primary_search_query,
resolve_quantity,
)
from data_layer.nutrition_items import macros_differ
def test_primary_query_keeps_decimal_comma():
assert primary_search_query("Joghurt 9,5%") == "Joghurt 9,5%"
assert primary_search_query("Joghurt, aus Kuhmilch") == "Joghurt"
def test_normalize_strips_leading_quantity():
assert normalize_food_name("50 g Hähnchen") == "hähnchen"
assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%"
assert normalize_food_name("1 ml Olivenöl") == normalize_food_name("2 ml Olivenöl") == "olivenöl"
def test_merge_unmapped_collapses_quantity_variants():
rows = merge_unmapped_rows([
{"source_name_raw": "1 ml Olivenöl", "source_name_normalized": "1 ml olivenöl", "count": 3, "kind": "diary"},
{"source_name_raw": "2 ml Olivenöl", "source_name_normalized": "2 ml olivenöl", "count": 1, "kind": "diary"},
])
assert len(rows) == 1
assert rows[0]["source_name_normalized"] == "olivenöl"
assert rows[0]["count"] == 4
assert rows[0]["source_name_raw"].lower() == "olivenöl"
def test_parse_quantity_g():
assert parse_quantity_g("150 g") == 150.0
assert parse_quantity_g("150") == 150.0
assert parse_quantity_g("1 kg") == 1000.0
assert parse_quantity_g("5 ml") == 5.0
assert parse_quantity_g("1 Stück") is None
assert parse_quantity_g("1 Stück", 60) == 60.0
assert parse_quantity_g("2 EL") == 30.0
assert parse_quantity("1 Scheibe")["needs_unit_map"] is True
assert parse_quantity("2 EL")["unit"] == "el"
def test_resolve_quantity_household_units():
two_tl = resolve_quantity(quantity_amount=2, source_unit="tl")
assert two_tl["quantity_g"] == 10.0
assert two_tl["quantity_raw"] == "2 TL"
pinch = resolve_quantity(quantity_amount=1, source_unit="prise")
assert pinch["quantity_g"] == 0.3
slice_ = resolve_quantity(quantity_amount=1, source_unit="stück")
assert slice_["quantity_g"] is None
assert slice_["needs_unit_map"] is True
assert resolve_quantity(quantity_amount=1, source_unit="stück", grams_per_unit=60)["quantity_g"] == 60.0
ids = [u["id"] for u in list_quantity_units()]
assert "tl" in ids and "prise" in ids
def test_guess_fddb_bezeichnung_without_template_mapping():
name, qty = guess_nutrition_item_fields({
"bezeichnung": "50 g Hähnchen",
"menge": "50 g",
"kj": "800",
})
assert name == "50 g Hähnchen"
assert qty == "50 g"
def test_recent_window_drops_old_and_dateless_foods():
today = date(2026, 9, 12)
rows = [
{"source_name_raw": "Haferflocken", "last_date": "2026-09-10", "count": 2},
{"source_name_raw": "Weißbrot", "last_date": "2026-01-02", "count": 40},
{"source_name_raw": "Altes Rezept-Salz", "last_date": None, "count": 1},
]
recent = filter_unmapped_since(rows, 28, today=today)
assert [r["source_name_raw"] for r in recent] == ["Haferflocken"]
assert len(filter_unmapped_since(rows, 0, today=today)) == 3
def test_macros_differ_rounds():
assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})

View File

@ -0,0 +1,10 @@
from data_layer.food_recipes import save_recipe
def test_save_recipe_requires_name():
try:
save_recipe(None, "p", {"name_raw": " ", "ingredients": []})
except ValueError as e:
assert "Rezeptname" in str(e)
else:
raise AssertionError("expected ValueError")

View File

@ -0,0 +1,107 @@
from data_layer.food_suggest import (
collapse_key,
name_tokens,
score_name_match,
suggest_batch,
suggest_for_name,
)
def _index(*foods):
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
for food in foods:
food["_c"] = collapse_key(food["name_de"])
food["_t"] = name_tokens(food["name_de"])
if food["_c"]:
index["by_collapse"].setdefault(food["_c"], []).append(food)
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
if len(food["_c"]) >= 4:
index["by_suffix"].setdefault(food["_c"][-4:], []).append(food)
for tok in food["_t"]:
index["by_token"].setdefault(tok, []).append(food)
return index
def test_collapse_treats_space_as_same():
assert collapse_key("Haferflocken") == collapse_key("Hafer Flocken")
def test_haferflocken_ranks_simple_name_first():
index = {
"foods": [],
"by_collapse": {},
"by_token": {},
"by_prefix": {},
"by_suffix": {},
}
simple = {"id": "1", "name_de": "Hafer Flocken", "name_en": "oat flakes", "bls_code": "C131111", "catalog_kind": "official_bls"}
dish = {"id": "2", "name_de": "Milch-Getreide-Brei, mit Haferflocken und Apfelsaft (geeignet für Beikost)", "name_en": "", "bls_code": "X", "catalog_kind": "official_bls"}
for food in (simple, dish):
food["_c"] = collapse_key(food["name_de"])
index["by_collapse"].setdefault(food["_c"], []).append(food)
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
index["by_suffix"].setdefault(food["_c"][-4:], []).append(food)
packed = suggest_for_name(index, "Haferflocken", limit=3)
assert packed["suggestions"][0]["name_de"] == "Hafer Flocken"
assert packed["suggestions"][0]["score"] >= score_name_match("Haferflocken", dish["name_de"])
def test_close_scores_are_ambiguous():
a = {"id": "1", "name_de": "Olivenöl nativ", "name_en": "", "bls_code": "A", "catalog_kind": "official_bls"}
b = {"id": "2", "name_de": "Olivenöl raffiniert", "name_en": "", "bls_code": "B", "catalog_kind": "official_bls"}
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
for food in (a, b):
food["_c"] = collapse_key(food["name_de"])
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
for tok in food["name_de"].lower().replace(",", "").split():
if len(tok) >= 2:
index["by_token"].setdefault(tok, []).append(food)
packed = suggest_for_name(index, "Olivenöl", limit=3)
assert packed["ambiguous"] is True
assert packed["suggestion_count"] >= 2
def test_large_prefix_bucket_keeps_exact_collapse():
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
target = {"id": "hit", "name_de": "Hafer Flocken", "name_en": "", "bls_code": "C", "catalog_kind": "official_bls"}
target["_c"] = collapse_key(target["name_de"])
index["by_collapse"][target["_c"]] = [target]
index["by_prefix"][target["_c"][:4]] = [target] + [
{"id": str(i), "name_de": f"Hafer Gericht {i}", "name_en": "", "_c": f"hafergericht{i}"}
for i in range(80)
]
packed = suggest_for_name(index, "Haferflocken", limit=3)
assert packed["suggestions"][0]["name_de"] == "Hafer Flocken"
def test_suggest_batch_dedupes_names():
class _Cur:
def execute(self, *args, **kwargs):
return None
def fetchall(self):
return []
out = suggest_batch(_Cur(), None, ["Haferflocken", "Haferflocken", ""], limit=2)
assert "Haferflocken" in out
assert out["Haferflocken"]["suggestions"] == []
def test_number_tokens_keep_decimal_fat():
assert "9.5" in name_tokens("Joghurt 9,5%")
assert "10" in name_tokens("Joghurt, aus Kuhmilch, 10 % Fett")
def test_joghurt_10_ranks_above_35():
light = {"id": "a", "name_de": "Joghurt >3,5% Fett", "name_en": "", "bls_code": "M", "catalog_kind": "official_bls"}
full = {
"id": "b",
"name_de": "Joghurt, aus Kuhmilch, 10 % Fett",
"name_en": "",
"bls_code": "N",
"catalog_kind": "official_bls",
}
index = _index(light, full)
for query in ("Joghurt 10%", "Joghurt 9,5%", "Joghurt 10"):
packed = suggest_for_name(index, query, limit=3)
assert packed["suggestions"][0]["name_de"] == full["name_de"], query
assert packed["suggestions"][0]["score"] > score_name_match(query, light["name_de"])

View File

@ -0,0 +1,43 @@
from bls.recipe_parser import parse_fddb_lists_csv, parse_fddb_produkte
from data_layer.food_mapping import normalize_food_name
PORRIDGE = (
"160 g Apfel, Braeburn, 5 ml Omega-3 Vegan Algenöl, 100 g Wildheidelbeeren, "
"30 g Haferflocken, 100% Hafer-Vollkorn, 40 g Hafer Flocken, Großblatt, 11 g Flohsamenschalen"
)
def test_parse_fddb_produkte_splits_on_next_quantity_not_commas():
ings = parse_fddb_produkte(PORRIDGE)
names = [i["source_name_raw"] for i in ings]
assert names == [
"Apfel, Braeburn",
"Omega-3 Vegan Algenöl",
"Wildheidelbeeren",
"Haferflocken, 100% Hafer-Vollkorn",
"Hafer Flocken, Großblatt",
"Flohsamenschalen",
]
assert ings[0]["quantity_g"] == 160.0
assert ings[1]["quantity_g"] == 5.0
assert ings[1]["quantity_raw"] == "5 ml"
def test_parse_fddb_lists_csv_porridge_and_portions():
text = (
"name;beschreibung;anzahl_portionen;zeit_vorbereitung;zeit_kochen;produkte;\n"
'"!PorridgeBreakfast ";"";"1";"0";"0";"' + PORRIDGE + '";\n'
'"Aloo gobi";"";"4";"0";"0";"32 ml Rapso Rapsöl, 83 g Zwiebel, frisch";\n'
)
recipes = parse_fddb_lists_csv(text)
assert len(recipes) == 2
assert recipes[0]["name_raw"] == "!PorridgeBreakfast"
assert recipes[0]["name_normalized"] == normalize_food_name("PorridgeBreakfast")
assert len(recipes[0]["ingredients"]) == 6
assert recipes[1]["portions"] == 4.0
assert recipes[1]["ingredients"][0]["source_name_raw"] == "Rapso Rapsöl"
def test_normalize_strips_list_bang_prefix():
assert normalize_food_name("!PorridgeBreakfast") == normalize_food_name("PorridgeBreakfast")

View File

@ -7,9 +7,9 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor - PATCH: Bugfix, kleine Änderung, Refactor
""" """
APP_VERSION = "0.9t" APP_VERSION = "0.9v"
BUILD_DATE = "2026-04-20" BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import DB_SCHEMA_VERSION = "20260912c" # 066 recipe ingredient units
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.0", "auth": "1.2.0",
@ -19,8 +19,9 @@ MODULE_VERSIONS = {
"weight": "1.0.3", "weight": "1.0.3",
"circumference": "1.0.1", "circumference": "1.0.1",
"caliper": "1.0.1", "caliper": "1.0.1",
"activity": "1.2.0", # GET /activity: optional days= window + limit "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.0.2", "nutrition": "1.3.2", # UI: Liste/Kombination; Tandoor-Gerichte später
"bls": "1.0.5", # GET /bls/units
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
"prompts": "1.1.0", "prompts": "1.1.0",
@ -31,11 +32,43 @@ MODULE_VERSIONS = {
"membership": "2.1.0", "membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_* "app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise "csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) "admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.9v",
"date": "2026-09-12",
"changes": [
"BLS 4.0 Stammdaten (dynamische Attribute, Upsert über bls_code)",
"Lernendes FDDB-Mapping ohne KI, änder- und löschbar",
"Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken",
"BLS-Import als Hintergrundjob (kein Proxy-504)",
"Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code",
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
"Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)",
"Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten",
"Inline-Vorschläge (Haferflocken → Hafer Flocken), Bestätigen in der Zeile, Neu anlegen",
"Zuordnen: Suche und Bestätigen ohne Browser-Freeze (Index-Cache, Batch-Vorschläge, kein Seiten-Reload)",
"Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen",
"Katalogsuche: Fettgehalt (9,5 / 10 %) bleibt erhalten, Joghurt 10% vor >3,5%",
"Eigenes Lebensmittel: beliebige Katalog-Stoffe (EPA/DHA/…) plus Portionsumrechnung auf 100 g",
"Zuordnen: Mapping-Export robust; Rezepte anlegen/bearbeiten; Admin-Mapping direkt ändern",
"Rezept-Dialog: Zutaten aus Zuordnungen und Katalog suchen; Tandoor später",
"Rezeptzutaten: Menge + Einheit (EL/TL/Prise/Stück); Umrechnung auf Gramm",
"Zuordnen: Mitai = Liste/Kombination; gekochte Rezepte Tandoor + Ausbeute später",
],
},
{
"version": "0.9u",
"date": "2026-07-24",
"changes": [
"Gitea #37: Activity legacy CSV import — activity_entries feature enforcement + UsageBadge UI",
"Gitea #38: Nutrition FDDB import — UsageBadge + deaktivierte Import-Zone bei Limit",
"Gitea #71: Universal CSV — Dry-Run/Format-Check Parität import_row_processing; Mapping-Validierung bei Copy/Import; strukturierte error_details in Nutzer-UI",
],
},
{ {
"version": "0.9t", "version": "0.9t",
"date": "2026-04-20", "date": "2026-04-20",

View File

@ -26,7 +26,7 @@
|---------|--------| |---------|--------|
| Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) | | Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) |
| Active-State Bottom + Sidebar | `frontend/src/App.jsx` (`navItemActive`), `frontend/src/components/DesktopSidebar.jsx` | | Active-State Bottom + Sidebar | `frontend/src/App.jsx` (`navItemActive`), `frontend/src/components/DesktopSidebar.jsx` |
| Admin Shell & Routing | `frontend/src/layouts/AdminShell.jsx`, `frontend/src/layouts/RequireAdmin.jsx`, `frontend/src/config/adminNav.js` (`ADMIN_GROUPS`, Hubs) | | Admin Shell & Routing | `frontend/src/layouts/AdminShell.jsx`, `frontend/src/layouts/RequireAdmin.jsx`, `frontend/src/config/adminNav.js` (`ADMIN_GROUPS`, Hubs; Gruppe **Ernährung**: BLS-Import, Katalog, Attribute, FDDB-Mappings) |
| Admin-Seiten | `frontend/src/pages/AdminHomePage.jsx`, `AdminGroupHubPage.jsx`, `AdminUsersPage.jsx`, `AdminSystemPage.jsx`, … | | Admin-Seiten | `frontend/src/pages/AdminHomePage.jsx`, `AdminGroupHubPage.jsx`, `AdminUsersPage.jsx`, `AdminSystemPage.jsx`, … |
| Einstellungen Profil | `frontend/src/pages/SettingsPage.jsx`, `.settings-page__field` in `app.css` | | Einstellungen Profil | `frontend/src/pages/SettingsPage.jsx`, `.settings-page__field` in `app.css` |
| KI-Analyse Layout | `frontend/src/pages/Analysis.jsx` + `.analysis-split*` in `app.css` | | KI-Analyse Layout | `frontend/src/pages/Analysis.jsx` + `.analysis-split*` in `app.css` |

View File

@ -0,0 +1,25 @@
## Ziel
Verlässliche Lebensmittel-Stammdaten (BLS 4.0, frei) plus lernendes FDDB-Mapping. Tagesmakros bleiben First Class.
## Umsetzung (develop)
- Migration 062: Katalog, dynamische Attribute, Mappings, `nutrition_items`, Tages-Rollup, Fasten-/Lücken-Marken, Import-Policy
- Admin-Gruppe Ernährung, Nutzer-Tab Zuordnen, Settings-Policy, Initialimport-Checkbox
- Mapping ohne KI, dauerhaft, änder- und löschbar
- BLS-Code bleibt stabile Identität
## Specs
- `.claude/docs/functional/BLS_FOOD_REFERENCE.md`
- `.claude/docs/technical/BLS_FOOD_REFERENCE.md`
- `docs/issues/issue-bls-food-mapping.md`
## Folge
Gitea #75 (Zucker/Ballaststoffe/Qualität, Platzhalter Mikros + Esszeitpunkte)
## Tests
- Unit: `backend/tests/test_food_mapping.py`, `backend/tests/test_bls_parser.py`
- Playwright: Ernährung-Tabs, Import-Checkbox, Settings-Policy, API unmapped/marks/bls-Suche

View File

@ -0,0 +1,24 @@
# BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch
**Status:** in Umsetzung · **Gitea:** [#106](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106) · **Folge:** #75 (Zucker/Ballaststoffe/Qualität)
## Ziel
Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernendes Mapping aus FDDB, optionale Item-Ebene. Tagesmakros bleiben ohne Katalog nutzbar.
## Abnahme Phase 1
- BLS-XLSX-Import (Dry-Run + Apply), Codes bleiben erhalten
- Manuelle Foods gekennzeichnet
- Mapping: Lookup, Bestätigen, Ändern, Löschen; keine KI
- FDDB-Import speichert Zeilen; Makro-Konflikt laut Policy
- Fasten-/Lücken-Marken unabhängig vom Import
- Einzelerfassung nur Makros unverändert
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer)
- FDDB-Listen-CSV importieren; Tagebuch-Listenzeilen in Zutaten auflösen
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping
- Manueller Eintrag: beliebige `num_per_100g`-Stoffe (EPA/DHA/…) und Portionsumrechnung
- Listen-Dialog mit Zutatensuche (Mappings + Katalog); Mitai = Roh-Kombination
- Listenzutaten: Menge + Einheit, Umrechnung auf Gramm (EL/TL/Prise/Stück)
- Folge: Tandoor-Gerichte + `cooked_yield_g` (Fertiggewicht) + Katalogeintrag pro 100 g — nicht Phase 1

View File

@ -7,10 +7,10 @@ server {
proxy_pass http://backend:8000/api/; proxy_pass http://backend:8000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 20M; client_max_body_size 50M;
proxy_read_timeout 300s; proxy_read_timeout 600s;
proxy_connect_timeout 60s; proxy_connect_timeout 60s;
proxy_send_timeout 60s; proxy_send_timeout 600s;
} }
location / { location / {

View File

@ -57,6 +57,10 @@ import CustomGoalsPage from './pages/CustomGoalsPage'
import UniversalCsvImportPage from './pages/UniversalCsvImportPage' import UniversalCsvImportPage from './pages/UniversalCsvImportPage'
import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage' import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage'
import AdminCsvTemplateEditorPage from './pages/AdminCsvTemplateEditorPage' import AdminCsvTemplateEditorPage from './pages/AdminCsvTemplateEditorPage'
import AdminBlsImportPage from './pages/AdminBlsImportPage'
import AdminBlsFoodsPage from './pages/AdminBlsFoodsPage'
import AdminFoodMappingsPage from './pages/AdminFoodMappingsPage'
import AdminFoodAttributesPage from './pages/AdminFoodAttributesPage'
import WorkflowEditorPage from './pages/WorkflowEditorPage' import WorkflowEditorPage from './pages/WorkflowEditorPage'
import DesktopSidebar from './components/DesktopSidebar' import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav' import { getMainNavItems } from './config/appNav'
@ -267,6 +271,10 @@ function AppShell() {
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/> <Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
<Route path="csv-templates" element={<AdminCsvTemplatesPage />} /> <Route path="csv-templates" element={<AdminCsvTemplatesPage />} />
<Route path="csv-templates/:id" element={<AdminCsvTemplateEditorPage />} /> <Route path="csv-templates/:id" element={<AdminCsvTemplateEditorPage />} />
<Route path="bls-import" element={<AdminBlsImportPage />} />
<Route path="bls-foods" element={<AdminBlsFoodsPage />} />
<Route path="food-attributes" element={<AdminFoodAttributesPage />} />
<Route path="food-mappings" element={<AdminFoodMappingsPage />} />
</Route> </Route>
</Route> </Route>
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/> <Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>

View File

@ -0,0 +1,76 @@
/**
* Strukturierte Zeilenfehler aus Universal-CSV-Import (error_details mit hint/code).
*/
export default function CsvImportErrorDetails({ errors, title, defaultOpen = true, maxVisible = 20 }) {
if (!errors?.length) return null
const shown = errors.slice(0, maxVisible)
const rest = errors.length - shown.length
return (
<details
className="card"
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
open={defaultOpen}
>
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
{title || `Zeilenfehler (${errors.length})`}
</summary>
<ul
style={{
margin: '12px 0 0 0',
padding: 0,
listStyle: 'none',
fontSize: 13,
lineHeight: 1.5,
}}
>
{shown.map((err, i) => {
const row = err.row ?? err.row_index
const message = err.error ?? err.message ?? (typeof err === 'string' ? err : JSON.stringify(err))
return (
<li
key={i}
style={{
padding: '10px 12px',
marginBottom: 8,
background: 'var(--surface2)',
borderRadius: 8,
borderLeft: '3px solid var(--danger)',
}}
>
<div style={{ color: 'var(--text1)' }}>
{row != null && (
<strong style={{ marginRight: 6 }}>Zeile {row}:</strong>
)}
{message}
{err.code && (
<code
style={{
marginLeft: 8,
fontSize: 11,
color: 'var(--text3)',
background: 'var(--surface)',
padding: '1px 6px',
borderRadius: 4,
}}
>
{err.code}
</code>
)}
</div>
{err.hint && (
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text2)' }}>{err.hint}</div>
)}
</li>
)
})}
</ul>
{rest > 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
und {rest} weitere Fehler (Details in der Import-Historie).
</p>
)}
</details>
)
}

View File

@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
function parseNum(raw) {
const n = parseFloat(String(raw ?? '').replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
export function foodCreatePayload(name, macros, extras, servingG) {
const attributes = {}
for (const row of extras || []) {
if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue
attributes[row.attr.attr_key] = parseNum(row.value)
}
const serving = parseNum(servingG)
return {
name_de: (name || '').trim(),
macros_per_100g: {
kcal: parseNum(macros.kcal),
protein_g: parseNum(macros.protein_g),
fat_g: parseNum(macros.fat_g),
carbs_g: parseNum(macros.carbs_g),
},
attributes,
serving_g: serving > 0 && serving !== 100 ? serving : null,
}
}
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
const [q, setQ] = useState('')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
const timer = useRef(null)
useEffect(() => () => clearTimeout(timer.current), [])
const search = (term) => {
clearTimeout(timer.current)
const query = (term || '').trim()
if (query.length < 2) {
setHits([])
return
}
timer.current = setTimeout(async () => {
setLoading(true)
try {
setHits(await api.listFoodAttributes(query, 20))
} catch {
setHits([])
} finally {
setLoading(false)
}
}, 250)
}
const add = (attr) => {
setExtras((list) => (
list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }]
))
setQ('')
setHits([])
}
const setValue = (key, value) => {
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r)))
}
return (
<div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
Zahlen in der Einheit des Stoffs. Bei Etikett pro 8 ml / 8 g die Portionsgröße setzen gespeichert wird immer pro 100 g.
</p>
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
Angabe gilt für{' '}
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 80, display: 'inline-block', textAlign: 'left' }}
value={servingG}
onChange={(e) => setServingG(e.target.value)}
/>
{' '}g (100 = pro 100 g)
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
<label key={key} style={{ fontSize: 12, color: 'var(--text2)' }}>
{label}
<input
className="form-input"
type="number"
min="0"
step="0.1"
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
value={macros[key]}
onChange={(e) => setMacros({ ...macros, [key]: e.target.value })}
/>
</label>
))}
</div>
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe (EPA, DHA, Ballaststoffe, )</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Stoff suchen, z. B. EPA oder Omega"
value={q}
onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
/>
{loading && <p style={{ fontSize: 12, color: 'var(--text3)' }}>Suche</p>}
{hits.map((h) => (
<button
key={h.attr_key}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 6, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => add(h)}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 11, color: 'var(--text3)' }}>{h.attr_key}{h.unit ? ` · ${h.unit}` : ''}{h.category ? ` · ${h.category}` : ''}</span>
</span>
</button>
))}
{extras.map((row) => (
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
{row.attr.name_de}{row.attr.unit ? ` (${row.attr.unit})` : ''}
<input
className="form-input"
type="number"
min="0"
step="0.001"
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
value={row.value}
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
placeholder="Wert in der Einheit oben"
/>
</label>
))}
</div>
)
}

View File

@ -0,0 +1,477 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
const FALLBACK_UNITS = [
{ id: 'g', label: 'g', default_g: 1, needs_unit_map: false },
{ id: 'ml', label: 'ml', default_g: 1, needs_unit_map: false },
{ id: 'el', label: 'EL', default_g: 15, needs_unit_map: false },
{ id: 'tl', label: 'TL', default_g: 5, needs_unit_map: false },
{ id: 'prise', label: 'Prise', default_g: 0.3, needs_unit_map: false },
{ id: 'msp', label: 'Msp.', default_g: 1, needs_unit_map: false },
{ id: 'stück', label: 'Stück', default_g: null, needs_unit_map: true },
{ id: 'scheibe', label: 'Scheibe', default_g: null, needs_unit_map: true },
{ id: 'portion', label: 'Portion', default_g: null, needs_unit_map: true },
{ id: 'becher', label: 'Becher', default_g: null, needs_unit_map: true },
{ id: 'tasse', label: 'Tasse', default_g: null, needs_unit_map: true },
{ id: 'kg', label: 'kg', default_g: 1000, needs_unit_map: false },
{ id: 'l', label: 'l', default_g: 1000, needs_unit_map: false },
]
function emptyIng(overrides = {}) {
return {
source_name_raw: '',
amount: '',
unit: 'g',
gramsPerUnit: '',
foodId: null,
mappedLabel: '',
...overrides,
}
}
function unitMeta(units, id) {
return (units || []).find((u) => u.id === id) || FALLBACK_UNITS.find((u) => u.id === id)
}
function gramsHint(ing, units) {
const amount = parseFloat(String(ing.amount).replace(',', '.'))
if (!Number.isFinite(amount) || amount <= 0) return null
const meta = unitMeta(units, ing.unit)
const factor = parseFloat(String(ing.gramsPerUnit).replace(',', '.'))
const per = Number.isFinite(factor) && factor > 0 ? factor : meta?.default_g
if (!per) return null
const g = Math.round(amount * per * 1000) / 1000
return g
}
function fromSavedIngredient(i, mapped) {
let amount = i.quantity_amount
let unit = i.source_unit || 'g'
if (amount == null && i.quantity_raw) {
const m = String(i.quantity_raw).trim().match(/^(\d+(?:[.,]\d+)?)\s*(.*)$/)
if (m) {
amount = m[1].replace(',', '.')
const label = (m[2] || '').trim().toLowerCase()
const hit = FALLBACK_UNITS.find((u) => u.id === label || u.label.toLowerCase() === label)
if (hit) unit = hit.id
}
}
if (amount == null && i.quantity_g != null) {
amount = i.quantity_g
unit = i.source_unit || 'g'
}
return emptyIng({
source_name_raw: i.source_name_raw || '',
amount: amount ?? '',
unit,
gramsPerUnit: mapped?.grams_per_unit ?? '',
foodId: mapped?.food_id || null,
mappedLabel: mapped?.food_name_de || '',
})
}
function mappingFor(name, learned) {
const n = (name || '').trim().toLowerCase()
if (!n) return null
return (learned || []).find((m) => {
const raw = (m.source_name_raw || '').toLowerCase()
const norm = (m.source_name_normalized || '').toLowerCase()
return raw === n || norm === n
}) || null
}
function filterLearned(term, learned) {
const t = (term || '').trim().toLowerCase()
if (t.length < 2) return []
return (learned || []).filter((m) => {
const hay = `${m.source_name_raw || ''} ${m.food_name_de || ''} ${m.bls_code || ''}`.toLowerCase()
return hay.includes(t)
}).slice(0, 8)
}
export default function FoodRecipeDialog({
title,
defaultName,
defaultPortions,
defaultIngredients,
learned,
disabled,
submitLabel,
onSave,
onClose,
}) {
const [name, setName] = useState(defaultName || '')
const [portions, setPortions] = useState(defaultPortions != null ? String(defaultPortions) : '1')
const [ings, setIngs] = useState(
(defaultIngredients || []).length
? defaultIngredients.map((i) => fromSavedIngredient(i, mappingFor(i.source_name_raw, learned)))
: []
)
const [units, setUnits] = useState(FALLBACK_UNITS)
const [q, setQ] = useState('')
const [catalogHits, setCatalogHits] = useState([])
const [searching, setSearching] = useState(false)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const [localLearned, setLocalLearned] = useState(learned || [])
const nameRef = useRef(null)
const qtyRefs = useRef({})
const timer = useRef(null)
const abortRef = useRef(null)
const seqRef = useRef(0)
useEffect(() => { setLocalLearned(learned || []) }, [learned])
useEffect(() => {
api.listFoodUnits().then((list) => {
if (Array.isArray(list) && list.length) setUnits(list)
}).catch(() => {})
}, [])
useEffect(() => {
nameRef.current?.focus()
const onKey = (e) => { if (e.key === 'Escape' && !saving) onClose() }
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
clearTimeout(timer.current)
abortRef.current?.abort()
}
}, [])
const mappedHits = filterLearned(q, localLearned)
const mappedNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase()))
const catalogOnly = catalogHits.filter((h) => !mappedNames.has((h.name_de || '').toLowerCase()))
const runCatalog = async (term) => {
const query = (term || '').trim()
abortRef.current?.abort()
if (query.length < 2) {
setCatalogHits([])
setSearching(false)
return
}
const seq = ++seqRef.current
const ac = new AbortController()
abortRef.current = ac
setSearching(true)
try {
const next = await api.searchBlsFoods(query, 12, ac.signal)
if (seq !== seqRef.current) return
setCatalogHits(Array.isArray(next) ? next : [])
} catch (e) {
if (e.name === 'AbortError') return
if (seq !== seqRef.current) return
setCatalogHits([])
} finally {
if (seq === seqRef.current) setSearching(false)
}
}
const onSearch = (value) => {
setQ(value)
clearTimeout(timer.current)
if (value.trim().length < 2) {
abortRef.current?.abort()
setCatalogHits([])
setSearching(false)
return
}
timer.current = setTimeout(() => runCatalog(value), 300)
}
const focusQty = (idx) => {
setTimeout(() => qtyRefs.current[idx]?.focus(), 30)
}
const addIngredient = (ing) => {
const raw = (ing.source_name_raw || '').trim()
if (!raw) return
setIngs((list) => {
const exists = list.findIndex((x) => (x.source_name_raw || '').trim().toLowerCase() === raw.toLowerCase())
if (exists >= 0) {
focusQty(exists)
return list
}
const next = [...list, emptyIng({ ...ing, source_name_raw: raw })]
focusQty(next.length - 1)
return next
})
setQ('')
setCatalogHits([])
setError(null)
}
const addMapped = (m) => addIngredient({
source_name_raw: m.source_name_raw,
foodId: m.food_id,
mappedLabel: m.food_name_de || '',
unit: m.source_unit || 'g',
gramsPerUnit: m.grams_per_unit ?? '',
})
const addCatalog = (h) => addIngredient({
source_name_raw: h.name_de,
foodId: h.id,
mappedLabel: h.name_de,
})
const addFreeText = () => {
const raw = q.trim()
if (!raw) return
addIngredient({ source_name_raw: raw })
}
const save = async () => {
const ingredients = ings
.map((i) => {
const amount = i.amount === '' ? null : parseFloat(String(i.amount).replace(',', '.'))
const gpu = i.gramsPerUnit === '' ? null : parseFloat(String(i.gramsPerUnit).replace(',', '.'))
return {
source_name_raw: (i.source_name_raw || '').trim(),
quantity_amount: Number.isFinite(amount) ? amount : null,
source_unit: i.unit || 'g',
grams_per_unit: Number.isFinite(gpu) && gpu > 0 ? gpu : null,
foodId: i.foodId,
}
})
.filter((i) => i.source_name_raw)
if (!name.trim()) {
setError('Listenname fehlt')
return
}
if (!ingredients.length) {
setError('Mindestens eine Zutat angeben')
return
}
if (ingredients.some((i) => i.quantity_amount == null || i.quantity_amount <= 0)) {
setError('Jede Zutat braucht eine Menge')
return
}
setSaving(true)
setError(null)
try {
const nextLearned = [...localLearned]
for (const ing of ingredients) {
if (!ing.foodId) continue
const already = nextLearned.some((m) => (
(m.source_name_raw || '').trim().toLowerCase() === ing.source_name_raw.toLowerCase()
))
if (already && !ing.grams_per_unit) continue
const res = await api.upsertMyFoodMapping({
source_name: ing.source_name_raw,
food_id: ing.foodId,
grams_per_unit: ing.grams_per_unit,
source_unit: ing.source_unit,
})
nextLearned.unshift({
id: res.mapping_id,
source_name_raw: ing.source_name_raw,
food_id: ing.foodId,
food_name_de: res.food_name_de || ing.source_name_raw,
})
}
setLocalLearned(nextLearned)
await onSave({
name_raw: name.trim(),
portions: parseFloat(String(portions).replace(',', '.')) || 1,
ingredients: ingredients.map(({ source_name_raw, quantity_amount, source_unit, grams_per_unit }) => ({
source_name_raw,
quantity_amount,
source_unit,
grams_per_unit,
})),
})
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
const showResults = q.trim().length >= 2
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 21000,
padding: 16,
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="recipe-dialog-title"
style={{
width: '100%', maxWidth: 560, 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 id="recipe-dialog-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Liste'}</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 }}>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.5, margin: '0 0 12px' }}>
Roh-Kombination ohne Kochen (z. B. Müsli). Zutaten suchen, Menge und Einheit angeben. Gramm ist die Standardeinheit EL, TL, Prise usw. werden umgerechnet, sobald der Faktor bekannt ist. Gekochte Gerichte bleiben in Tandoor.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
<label className="form-label">Name der Liste</label>
<input
ref={nameRef}
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 12 }}
placeholder="z. B. Overnight Oats"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<label className="form-label">Portionen</label>
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 96, textAlign: 'left', marginBottom: 16 }}
value={portions}
onChange={(e) => setPortions(e.target.value)}
/>
<p style={{ fontSize: 13, fontWeight: 600, margin: '0 0 8px' }}>Zutat suchen</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Name, z. B. Haferflocken oder Joghurt 10%"
value={q}
onChange={(e) => onSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addFreeText() } }}
/>
{showResults && (
<div style={{ marginTop: 8, border: '1px solid var(--border)', borderRadius: 10, background: 'var(--surface2)' }}>
{mappedHits.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Bereits zugeordnet</div>
)}
{mappedHits.map((m) => (
<button
key={m.id || m.source_name_raw}
type="button"
className="btn btn-secondary btn-full"
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => addMapped(m)}
>
<span>
<strong style={{ display: 'block' }}>{m.source_name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}> {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}</span>
</span>
</button>
))}
{searching && <p style={{ fontSize: 12, color: 'var(--text2)', padding: '6px 10px' }}>Katalog</p>}
{catalogOnly.length > 0 && (
<div style={{ padding: '8px 10px 4px', fontSize: 11, color: 'var(--text3)' }}>Katalog</div>
)}
{catalogOnly.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ margin: '0 8px 8px', justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
onClick={() => addCatalog(h)}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</span>
</span>
</button>
))}
<button type="button" className="btn btn-secondary btn-full" style={{ margin: '0 8px 8px' }} onClick={addFreeText}>
{q.trim()} als offene Zutat übernehmen
</button>
</div>
)}
<p style={{ fontSize: 13, fontWeight: 600, margin: '16px 0 8px' }}>Zutaten ({ings.length})</p>
{ings.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>Noch keine Zutaten oben suchen und übernehmen.</p>
)}
{ings.map((ing, i) => {
const mapped = mappingFor(ing.source_name_raw, localLearned) || (ing.foodId ? { food_name_de: ing.mappedLabel } : null)
const meta = unitMeta(units, ing.unit)
const approx = gramsHint(ing, units)
const needsFactor = Boolean(meta?.needs_unit_map) && !approx
const isMass = ['g', 'ml', 'kg', 'l'].includes(ing.unit)
return (
<div key={`${ing.source_name_raw}-${i}`} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
className="form-input"
style={{ textAlign: 'left', flex: 1 }}
value={ing.source_name_raw}
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value, foodId: null, mappedLabel: '' } : x))}
/>
<button type="button" className="btn btn-secondary" style={{ padding: '6px 10px' }} onClick={() => setIngs((list) => list.filter((_, j) => j !== i))} aria-label="Zutat entfernen">×</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
<input
ref={(el) => { qtyRefs.current[i] = el }}
className="form-input"
type="number"
min="0"
step="0.1"
style={{ textAlign: 'left' }}
placeholder="Menge"
value={ing.amount}
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, amount: e.target.value } : x))}
/>
<select
className="form-input"
style={{ textAlign: 'left' }}
value={ing.unit}
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, unit: e.target.value } : x))}
>
{units.map((u) => (
<option key={u.id} value={u.id}>{u.label}</option>
))}
</select>
</div>
{!isMass && (
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 6 }}>
1 {meta?.label || ing.unit} ={' '}
<input
className="form-input"
type="number"
min="0.01"
step="0.1"
style={{ width: 80, display: 'inline-block', textAlign: 'left' }}
placeholder={meta?.default_g != null ? String(meta.default_g) : 'g'}
value={ing.gramsPerUnit}
onChange={(e) => setIngs((list) => list.map((x, j) => j === i ? { ...x, gramsPerUnit: e.target.value } : x))}
/>
{' '}g
</label>
)}
<div style={{ fontSize: 11, color: mapped ? 'var(--accent-dark)' : 'var(--text3)', marginTop: 4 }}>
{approx != null ? `${String(approx).replace('.', ',')} g` : (needsFactor ? 'Gramm pro Einheit fehlt — später umrechenbar' : '')}
{mapped ? ` · zugeordnet: ${mapped.food_name_de || ing.mappedLabel}` : ' · noch offen — nach dem Speichern in der Liste zuordnen'}
</div>
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-secondary" disabled={saving || disabled} onClick={onClose}>Abbrechen</button>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} disabled={saving || disabled} onClick={save}>
{saving ? 'Speichere…' : (submitLabel || 'Liste speichern')}
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,91 @@
import { useState } from 'react'
import { api } from '../utils/api'
import FoodRecipeDialog from './FoodRecipeDialog'
export default function FoodRecipeEditor({ recipes, learned, onChanged }) {
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(null)
const [error, setError] = useState(null)
const startNew = () => {
setEditing('new')
setError(null)
setOpen(true)
}
const startEdit = (r) => {
setEditing(r)
setError(null)
setOpen(true)
}
const save = async (body) => {
if (editing === 'new') await api.createNutritionRecipe(body)
else await api.updateNutritionRecipe(editing.id, body)
setEditing(null)
onChanged?.()
}
const remove = async (id) => {
if (!confirm('Liste wirklich löschen?')) return
try {
await api.deleteNutritionRecipe(id)
if (editing && editing !== 'new' && editing.id === id) setEditing(null)
onChanged?.()
} catch (e) {
setError(e.message)
}
}
return (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<h3 style={{ fontSize: 14, margin: 0 }}>Eigene Listen / Kombinationen ({recipes.length})</h3>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => { setOpen((v) => !v); if (open) setEditing(null) }}>
{open ? 'Einklappen' : 'Anzeigen'}
</button>
</div>
{open && (
<div style={{ marginTop: 8 }}>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 8 }}>{error}</div>}
<button type="button" className="btn btn-secondary btn-full" onClick={startNew}>Liste anlegen</button>
{recipes.map((r) => (
<div key={r.id} style={{ borderTop: '1px solid var(--border)', padding: '8px 0', marginTop: 8 }}>
<div style={{ fontWeight: 600 }}>{r.name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{(r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen
{r.source === 'manual' ? ' · selbst angelegt' : ''}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => startEdit(r)}>Bearbeiten</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => remove(r.id)}>Löschen</button>
</div>
</div>
))}
</div>
)}
{editing === 'new' && (
<FoodRecipeDialog
title="Liste anlegen"
learned={learned}
submitLabel="Liste anlegen"
onSave={save}
onClose={() => setEditing(null)}
/>
)}
{editing && editing !== 'new' && (
<FoodRecipeDialog
key={editing.id}
title="Liste bearbeiten"
defaultName={editing.name_raw}
defaultPortions={editing.portions}
defaultIngredients={editing.ingredients}
learned={learned}
submitLabel="Liste speichern"
onSave={save}
onClose={() => setEditing(null)}
/>
)}
</div>
)
}

View File

@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse'])
const UNIT_LABEL = {
stück: 'Stück', scheibe: 'Scheibe', portion: 'Portion',
becher: 'Becher', tasse: 'Tasse', el: 'EL', tl: 'TL', prise: 'Prise',
}
function detectUnit(...texts) {
for (const text of texts) {
const m = String(text || '').match(
/(\d+(?:[.,]\d+)?)\s*(stück|stk|st\.?|scheibe|portion(?:en)?|becher|tasse|el|esslöffel|tl|teelöffel|prise)\b/i
)
if (!m) continue
const raw = m[2].toLowerCase().replace(/\.$/, '')
const unit = raw.startsWith('st') && !raw.startsWith('scheibe') ? 'stück'
: raw.startsWith('ess') || raw === 'el' ? 'el'
: raw.startsWith('tee') || raw === 'tl' ? 'tl'
: raw.startsWith('portion') ? 'portion'
: raw
return unit
}
return null
}
export default function FoodSearchModal({ title, initialQuery, quantityHint, allowRecipe, onSelect, onWantRecipe, onClose }) {
const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
const [createKind, setCreateKind] = useState(null)
const [manual, setManual] = useState({
name_de: initialQuery || '',
kcal: '', protein_g: '', fat_g: '', carbs_g: '',
})
const [nutrientExtras, setNutrientExtras] = useState([])
const [servingG, setServingG] = useState('100')
const suggestedUnit = detectUnit(quantityHint, initialQuery)
const [gramsPerUnit, setGramsPerUnit] = useState(
suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : ''
)
const inputRef = useRef(null)
const timer = useRef(null)
const abortRef = useRef(null)
const seqRef = useRef(0)
const unitExtras = () => {
const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
if (!suggestedUnit || !g || g <= 0) return {}
return { grams_per_unit: g, source_unit: suggestedUnit }
}
const runSearch = async (term) => {
const query = (term || '').trim()
abortRef.current?.abort()
if (query.length < 2) {
setHits([])
setLoading(false)
return
}
const seq = ++seqRef.current
const ac = new AbortController()
abortRef.current = ac
setLoading(true)
setError(null)
try {
const next = await api.searchBlsFoods(query, 20, ac.signal)
if (seq !== seqRef.current) return
setHits(next)
} catch (e) {
if (e.name === 'AbortError') return
if (seq !== seqRef.current) return
setError(e.message)
setHits([])
} finally {
if (seq === seqRef.current) setLoading(false)
}
}
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
if ((initialQuery || '').trim().length >= 2) runSearch(initialQuery)
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
clearTimeout(timer.current)
abortRef.current?.abort()
}
}, [])
const onChange = (value) => {
setQ(value)
clearTimeout(timer.current)
if (value.trim().length < 2) {
abortRef.current?.abort()
setHits([])
setLoading(false)
return
}
timer.current = setTimeout(() => runSearch(value), 400)
}
const createManual = async () => {
const name = (manual.name_de || q || '').trim()
if (!name) {
setError('Name für den Eintrag fehlt')
return
}
setCreating(true)
setError(null)
try {
const food = await api.createUserFood(foodCreatePayload(name, manual, nutrientExtras, servingG))
onSelect(food, unitExtras())
} catch (e) {
setError(e.message)
} finally {
setCreating(false)
}
}
const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000,
padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="food-search-title"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 720px)',
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 id="food-search-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Lebensmittel suchen'}</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input
ref={inputRef}
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Name und Fettgehalt, z. B. Joghurt 10%"
value={q}
onChange={(e) => onChange(e.target.value)}
/>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
Fettgehalt mit eingeben, z. B. Joghurt 10 % oder 9,5 %. Ohne Zahl erscheint oft nur die 3,5 %-Klasse. Fehlt der Treffer, eigenen Eintrag anlegen.
</p>
{suggestedUnit && (
<label style={{ display: 'block', marginTop: 10, fontSize: 13 }}>
1 {unitLabel} ={' '}
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 90, display: 'inline-block', textAlign: 'left' }}
value={gramsPerUnit}
onChange={(e) => setGramsPerUnit(e.target.value)}
placeholder={COUNT_UNITS.has(suggestedUnit) ? 'z. B. 60' : ''}
/>
{' '}g
</label>
)}
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche</p>}
{error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>}
{!loading && q.trim().length >= 2 && hits.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)}
{hits.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h, unitExtras())}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</span>
</span>
</button>
))}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{!showCreate ? (
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
setShowCreate(true)
setCreateKind(allowRecipe && onWantRecipe ? null : 'food')
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
}}>
{allowRecipe && onWantRecipe ? 'Lebensmittel oder Liste anlegen' : 'Eigenes Lebensmittel anlegen'}
</button>
) : !createKind ? (
<div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Was möchtest du anlegen?</p>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 8px' }}>Lebensmittel = ein Katalogeintrag. Liste = Roh-Kombination (z. B. Müsli) mit Zutatensuche.</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" onClick={() => setCreateKind('food')}>Lebensmittel</button>
<button type="button" className="btn btn-secondary" onClick={onWantRecipe}>Liste</button>
<button type="button" className="btn btn-secondary" onClick={() => { setShowCreate(false); setCreateKind(null) }}>Zurück</button>
</div>
</div>
) : (
<div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Name, z. B. Norsan Omega-3 Algenöl"
value={manual.name_de}
onChange={(e) => setManual({ ...manual, name_de: e.target.value })}
/>
<FoodNutrientFields
macros={manual}
setMacros={setManual}
extras={nutrientExtras}
setExtras={setNutrientExtras}
servingG={servingG}
setServingG={setServingG}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
{allowRecipe && onWantRecipe && (
<button type="button" className="btn btn-secondary" disabled={creating} onClick={() => setCreateKind(null)}>Zurück</button>
)}
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={creating}
onClick={createManual}
>
{creating ? 'Speichere…' : 'Anlegen und zuordnen'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,562 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields'
import FoodRecipeEditor from './FoodRecipeEditor'
import FoodRecipeDialog from './FoodRecipeDialog'
const PERIODS = [
{ days: 14, label: '14 Tage' },
{ days: 28, label: '4 Wochen' },
{ days: 90, label: '90 Tage' },
{ days: 0, label: 'Alle' },
]
function formatDay(iso) {
const s = String(iso || '').slice(0, 10)
if (s.length < 10) return ''
const [y, m, d] = s.split('-')
return `${d}.${m}.${String(y).slice(2)}`
}
function suggestQuery(raw) {
let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
s = s.replace(/^!+/, '').trim()
const comma = s.indexOf(',')
if (comma > 2) s = s.slice(0, comma).trim()
return s
}
function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
const [q, setQ] = useState(suggestQuery(sourceName))
const filtered = recipes.filter((r) => {
const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase()
return !q.trim() || hay.includes(q.trim().toLowerCase())
})
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000, padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 480, maxHeight: 'min(88vh, 560px)',
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 }}>Eigene Liste wählen</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Listenname filtern" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Keine passende Liste.</p>}
{filtered.map((r) => (
<button key={r.id} type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} onClick={() => onPick(r.id)}>
<span>
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{(r.ingredients || []).length} Zutaten</span>
</span>
</button>
))}
</div>
</div>
</div>
)
}
function InlineCreate({ defaultName, disabled, allowRecipe, onCreateFood, onWantRecipe }) {
const [kind, setKind] = useState(allowRecipe ? null : 'food')
const [name, setName] = useState(defaultName || '')
const [macros, setMacros] = useState({ kcal: '0', protein_g: '0', fat_g: '0', carbs_g: '0' })
const [extras, setExtras] = useState([])
const [servingG, setServingG] = useState('100')
if (!kind) {
return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Lebensmittel (ein Eintrag im Katalog) oder Liste (Roh-Kombination, z. B. Müsli)?</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => setKind('food')}>Lebensmittel</button>
{allowRecipe && (
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={onWantRecipe}>Liste</button>
)}
</div>
</div>
)
}
return (
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.</p>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<FoodNutrientFields
macros={macros}
setMacros={setMacros}
extras={extras}
setExtras={setExtras}
servingG={servingG}
setServingG={setServingG}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={() => setKind(null)}>Zurück</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={disabled || !name.trim()}
onClick={() => onCreateFood(foodCreatePayload(name, macros, extras, servingG))}
>
Anlegen und zuordnen
</button>
</div>
</div>
)
}
export default function NutritionFoodMap({ onChanged, onMapped }) {
const [unmapped, setUnmapped] = useState([])
const [learned, setLearned] = useState([])
const [recipes, setRecipes] = useState([])
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [saving, setSaving] = useState(null)
const [searchFor, setSearchFor] = useState(null)
const [recipeFor, setRecipeFor] = useState(null)
const [recipeDraft, setRecipeDraft] = useState(null)
const [createFor, setCreateFor] = useState(null)
const [filter, setFilter] = useState('all')
const [sinceDays, setSinceDays] = useState(28)
const [totalOpen, setTotalOpen] = useState(0)
const [q, setQ] = useState('')
const [visible, setVisible] = useState(80)
const [showLearned, setShowLearned] = useState(false)
const [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null)
const bundleRef = useRef(null)
const loadGen = useRef(0)
const askedSuggest = useRef(new Set())
const load = async () => {
const gen = ++loadGen.current
try {
const [u, m, r, counts] = await Promise.all([
api.listUnmappedFoods(sinceDays),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
api.listUnmappedFoodCount(sinceDays).catch(() => null),
])
if (gen !== loadGen.current) return
askedSuggest.current = new Set()
setUnmapped(Array.isArray(u) ? u : [])
setTotalOpen(Number(counts?.total) || (Array.isArray(u) ? u.length : 0))
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
if (gen !== loadGen.current) return
setError(e.message)
}
}
useEffect(() => { load() }, [sinceDays])
const filtered = useMemo(() => {
const term = q.trim().toLowerCase()
return unmapped.filter((u) => {
if (filter === 'suggested' && !(u.suggestions || []).length) return false
if (filter === 'none' && (u.suggestions || []).length) return false
if (!term) return true
const hay = `${u.source_name_raw || ''} ${u.source_name_normalized || ''}`.toLowerCase()
return hay.includes(term)
})
}, [unmapped, filter, q])
const shown = filtered.slice(0, visible)
useEffect(() => {
const need = []
const seen = new Set()
const take = (u) => {
const key = u.source_name_normalized
if (!key || askedSuggest.current.has(key) || seen.has(key)) return
seen.add(key)
need.push(u)
}
shown.forEach(take)
if (need.length < 80) {
for (const u of unmapped) {
take(u)
if (need.length >= 80) break
}
}
if (!need.length) return
need.forEach((u) => askedSuggest.current.add(u.source_name_normalized))
const names = need.map((u) => u.source_name_raw).filter(Boolean)
if (!names.length) return
api.suggestFoodsBatch(names, 3).then((packed) => {
if (!packed || typeof packed !== 'object') return
setUnmapped((list) => list.map((u) => {
const hit = packed[u.source_name_raw]
return hit ? { ...u, ...hit } : u
}))
}).catch(() => {
need.forEach((u) => askedSuggest.current.delete(u.source_name_normalized))
})
}, [visible, filter, q, unmapped.length])
const assign = async (row, foodId, extras = {}, foodMeta = {}) => {
const sourceName = row.source_name_raw
setSaving(sourceName)
setError(null)
try {
const res = await api.upsertMyFoodMapping({
source_name: sourceName,
food_id: foodId,
grams_per_unit: extras.grams_per_unit || null,
source_unit: extras.source_unit || null,
})
setSearchFor(null)
setCreateFor(null)
setUnmapped((list) => list.filter((x) => x.source_name_normalized !== row.source_name_normalized))
setLearned((list) => [{
id: res.mapping_id,
source_name_raw: sourceName,
food_name_de: res.food_name_de || foodMeta.name_de,
bls_code: res.bls_code || foodMeta.bls_code,
catalog_kind: res.catalog_kind || foodMeta.catalog_kind,
}, ...list])
setNotice(`Gespeichert: ${suggestQuery(sourceName) || sourceName}`)
onMapped?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const createAndAssign = async (row, body) => {
setSaving(row.source_name_raw)
setError(null)
try {
const food = await api.createUserFood(body)
await assign(row, food.id, {}, { name_de: food.name_de, catalog_kind: food.catalog_kind })
} catch (e) {
setError(e.message)
setSaving(null)
}
}
const createRecipeAndAssign = async (row, body) => {
setSaving(row.source_name_raw)
setError(null)
try {
const rec = await api.createNutritionRecipe({ ...body, source: 'manual' })
await api.applyNutritionRecipe(rec.id, row.source_name_raw)
setCreateFor(null)
setSearchFor(null)
setNotice('Liste angelegt. Offene Zutaten erscheinen unten zum Zuordnen.')
await load()
onMapped?.()
} catch (e) {
setError(e.message)
throw e
} finally {
setSaving(null)
}
}
const applyRecipe = async (sourceName, recipeId) => {
setSaving(sourceName)
setError(null)
try {
await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null)
setNotice('Liste verknüpft. Offene Zutaten erscheinen unten zum Zuordnen.')
await load()
onMapped?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const remove = async (id) => {
if (!confirm('Zuordnung wirklich löschen?')) return
try {
await api.deleteMyFoodMapping(id)
setLearned((list) => list.filter((x) => x.id !== id))
await load()
onChanged?.()
} catch (e) {
setError(e.message)
}
}
const exportBundle = async () => {
setBusy(true)
setError(null)
try {
await api.exportFoodKnowledge()
setNotice('Zuordnungen und Listen als JSON heruntergeladen.')
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const importBundle = async (file) => {
if (!file) return
setBusy(true)
setError(null)
try {
const res = await api.importFoodKnowledge(file)
await load()
onChanged?.()
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
setNotice(`${res.mappings || 0} Zuordnungen und ${(res.inserted || 0) + (res.updated || 0)} Listen übernommen${skip}.`)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const importLists = async (file) => {
if (!file) return
setImporting(true)
setError(null)
try {
const res = await api.importFddbLists(file)
await load()
onChanged?.()
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.`)
} catch (e) {
setError(e.message)
} finally {
setImporting(false)
}
}
const withSuggest = unmapped.filter((u) => (u.suggestions || []).length).length
return (
<div className="card section-gap">
<div className="card-title">Lebensmittel zuordnen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
Zuerst die letzten Wochen zuordnen. Ältere Namen (z. B. Getreide, das du nicht mehr isst) bleiben unter Alle liegen und müssen nicht gemappt werden.
</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>}
<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) }} />
<button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}>
{importing ? 'Importiere Listen…' : 'FDDB-Listen importieren'}
</button>
<FoodRecipeEditor recipes={recipes} learned={learned} onChanged={() => { load(); onChanged?.() }} />
<input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>Zuordnungen & Listen exportieren</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => bundleRef.current?.click()}>Zuordnungen & Listen importieren</button>
</div>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>
Offen ({filtered.length}{sinceDays && totalOpen > unmapped.length ? ` von ${totalOpen}` : filtered.length !== unmapped.length ? ` / ${unmapped.length}` : ''})
</h3>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>{withSuggest} mit Vorschlag</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{PERIODS.map((p) => (
<button
key={p.days}
type="button"
className={sinceDays === p.days ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ fontSize: 12 }}
onClick={() => { setSinceDays(p.days); setVisible(80); setQ('') }}
>
{p.label}
</button>
))}
</div>
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Offene Liste filtern…" value={q} onChange={(e) => { setQ(e.target.value); setVisible(80) }} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{[['all', 'Alle Treffer'], ['suggested', 'Mit Vorschlag'], ['none', 'Ohne Vorschlag']].map(([id, label]) => (
<button key={id} type="button" className={filter === id ? 'btn btn-primary' : 'btn btn-secondary'} style={{ fontSize: 12 }} onClick={() => { setFilter(id); setVisible(80) }}>{label}</button>
))}
</div>
{shown.length === 0 && (
<p className="muted">
{sinceDays && unmapped.length === 0 && totalOpen > 0
? `In diesem Zeitraum ist nichts Offen. ${totalOpen} ältere Namen liegen unter „Alle“ — die brauchst du nicht, wenn du sie nicht mehr isst.`
: 'Keine offenen Bezeichner in diesem Filter.'}
</p>
)}
{shown.map((u) => {
const key = u.source_name_normalized
const suggestions = u.suggestions || []
const best = suggestions[0]
const busyRow = saving === u.source_name_raw
return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
<div>
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
</div>
</div>
{(u.ambiguous || suggestions.length > 1) && (
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 999, background: 'var(--surface2)', color: 'var(--danger)', whiteSpace: 'nowrap' }}>
mehrere möglich
</span>
)}
</div>
{best && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 13 }}>
<strong>{best.name_de}</strong>
<span style={{ color: 'var(--text3)' }}>{best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}</span>
</span>
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}>
Bestätigen
</button>
</div>
)}
{suggestions.slice(1).map((s) => (
<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>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button>
</div>
))}
{!best && <p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>Kein Katalogvorschlag selbst anlegen.</p>}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}>
{createFor === key ? 'Anlegen schließen' : (u.kind === 'recipe_ingredient' ? 'Neu anlegen' : 'Lebensmittel oder Liste')}
</button>
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Liste</button>
)}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Liste wählen</button>
)}
</div>
{createFor === key && (
<InlineCreate
defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw}
disabled={busyRow}
allowRecipe={u.kind !== 'recipe_ingredient'}
onCreateFood={(body) => createAndAssign(u, body)}
onWantRecipe={() => {
setCreateFor(null)
setSearchFor(null)
setRecipeDraft({
row: u,
defaultName: suggestQuery(u.source_name_raw) || u.source_name_raw,
})
}}
/>
)}
</div>
)
})}
{visible < filtered.length && (
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 80)}>
Weitere {Math.min(80, filtered.length - visible)} zeigen
</button>
)}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>
Gelernt ({learned.length}){' '}
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => setShowLearned((v) => !v)}>
{showLearned ? 'Einklappen' : 'Anzeigen'}
</button>
</h3>
{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>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
</div>
))}
{searchFor && (
<FoodSearchModal
title={`Suchen: ${searchFor.source_name_raw}`}
initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
allowRecipe={searchFor.kind !== 'recipe_ingredient'}
onClose={() => setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
onWantRecipe={searchFor.kind === 'recipe_ingredient' ? undefined : () => {
const row = searchFor
setSearchFor(null)
setCreateFor(null)
setRecipeDraft({
row,
defaultName: suggestQuery(row.source_name_raw) || row.source_name_raw,
})
}}
/>
)}
{recipeDraft && (
<FoodRecipeDialog
title="Liste anlegen"
defaultName={recipeDraft.defaultName}
learned={learned}
disabled={saving === recipeDraft.row.source_name_raw}
submitLabel="Liste anlegen und zuordnen"
onClose={() => setRecipeDraft(null)}
onSave={async (body) => {
await createRecipeAndAssign(recipeDraft.row, body)
setRecipeDraft(null)
}}
/>
)}
{recipeFor && (
<RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} />
)}
</div>
)
}
export function DayMarkButtons({ date, markType, onChanged }) {
const setMark = async (type) => {
try {
if (markType === type) await api.deleteNutritionDayMark(date)
else await api.putNutritionDayMark(date, { mark_type: type })
onChanged?.()
} catch (e) {
alert(e.message)
}
}
return (
<span style={{ display: 'inline-flex', gap: 8 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}>
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('incomplete')}>
{markType === 'incomplete' ? '✓ Lücke' : 'Lücke'}
</button>
</span>
)
}

View File

@ -115,6 +115,33 @@ export const ADMIN_GROUPS = [
}, },
], ],
}, },
{
id: 'nutrition',
label: 'Ernährung',
description: 'BLS-Stammdaten, Attribute und FDDB-Mappings.',
items: [
{
to: '/admin/bls-import',
label: 'BLS importieren',
description: 'Offizielle BLS-4.0-XLSX (Components + Daten).',
},
{
to: '/admin/bls-foods',
label: 'Lebensmittelkatalog',
description: 'Suche inkl. BLS-Code, manuelle Einträge.',
},
{
to: '/admin/food-attributes',
label: 'Stoffe & Attribute',
description: 'BLS-Komponenten und Erweiterungen (Histamin, Gluten).',
},
{
to: '/admin/food-mappings',
label: 'FDDB-Mappings',
description: 'Globale Standardzuordnungen und Coverage.',
},
],
},
{ {
id: 'system', id: 'system',
label: 'Basiseinstellungen', label: 'Basiseinstellungen',

View File

@ -364,13 +364,15 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) {
} }
// Import Panel // Import Panel
function ImportPanel({ onImported }) { function ImportPanel({ onImported, usage = null }) {
const fileRef = useRef() const fileRef = useRef()
const [status, setStatus] = useState(null) const [status, setStatus] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const atLimit = usage && !usage.allowed
const runImport = async (file) => { const runImport = async (file) => {
if (atLimit) return
setStatus('loading'); setError(null) setStatus('loading'); setError(null)
try { try {
const result = await api.importActivityCsv(file) const result = await api.importActivityCsv(file)
@ -382,24 +384,29 @@ function ImportPanel({ onImported }) {
return ( return (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">📥 Apple Health Import</div> <div className="card-title badge-container-right">
<span>📥 Apple Health Import</span>
{usage && <UsageBadge {...usage} />}
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}> <p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
<strong>Health Auto Export App</strong> Workouts exportieren CSV hier hochladen.<br/> <strong>Health Auto Export App</strong> Workouts exportieren CSV hier hochladen.<br/>
Nur die <em>Workouts-csv</em> Datei wird benötigt (nicht die Detaildateien). Nur die <em>Workouts-csv</em> Datei wird benötigt (nicht die Detaildateien).
</p> </p>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} <input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
disabled={atLimit}
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/> onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
<div <div
onDragOver={e=>{e.preventDefault();setDragging(true)}} onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
onDragLeave={()=>setDragging(false)} onDragLeave={()=>setDragging(false)}
onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}} onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; const f=e.dataTransfer.files[0]; if(f) runImport(f) }}
onClick={()=>fileRef.current.click()} onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10, style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)', padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
cursor:'pointer',transition:'all 0.15s'}}> cursor:atLimit?'not-allowed':'pointer',opacity:atLimit?0.65:1,transition:'all 0.15s'}}>
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/> <Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}> <div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'} {atLimit ? '🔒 Limit erreicht — Import nicht möglich' : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen'}
</div> </div>
</div> </div>
{status==='loading' && ( {status==='loading' && (
@ -945,7 +952,12 @@ export default function ActivityPage() {
</div> </div>
)} )}
{tab==='import' && <ImportPanel onImported={load}/>} {tab==='import' && (
<ImportPanel
usage={activityUsage}
onImported={() => { load(); loadUsage() }}
/>
)}
{tab==='categorize' && ( {tab==='categorize' && (
<div className="card section-gap"> <div className="card section-gap">

View File

@ -0,0 +1,57 @@
import { useState } from 'react'
import { api } from '../utils/api'
export default function AdminBlsFoodsPage() {
const [q, setQ] = useState('')
const [rows, setRows] = useState([])
const [detail, setDetail] = useState(null)
const [error, setError] = useState(null)
const search = async () => {
try {
setRows(await api.adminBlsFoods(q))
} catch (e) {
setError(e.message)
}
}
return (
<div className="card">
<h1 className="page-title">Lebensmittelkatalog</h1>
{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" />
<button type="button" className="btn btn-primary" onClick={search}>Suchen</button>
</div>
{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>
))}
{detail && (
<div style={{ marginTop: 16 }}>
<h3>{detail.bls_code} {detail.name_de}</h3>
<p style={{ fontSize: 12, color: 'var(--text3)' }}>{detail.catalog_kind} · {detail.bls_version || '—'}</p>
<table style={{ width: '100%', fontSize: 12 }}>
<tbody>
{(detail.attributes || []).filter((a) => a.value_num != null || a.is_trace).slice(0, 40).map((a) => (
<tr key={a.attr_key}>
<td>{a.attr_key}</td>
<td>{a.name_de}</td>
<td>{a.is_trace ? 'Spuren' : a.value_num} {a.unit || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
function summarizeCheck(kind, check) {
if (!check) return ''
if (kind === 'components') return `${check.attributes ?? 0} Stoffe erkannt`
return `${check.foods ?? 0} Lebensmittel, ${check.attribute_columns ?? 0} Stoffspalten`
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function waitForJob(jobId, doneStatuses, onTick) {
for (;;) {
const job = await api.adminBlsImportJob(jobId)
onTick?.(job)
if (job.status === 'error') {
throw new Error(job.error || 'Import fehlgeschlagen')
}
if (doneStatuses.includes(job.status)) return job
await sleep(1200)
}
}
function FileImportBlock({ label, kind, onImported }) {
const inputRef = useRef(null)
const [file, setFile] = useState(null)
const [job, setJob] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(null)
const checkFile = async (nextFile) => {
setError(null)
setJob(null)
if (!nextFile) return
setBusy('check')
try {
const started = await api.adminBlsImportStart(kind, nextFile)
const done = await waitForJob(started.id, ['checked'], setJob)
setJob(done)
} catch (e) {
setError(e.message)
} finally {
setBusy(null)
}
}
const applyImport = async () => {
if (!job?.id || job.status !== 'checked') return
setBusy('apply')
setError(null)
try {
await api.adminBlsImportApply(job.id)
const done = await waitForJob(job.id, ['done'], setJob)
setJob(done)
onImported?.()
} catch (e) {
setError(e.message)
} finally {
setBusy(null)
}
}
const progress = job?.progress
const result = job?.result
return (
<div className="settings-page__field">
<label className="settings-page__field-label">{label}</label>
<input
ref={inputRef}
type="file"
accept=".xlsx"
disabled={!!busy}
style={{ display: 'none' }}
onChange={(e) => {
const next = e.target.files?.[0] || null
setFile(next)
checkFile(next)
}}
/>
<button
type="button"
className="btn btn-secondary btn-full"
disabled={!!busy}
onClick={() => inputRef.current?.click()}
>
{file ? file.name : 'Datei auswählen'}
</button>
{busy === 'check' && <p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>Prüfe</p>}
{busy === 'apply' && (
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
Importiere{progress?.total ? ` ${progress.current}/${progress.total}` : ''}
</p>
)}
{error && <p style={{ color: 'var(--danger)', margin: 0 }}>{error}</p>}
{job?.status === 'checked' && !error && (
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
Prüfung: {summarizeCheck(kind, job.check)}
</p>
)}
{job?.status === 'done' && result && (
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
{(result.inserted ?? result.foods_inserted) ?? 0} neu · {(result.updated ?? result.foods_updated) ?? 0} aktualisiert
{result.values_written != null ? ` · ${result.values_written} Werte` : ''}
</p>
)}
<button
type="button"
className="btn btn-primary btn-full"
disabled={job?.status !== 'checked' || !!error || !!busy}
onClick={applyImport}
>
{job?.status === 'done' ? 'Importiert' : 'Importieren'}
</button>
</div>
)
}
export default function AdminBlsImportPage() {
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const load = () => {
api.adminBlsStatus().then(setStatus).catch((e) => setError(e.message))
}
useEffect(() => { load() }, [])
return (
<div className="card">
<h1 className="page-title">BLS 4.0 importieren</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6 }}>
Offizielle Dateien von blsdb.de (frei verfügbar, MRI). Codes bleiben erhalten.
Datei wählen prüft automatisch. Danach Importieren. Zuerst Components, dann Daten.
</p>
{status && (
<p style={{ fontSize: 13 }}>
{status.official_foods} offizielle Lebensmittel · {status.official_attributes} Stoffe ·
{status.manual_foods} manuell · {status.source}
</p>
)}
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<FileImportBlock label="Components XLSX" kind="components" onImported={load} />
<FileImportBlock label="Daten XLSX" kind="foods" onImported={load} />
</div>
)
}

View File

@ -122,6 +122,68 @@ function buildImportRowProcessingSimple(modFields, fm, groupBy, mode, multiRowPo
return out return out
} }
/** Gleiche Logik wie beim Speichern — für Formatprüfung und Create/Update. */
function resolveEditorImportRowProcessing({
aggregateSleepImport,
rowAggUseCustom,
rowAggIrregular,
rowAggJsonText,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
modFields,
fieldMappings,
assignedTargets,
}) {
if (aggregateSleepImport || !rowAggUseCustom) {
return { import_row_processing: null, error: null }
}
if (rowAggIrregular) {
try {
const import_row_processing = JSON.parse(rowAggJsonText || '{}')
if (!import_row_processing || typeof import_row_processing !== 'object') {
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
}
const gb = import_row_processing.group_by
if (!Array.isArray(gb) || !gb.length) {
return {
import_row_processing: null,
error: 'Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.',
}
}
return { import_row_processing, error: null }
} catch {
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
}
}
if (!rowAggGroupBy.length) {
return { import_row_processing: null, error: 'Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.' }
}
if (!rowAggMode) {
return { import_row_processing: null, error: 'Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).' }
}
for (const g of rowAggGroupBy) {
if (!assignedTargets.has(g)) {
return {
import_row_processing: null,
error: `Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`,
}
}
}
return {
import_row_processing: buildImportRowProcessingSimple(
modFields,
fieldMappings,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
),
error: null,
}
}
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */ /** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
function normalizeDecimalInputString(raw) { function normalizeDecimalInputString(raw) {
let s = String(raw).trim().replace(/\s/g, '') let s = String(raw).trim().replace(/\s/g, '')
@ -621,11 +683,28 @@ export default function AdminCsvTemplateEditorPage() {
} }
setValidating(true) setValidating(true)
try { try {
const { import_row_processing, error: irpError } = resolveEditorImportRowProcessing({
aggregateSleepImport,
rowAggUseCustom,
rowAggIrregular,
rowAggJsonText,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
modFields: modMeta?.fields,
fieldMappings,
assignedTargets,
})
if (irpError) {
setError(irpError)
return
}
const r = await api.adminValidateCsvTemplate({ const r = await api.adminValidateCsvTemplate({
module, module,
field_mappings: fieldMappings, field_mappings: fieldMappings,
type_conversions: tc, type_conversions: tc,
import_row_processing: null, import_row_processing,
column_signature: columnSignature.length ? columnSignature : null, column_signature: columnSignature.length ? columnSignature : null,
}) })
setValidationReport(r) setValidationReport(r)
@ -692,44 +771,25 @@ export default function AdminCsvTemplateEditorPage() {
} }
let import_row_processing = null let import_row_processing = null
if (!aggregateSleepImport && rowAggUseCustom) { {
if (rowAggIrregular) { const resolved = resolveEditorImportRowProcessing({
try { aggregateSleepImport,
import_row_processing = JSON.parse(rowAggJsonText || '{}') rowAggUseCustom,
if (!import_row_processing || typeof import_row_processing !== 'object') throw new Error('bad') rowAggIrregular,
} catch { rowAggJsonText,
setError('Zeilenaggregation: ungültiges JSON.') rowAggGroupBy,
return rowAggMode,
} rowAggMultiRowPolicy,
const gb = import_row_processing.group_by rowAggDedupeIdentical,
if (!Array.isArray(gb) || !gb.length) { modFields: modMeta?.fields,
setError('Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.') fieldMappings,
return assignedTargets,
} })
} else { if (resolved.error) {
if (!rowAggGroupBy.length) { setError(resolved.error)
setError('Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.') return
return
}
if (!rowAggMode) {
setError('Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).')
return
}
for (const g of rowAggGroupBy) {
if (!assignedTargets.has(g)) {
setError(`Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`)
return
}
}
import_row_processing = buildImportRowProcessingSimple(
modMeta?.fields,
fieldMappings,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
)
} }
import_row_processing = resolved.import_row_processing
} }
const payload = { const payload = {
@ -1494,12 +1554,13 @@ export default function AdminCsvTemplateEditorPage() {
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}> speicherfähig</span> : <span style={{ color: 'var(--danger)' }}> Fehler beheben</span>} {validationReport.valid ? <span style={{ color: 'var(--accent)' }}> speicherfähig</span> : <span style={{ color: 'var(--danger)' }}> Fehler beheben</span>}
</div> </div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}> <p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht. Inkl. Zeilenaggregation (wie beim Speichern). Warnungen blockieren nicht.
</p> </p>
{validationReport.errors?.length ? ( {validationReport.errors?.length ? (
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}> <ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
{validationReport.errors.map((e, i) => ( {validationReport.errors.map((e, i) => (
<li key={`e-${i}`}> <li key={`e-${i}`}>
{e.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{e.code}</code> : null}
{e.message} {e.message}
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null} {e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
</li> </li>
@ -1510,6 +1571,7 @@ export default function AdminCsvTemplateEditorPage() {
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}> <ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
{validationReport.warnings.map((w, i) => ( {validationReport.warnings.map((w, i) => (
<li key={`w-${i}`}> <li key={`w-${i}`}>
{w.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{w.code}</code> : null}
{w.message} {w.message}
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null} {w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
</li> </li>

View File

@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { api } from '../utils/api'
export default function AdminFoodAttributesPage() {
const [rows, setRows] = useState([])
const [form, setForm] = useState({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
const [error, setError] = useState(null)
const load = () => api.adminBlsAttributes().then(setRows).catch((e) => setError(e.message))
useEffect(() => { load() }, [])
return (
<div className="card">
<h1 className="page-title">Stoffe & Attribute</h1>
<p style={{ fontSize: 13, color: 'var(--text2)' }}>
Offizielle BLS-Codes kommen aus dem Import. Erweiterungen (Histamin, Gluten, ) hier anlegen ohne Migration.
</p>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<div className="form-row">
<input className="form-input" placeholder="KEY" value={form.attr_key} onChange={(e) => setForm({ ...form, attr_key: e.target.value })} />
<input className="form-input" placeholder="Name DE" value={form.name_de} onChange={(e) => setForm({ ...form, name_de: e.target.value })} />
<input className="form-input" placeholder="Einheit" value={form.unit} onChange={(e) => setForm({ ...form, unit: e.target.value })} />
<select className="form-input" value={form.data_type} onChange={(e) => setForm({ ...form, data_type: e.target.value })}>
<option value="num_per_100g">Zahl / 100g</option>
<option value="boolean">Ja/Nein</option>
<option value="text">Text</option>
<option value="enum">Auswahl</option>
</select>
<button type="button" className="btn btn-primary" onClick={async () => {
try {
await api.adminCreateBlsAttribute(form)
setForm({ attr_key: '', name_de: '', unit: '', data_type: 'num_per_100g' })
load()
} catch (e) { setError(e.message) }
}}>Anlegen</button>
</div>
{rows.map((r) => (
<div key={r.id} style={{ fontSize: 13, padding: '6px 0', borderTop: '1px solid var(--border)' }}>
<strong>{r.attr_key}</strong> · {r.name_de} · {r.data_type} · {r.origin}
</div>
))}
</div>
)
}

View File

@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
export default function AdminFoodMappingsPage() {
const [rows, setRows] = useState([])
const [coverage, setCoverage] = useState(null)
const [name, setName] = useState('')
const [foodQ, setFoodQ] = useState('')
const [hits, setHits] = useState([])
const [foodId, setFoodId] = useState('')
const [error, setError] = useState(null)
const [filter, setFilter] = useState('')
const [editId, setEditId] = useState(null)
const [editQ, setEditQ] = useState('')
const [editHits, setEditHits] = useState([])
const [saving, setSaving] = useState(false)
const searchTimer = useRef(null)
const editTimer = useRef(null)
const load = () => {
Promise.all([api.adminListFoodMappings(false), api.adminFoodMappingCoverage()])
.then(([m, c]) => { setRows(m); setCoverage(c) })
.catch((e) => setError(e.message))
}
useEffect(() => { load() }, [])
const searchFoods = (term, setter) => {
const q = (term || '').trim()
if (q.length < 2) {
setter([])
return
}
api.searchBlsFoods(q, 20).then(setter).catch(() => setter([]))
}
const shown = rows.filter((r) => {
const term = filter.trim().toLowerCase()
if (!term) return true
const hay = `${r.source_name_raw || ''} ${r.food_name_de || ''} ${r.bls_code || ''}`.toLowerCase()
return hay.includes(term)
})
const applyChange = async (id, food) => {
setSaving(true)
setError(null)
try {
await api.adminUpdateFoodMapping(id, { food_id: food.id })
setEditId(null)
setEditHits([])
load()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return (
<div className="card">
<h1 className="page-title">FDDBBLS Mappings</h1>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
{coverage && (
<p style={{ fontSize: 13 }}>
{coverage.mapped_items}/{coverage.total_items} Zeilen gemappt ·
{coverage.unmapped_names} ungemappte Namen
</p>
)}
<div className="form-row">
<input className="form-input" placeholder="FDDB-Bezeichner" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="form-row">
<input className="form-input" placeholder="Lebensmittel suchen" value={foodQ} onChange={(e) => {
setFoodQ(e.target.value)
clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => searchFoods(e.target.value, setHits), 300)
}} />
</div>
{hits.map((h) => (
<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}
</button>
))}
<button
type="button"
className="btn btn-primary"
disabled={!name || !foodId}
onClick={async () => {
try {
await api.adminCreateFoodMapping({ source_name: name, food_id: foodId })
setName(''); setFoodId(''); setFoodQ(''); setHits([]); load()
} catch (e) { setError(e.message) }
}}
>
Globales Mapping anlegen
</button>
<input className="form-input" style={{ width: '100%', textAlign: 'left', margin: '16px 0 8px' }} placeholder="Liste filtern…" value={filter} onChange={(e) => setFilter(e.target.value)} />
{shown.map((r) => (
<div key={r.id} style={{ padding: '8px 0', borderTop: '1px solid var(--border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
<div>
{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>
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn btn-secondary" onClick={() => { setEditId(editId === r.id ? null : r.id); setEditQ(''); setEditHits([]) }}>
{editId === r.id ? 'Schließen' : 'Ändern'}
</button>
<button type="button" className="btn btn-secondary" onClick={async () => { await api.adminDeleteFoodMapping(r.id); load() }}>Löschen</button>
</div>
</div>
{editId === r.id && (
<div style={{ marginTop: 8 }}>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Neues Lebensmittel suchen"
value={editQ}
onChange={(e) => {
setEditQ(e.target.value)
clearTimeout(editTimer.current)
editTimer.current = setTimeout(() => searchFoods(e.target.value, setEditHits), 300)
}}
/>
{editHits.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 6, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '8px 10px' }}
disabled={saving}
onClick={() => applyChange(r.id, h)}
>
{h.name_de}{h.bls_code ? ` · ${h.bls_code}` : ''}
</button>
))}
</div>
)}
</div>
))}
</div>
)
}

View File

@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react' import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
import UsageBadge from '../components/UsageBadge'
import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap'
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
@ -193,6 +195,49 @@ function EntryForm({ onSaved }) {
} }
// Data Tab (Editable Entry List) // Data Tab (Editable Entry List)
function DayItems({ date }) {
const [items, setItems] = useState(null)
const [open, setOpen] = useState(false)
const [error, setError] = useState(null)
const toggle = async () => {
if (open) {
setOpen(false)
return
}
if (!items) {
try {
setItems(await nutritionApi.listNutritionItems(date))
} catch (e) {
setError(e.message)
return
}
}
setOpen(true)
}
return (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={toggle}>
{open ? 'Lebensmittel ausblenden' : 'Lebensmittel zeigen'}
</button>
{error && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{error}</p>}
{open && Array.isArray(items) && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 13, color: 'var(--text1)' }}>
{items.length === 0 && <li style={{ color: 'var(--text3)' }}>Keine Zeilen für diesen Tag</li>}
{items.map((it) => (
<li key={it.id}>
{it.source_name_raw}
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
{it.food_name_de ? `${it.food_name_de}` : ' · noch nicht zugeordnet'}
</li>
))}
</ul>
)}
</div>
)
}
function DataTab({ entries, onUpdate }) { function DataTab({ entries, onUpdate }) {
const [editId, setEditId] = useState(null) const [editId, setEditId] = useState(null)
const [editValues, setEditValues] = useState({}) const [editValues, setEditValues] = useState({})
@ -323,9 +368,19 @@ function DataTab({ entries, onUpdate }) {
</div> </div>
{e.source && ( {e.source && (
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}> <div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
Quelle: {e.source} Quelle: {e.source}{e.macro_origin ? ` · Makros: ${e.macro_origin}` : ''}
{e.mark_type ? ` · ${e.mark_type === 'fasting' ? 'Fastentag' : 'unvollständig'}` : ''}
{e.item_count ? ` · ${e.item_count} Lebensmittel` : ''}
</div> </div>
)} )}
{e.item_count > 0 ? (
<DayItems date={e.date} />
) : (
<div style={{fontSize:12, color:'var(--text3)', marginTop:6}}>Keine einzelnen Lebensmittelzeilen</div>
)}
<div style={{marginTop:8}}>
<DayMarkButtons date={e.date} markType={e.mark_type} onChanged={onUpdate} />
</div>
</> </>
) : ( ) : (
<> <>
@ -433,20 +488,25 @@ function ImportHistory() {
} }
// Import Panel // Import Panel
function ImportPanel({ onImported }) { function ImportPanel({ onImported, usage = null }) {
const fileRef = useRef() const fileRef = useRef()
const [status, setStatus] = useState(null) const [status, setStatus] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [dragging,setDragging]= useState(false) const [dragging,setDragging]= useState(false)
const [tab, setTab] = useState('file') // 'file' | 'paste' const [tab, setTab] = useState('file') // 'file' | 'paste'
const [pasteText, setPasteText] = useState('') const [pasteText, setPasteText] = useState('')
const [overwrite, setOverwrite] = useState(false)
const [conflicts, setConflicts] = useState([])
const atLimit = usage && !usage.allowed
const runImport = async (file) => { const runImport = async (file) => {
setStatus('loading'); setError(null) if (atLimit) return
setStatus('loading'); setError(null); setConflicts([])
try { try {
const result = await nutritionApi.importCsv(file) const result = await nutritionApi.importCsv(file, overwrite)
if (result.days_imported === undefined) throw new Error(JSON.stringify(result)) if (result.days_imported === undefined) throw new Error(JSON.stringify(result))
setStatus(result) setStatus(result)
setConflicts(result.conflicts || [])
onImported() onImported()
} catch(err) { } catch(err) {
setError('Import fehlgeschlagen: ' + err.message) setError('Import fehlgeschlagen: ' + err.message)
@ -468,7 +528,7 @@ function ImportPanel({ onImported }) {
} }
const handlePasteImport = async () => { const handlePasteImport = async () => {
if (!pasteText.trim()) return if (!pasteText.trim() || atLimit) return
const blob = new Blob([pasteText], { type: 'text/csv' }) const blob = new Blob([pasteText], { type: 'text/csv' })
const file = new File([blob], 'paste.csv', { type: 'text/csv' }) const file = new File([blob], 'paste.csv', { type: 'text/csv' })
await runImport(file) await runImport(file)
@ -476,10 +536,18 @@ function ImportPanel({ onImported }) {
return ( return (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">📥 FDDB CSV Import</div> <div className="card-title badge-container-right">
<span>📥 FDDB CSV Import</span>
{usage && <UsageBadge {...usage} />}
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}> <p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
In FDDB: <strong>Mein Tagebuch Exportieren CSV</strong> dann hier importieren. In FDDB: <strong>Mein Tagebuch Exportieren CSV</strong> dann hier importieren.
Zeilen (Name, Menge, Uhrzeit) werden gespeichert, wenn die CSV sie enthält.
</p> </p>
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,marginBottom:10}}>
<input type="checkbox" checked={overwrite} onChange={(e)=>setOverwrite(e.target.checked)} />
Vorhandene Tagesmakros überschreiben (Initialimport)
</label>
{/* Tab switcher */} {/* Tab switcher */}
<div style={{display:'flex',gap:6,marginBottom:12}}> <div style={{display:'flex',gap:6,marginBottom:12}}>
@ -498,23 +566,26 @@ function ImportPanel({ onImported }) {
<> <>
{/* Drag & Drop Zone */} {/* Drag & Drop Zone */}
<div <div
onDragOver={e=>{e.preventDefault();setDragging(true)}} onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
onDragLeave={()=>setDragging(false)} onDragLeave={()=>setDragging(false)}
onDrop={handleDrop} onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; handleDrop(e) }}
onClick={()=>fileRef.current.click()} onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
style={{ style={{
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`, border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
borderRadius:10, padding:'24px 16px', textAlign:'center', borderRadius:10, padding:'24px 16px', textAlign:'center',
background: dragging?'var(--accent-light)':'var(--surface2)', background: dragging?'var(--accent-light)':'var(--surface2)',
cursor:'pointer', transition:'all 0.15s', cursor:atLimit?'not-allowed':'pointer', opacity:atLimit?0.65:1, transition:'all 0.15s',
}}> }}>
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/> <Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}> <div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
{dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'} {atLimit
? '🔒 Limit erreicht — Import nicht möglich'
: dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
</div> </div>
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div> <div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
</div> </div>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} onChange={handleFile}/> <input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} disabled={atLimit} onChange={handleFile}/>
</> </>
)} )}
@ -523,14 +594,20 @@ function ImportPanel({ onImported }) {
<textarea <textarea
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11, style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8, background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
color:'var(--text1)',resize:'vertical',boxSizing:'border-box'}} color:'var(--text1)',resize:'vertical',boxSizing:'border-box',
opacity:atLimit?0.65:1}}
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung;&#10;13.03.2026 21:54;50 g Hähnchen;..." placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung;&#10;13.03.2026 21:54;50 g Hähnchen;..."
value={pasteText} value={pasteText}
onChange={e=>setPasteText(e.target.value)} onChange={e=>setPasteText(e.target.value)}
disabled={atLimit}
/> />
<button className="btn btn-primary btn-full" style={{marginTop:8}} <button className="btn btn-primary btn-full" style={{marginTop:8}}
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}> onClick={handlePasteImport}
{status==='loading' disabled={status==='loading'||!pasteText.trim()||atLimit}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}>
{atLimit
? '🔒 Limit erreicht'
: status==='loading'
? <><div className="spinner" style={{width:14,height:14}}/> Importiere</> ? <><div className="spinner" style={{width:14,height:14}}/> Importiere</>
: <><Upload size={15}/> CSV-Text importieren</>} : <><Upload size={15}/> CSV-Text importieren</>}
</button> </button>
@ -552,7 +629,7 @@ function ImportPanel({ onImported }) {
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}> <div style={{display:'flex',alignItems:'center',gap:6,marginBottom:4}}>
<CheckCircle size={15}/><strong>Import erfolgreich</strong> <CheckCircle size={15}/><strong>Import erfolgreich</strong>
</div> </div>
<div>{status.days_imported} Tage importiert · {status.rows_parsed} Einträge verarbeitet</div> <div>{status.days_imported} Tage · {status.rows_parsed} Zeilen{status.items_written ? ` · ${status.items_written} Items` : ''}</div>
{status.date_range?.from && ( {status.date_range?.from && (
<div style={{fontSize:11,marginTop:2}}> <div style={{fontSize:11,marginTop:2}}>
{dayjs(status.date_range.from).format('DD.MM.YYYY')} {dayjs(status.date_range.to).format('DD.MM.YYYY')} {dayjs(status.date_range.from).format('DD.MM.YYYY')} {dayjs(status.date_range.to).format('DD.MM.YYYY')}
@ -560,6 +637,45 @@ function ImportPanel({ onImported }) {
)} )}
</div> </div>
)} )}
{conflicts.length > 0 && (
<ConflictDialog conflicts={conflicts} onDone={() => { setConflicts([]); onImported() }} />
)}
</div>
)
}
function ConflictDialog({ conflicts, onDone }) {
const [choices, setChoices] = useState({})
const [busy, setBusy] = useState(false)
const setC = (date, choice) => setChoices((s) => ({ ...s, [date]: choice }))
const save = async () => {
setBusy(true)
try {
const decisions = conflicts.map((c) => ({ date: c.date, choice: choices[c.date] || 'existing' }))
await nutritionApi.resolveNutritionConflicts(decisions)
onDone()
} catch (e) {
alert(e.message)
} finally {
setBusy(false)
}
}
return (
<div style={{marginTop:12,padding:12,border:'1px solid var(--border)',borderRadius:8}}>
<strong>Abweichende Tagesmakros</strong>
{conflicts.map((c) => (
<div key={c.date} style={{marginTop:10,fontSize:12}}>
<div>{c.date}</div>
<div>Ist {c.existing?.kcal} · FDDB {c.fddb?.kcal} · Katalog {c.catalog?.kcal} kcal</div>
{['existing','fddb','catalog'].map((k) => (
<label key={k} style={{marginRight:10}}>
<input type="radio" name={`c-${c.date}`} checked={(choices[c.date]||'existing')===k} onChange={()=>setC(c.date,k)} />
{k === 'existing' ? 'behalten' : k === 'fddb' ? 'FDDB' : 'Katalog'}
</label>
))}
</div>
))}
<button type="button" className="btn btn-primary" style={{marginTop:10}} disabled={busy} onClick={save}>Übernehmen</button>
</div> </div>
) )
} }
@ -781,30 +897,62 @@ export default function NutritionPage() {
const [loading, setLoad] = useState(true) const [loading, setLoad] = useState(true)
const [hasData, setHasData]= useState(false) const [hasData, setHasData]= useState(false)
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
const [nutritionUsage, setNutritionUsage] = useState(null)
const [unmappedCount, setUnmappedCount] = useState(0)
const [unmappedTotal, setUnmappedTotal] = useState(0)
const loadUsage = () => {
nutritionApi.getFeatureUsage().then(features => {
const nutritionFeature = features.find(f => f.feature_id === 'nutrition_entries')
setNutritionUsage(nutritionFeature ?? null)
}).catch(err => console.error('Failed to load usage:', err))
}
const load = async () => { const load = async () => {
setLoad(true) setLoad(true)
try { try {
const [corr, wkly, ent, prof] = await Promise.all([ const [corr, wkly, ent, prof, unmapped] = await Promise.all([
nutritionApi.nutritionCorrelations(), nutritionApi.nutritionCorrelations(),
nutritionApi.nutritionWeekly(16), nutritionApi.nutritionWeekly(16),
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
nutritionApi.getActiveProfile(), nutritionApi.getActiveProfile(),
nutritionApi.listUnmappedFoodCount(28).catch(() => ({ count: 0, total: 0 })),
]) ])
setCorr(Array.isArray(corr)?corr:[]) setCorr(Array.isArray(corr)?corr:[])
setWeekly(Array.isArray(wkly)?wkly:[]) setWeekly(Array.isArray(wkly)?wkly:[])
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
setProf(prof) setProf(prof)
setUnmappedCount(Number(unmapped?.count) || 0)
setUnmappedTotal(Number(unmapped?.total) || Number(unmapped?.count) || 0)
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal)) setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
} catch(e) { console.error('load error:', e) } } catch(e) { console.error('load error:', e) }
finally { setLoad(false) } finally { setLoad(false) }
} }
useEffect(() => { load() }, []) const refreshUnmappedCount = async () => {
try {
const d = await nutritionApi.listUnmappedFoodCount(28)
setUnmappedCount(Number(d?.count) || 0)
setUnmappedTotal(Number(d?.total) || Number(d?.count) || 0)
} catch { /* Banner bleibt auf letztem Stand */ }
}
useEffect(() => { load(); loadUsage() }, [])
return ( return (
<div className="capture-page"> <div className="capture-page">
<h1 className="page-title">Ernährung</h1> <h1 className="page-title">Ernährung</h1>
{(unmappedCount > 0 || unmappedTotal > 0) && (
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
{unmappedCount > 0
? `${unmappedCount} Lebensmittel der letzten 4 Wochen noch ohne Zuordnung${unmappedTotal > unmappedCount ? ` · ${unmappedTotal} insgesamt` : ''}.`
: `In den letzten 4 Wochen ist alles zugeordnet. ${unmappedTotal} ältere Namen ohne Zuordnung — nur nötig, wenn du sie wieder isst.`}
{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
Jetzt zuordnen
</button>
</div>
)}
{/* Input Method Tabs */} {/* Input Method Tabs */}
<div className="tabs section-gap" style={{marginBottom:0}}> <div className="tabs section-gap" style={{marginBottom:0}}>
@ -814,6 +962,9 @@ export default function NutritionPage() {
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}> <button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
📥 Import 📥 Import
</button> </button>
<button className={'tab'+(inputTab==='map'?' active':'')} onClick={()=>setInputTab('map')}>
Zuordnen{unmappedCount ? ` (${unmappedCount})` : ''}
</button>
</div> </div>
{/* Entry Form */} {/* Entry Form */}
@ -822,14 +973,24 @@ export default function NutritionPage() {
{/* Import Panel + History */} {/* Import Panel + History */}
{inputTab==='import' && ( {inputTab==='import' && (
<> <>
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/> <ImportPanel
usage={nutritionUsage}
onImported={() => { load(); loadUsage(); setImportHistoryKey(Date.now()) }}
/>
<ImportHistory key={importHistoryKey}/> <ImportHistory key={importHistoryKey}/>
</> </>
)} )}
{loading && <div className="empty-state"><div className="spinner"/></div>} {inputTab==='map' && (
<NutritionFoodMap
onMapped={refreshUnmappedCount}
onChanged={refreshUnmappedCount}
/>
)}
{!loading && !hasData && ( {loading && inputTab !== 'map' && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && inputTab !== 'map' && (
<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>
@ -837,7 +998,7 @@ export default function NutritionPage() {
)} )}
{/* Analysis Section */} {/* Analysis Section */}
{!loading && hasData && ( {!loading && hasData && inputTab !== 'map' && (
<> <>
<OverviewCards data={corrData}/> <OverviewCards data={corrData}/>

View File

@ -458,6 +458,33 @@ export default function SettingsPage() {
</Link> </Link>
</div> </div>
<div className="card section-gap">
<div className="card-title">Ernährungs-Import</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
Wenn für einen Tag schon Makros existieren: nachfragen, Katalog-/FDDB-Summe schreiben oder die bestehenden Werte behalten.
Für den einmaligen FDDB-Reimport kannst du auch die Checkbox am Import nutzen.
</p>
<select
className="form-input"
value={activeProfile?.nutrition_import_conflict_policy || 'prompt'}
onChange={async (e) => {
try {
await api.updateActiveProfile({ nutrition_import_conflict_policy: e.target.value })
await refreshProfiles()
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (err) {
setProfileErr(err.message)
}
}}
>
<option value="prompt">Bei Abweichung nachfragen</option>
<option value="overwrite_catalog">Immer Katalog-/BLS-Summe</option>
<option value="overwrite_fddb">Immer FDDB-Rohsumme</option>
<option value="keep_existing">Bestehende Makros behalten</option>
</select>
</div>
{/* Auth actions */} {/* Auth actions */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">🔐 Konto</div> <div className="card-title">🔐 Konto</div>

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react' import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { csvPreviewTdStyle } from '../utils/csvPreviewCells' import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
import CsvImportErrorDetails from '../components/CsvImportErrorDetails'
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */ /** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
const EXECUTOR_READY = new Set([ const EXECUTOR_READY = new Set([
@ -200,10 +201,14 @@ export default function UniversalCsvImportPage() {
setLastImport(res) setLastImport(res)
const st = res.stats || {} const st = res.stats || {}
const modLabel = MODULE_LABEL[res.module] || res.module || '' const modLabel = MODULE_LABEL[res.module] || res.module || ''
const extra = []
if (st.items_written) extra.push(`${st.items_written} Lebensmittelzeilen`)
if (st.unmapped_names) extra.push(`${st.unmapped_names} noch ohne Zuordnung — Ernährung → Zuordnen`)
setSuccess( setSuccess(
(modLabel ? `${modLabel}: ` : '') + (modLabel ? `${modLabel}: ` : '') +
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` + `Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`, `${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.` +
(extra.length ? ` ${extra.join(' · ')}.` : ''),
) )
} catch (e) { } catch (e) {
setError(e.message || 'Import fehlgeschlagen') setError(e.message || 'Import fehlgeschlagen')
@ -288,29 +293,11 @@ export default function UniversalCsvImportPage() {
)} )}
{lastImport?.error_details?.length > 0 && ( {lastImport?.error_details?.length > 0 && (
<details <CsvImportErrorDetails
className="card" errors={lastImport.error_details}
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }} title={`Zeilenfehler vom letzten Import (${lastImport.error_details.length})`}
open defaultOpen
> />
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) zum Kopieren aufklappen
</summary>
<pre
style={{
marginTop: 12,
fontSize: 12,
overflow: 'auto',
maxHeight: 320,
background: 'var(--surface2)',
padding: 12,
borderRadius: 8,
color: 'var(--text1)',
}}
>
{JSON.stringify(lastImport.error_details, null, 2)}
</pre>
</details>
)} )}
<div className="card" style={{ marginBottom: 16, padding: 16 }}> <div className="card" style={{ marginBottom: 16, padding: 16 }}>

View File

@ -29,6 +29,13 @@ export function formatFastApiDetail(detail, fallback = '') {
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler' return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
} }
if (typeof detail === 'object') { if (typeof detail === 'object') {
if (detail.validation?.errors?.length) {
const parts = detail.validation.errors
.map((e) => (e && typeof e === 'object' ? e.message || e.msg || '' : String(e)))
.filter(Boolean)
const prefix = detail.message || 'Vorlage ungültig'
return parts.length ? `${prefix}: ${parts.join(' · ')}` : prefix
}
if (Array.isArray(detail.errors) && detail.errors.length > 0) { if (Array.isArray(detail.errors) && detail.errors.length > 0) {
const parts = detail.errors const parts = detail.errors
.map((e) => { .map((e) => {
@ -66,6 +73,29 @@ async function req(path, opts={}) {
} }
return res.json() return res.json()
} }
async function readJsonResponse(res) {
const text = await res.text()
const trimmed = (text || '').trim()
if (!trimmed) {
throw new Error(res.ok ? 'Leere Antwort' : `HTTP ${res.status}`)
}
if (trimmed.startsWith('<')) {
throw new Error(
`Server lieferte HTML statt JSON (HTTP ${res.status}). Meist Timeout oder Datei zu groß — erneut versuchen.`
)
}
let parsed
try {
parsed = JSON.parse(trimmed)
} catch {
throw new Error(trimmed.slice(0, 180) || `HTTP ${res.status}`)
}
if (!res.ok) {
throw new Error(formatFastApiDetail(parsed.detail, JSON.stringify(parsed)))
}
return parsed
}
const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)}) const json=(d)=>({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
const jput=(d)=>({method:'PUT', headers:{'Content-Type':'application/json'},body:JSON.stringify(d)}) const jput=(d)=>({method:'PUT', headers:{'Content-Type':'application/json'},body:JSON.stringify(d)})
@ -203,11 +233,78 @@ export const api = {
}, },
// Nutrition // Nutrition
importCsv: async(file)=>{ importCsv: async(file, overwriteExisting=false)=>{
const fd=new FormData();fd.append('file',file) const fd=new FormData();fd.append('file',file)
const r=await fetch(`${BASE}/nutrition/import-csv`,{method:'POST',body:fd,headers:hdrs()}) const qs = overwriteExisting ? '?overwrite_existing=true' : ''
const r=await fetch(`${BASE}/nutrition/import-csv${qs}`,{method:'POST',body:fd,headers:hdrs()})
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
}, },
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
listUnmappedFoods: (sinceDays=0) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}`),
listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`),
listNutritionRecipes: () => req('/nutrition/recipes'),
createNutritionRecipe: (d) => req('/nutrition/recipes', json(d)),
updateNutritionRecipe: (id, d) => req(`/nutrition/recipes/${id}`, jput(d)),
deleteNutritionRecipe: (id) => req(`/nutrition/recipes/${id}`, {method:'DELETE'}),
importFddbLists: async (file) => {
const fd = new FormData(); fd.append('file', file)
const r = await fetch(`${BASE}/nutrition/recipes/import-fddb-lists`, { method: 'POST', body: fd, headers: hdrs() })
return readJsonResponse(r)
},
applyNutritionRecipe: (recipeId, sourceName) => req(`/nutrition/recipes/${recipeId}/apply`, json({ source_name: sourceName })),
exportFoodKnowledge: async () => {
const r = await fetch(`${BASE}/nutrition/food-knowledge`, { headers: hdrs() })
if (!r.ok) {
const text = await r.text()
let msg = text.trim() || `HTTP ${r.status}`
try { msg = formatFastApiDetail(JSON.parse(text).detail, msg) } catch { /* HTML/Text */ }
throw new Error(msg)
}
const blob = await r.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `mitai-food-knowledge-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
},
importFoodKnowledge: async (file) => {
const fd = new FormData(); fd.append('file', file)
const r = await fetch(`${BASE}/nutrition/food-knowledge`, { method: 'POST', body: fd, headers: hdrs() })
return readJsonResponse(r)
},
listNutritionMarks: () => req('/nutrition/marks'),
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
listFoodUnits: () => req('/bls/units'),
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
createUserFood: (d) => req('/bls/foods/manual', json(d)),
listFoodAttributes: (q='', limit=40) => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}`),
listMyFoodMappings: () => req('/bls/mappings'),
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
adminBlsStatus: () => req('/admin/bls/status'),
adminBlsImportStart: async (kind, file) => {
const fd=new FormData();fd.append('file',file)
const r=await fetch(`${BASE}/admin/bls/import/jobs?kind=${encodeURIComponent(kind)}`,{method:'POST',body:fd,headers:hdrs()})
return readJsonResponse(r)
},
adminBlsImportJob: (id) => req(`/admin/bls/import/jobs/${id}`),
adminBlsImportApply: (id) => req(`/admin/bls/import/jobs/${id}/apply`, {method:'POST'}),
adminBlsFoods: (q, kind) => req(`/admin/bls/foods?${q?('q='+encodeURIComponent(q)+'&'):''}${kind?('kind='+kind):''}`),
adminBlsFoodDetail: (id) => req(`/admin/bls/foods/${id}`),
adminCreateManualFood: (d) => req('/admin/bls/foods/manual', json(d)),
adminBlsAttributes: () => req('/admin/bls/attributes'),
adminCreateBlsAttribute: (d) => req('/admin/bls/attributes', json(d)),
adminListFoodMappings: (globalOnly) => req(`/admin/food-mappings${globalOnly?'?global_only=true':''}`),
adminCreateFoodMapping: (d) => req('/admin/food-mappings', json(d)),
adminUpdateFoodMapping: (id, d) => req(`/admin/food-mappings/${id}`, jput(d)),
adminDeleteFoodMapping: (id) => req(`/admin/food-mappings/${id}`, {method:'DELETE'}),
adminFoodMappingCoverage: () => req('/admin/food-mappings/stats/coverage'),
listNutrition: (l=365) => req(`/nutrition?limit=${l}`), listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
nutritionCorrelations: () => req('/nutrition/correlations'), nutritionCorrelations: () => req('/nutrition/correlations'),
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`), nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
@ -713,6 +810,7 @@ export const api = {
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'), req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
copyCsvMapping: (mappingId, body = null) => copyCsvMapping: (mappingId, body = null) =>
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }), req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
validateCsvMapping: (mappingId) => req(`/csv/mappings/${mappingId}/validate`),
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */ /** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
/** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */ /** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */
diagnoseUniversalCsv: async (file, mappingId, module = null) => { diagnoseUniversalCsv: async (file, mappingId, module = null) => {

View File

@ -51,8 +51,8 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # KI-Calls können länger dauern proxy_read_timeout 600s; # KI-Calls und BLS-Import
client_max_body_size 20M; # CSV + Foto Uploads client_max_body_size 50M; # CSV, Foto, BLS-XLSX
} }
# Frontend - React PWA # Frontend - React PWA

View File

@ -1,12 +1,15 @@
module.exports = { module.exports = {
testDir: './tests', testDir: './tests',
timeout: 30000, timeout: 30000,
workers: 1,
globalSetup: require.resolve('./tests/global-setup.js'),
use: { use: {
channel: 'chrome', channel: 'chrome',
headless: true, headless: true,
viewport: { width: 390, height: 844 }, viewport: { width: 390, height: 844 },
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
baseURL: 'https://dev.mitai.jinkendo.de', baseURL: 'https://dev.mitai.jinkendo.de',
storageState: './tests/.auth/user.json',
}, },
reporter: 'list', reporter: 'list',
}; };

View File

@ -1,65 +1,91 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { loginViaForm, clickBottomNav } = require('./helpers');
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com'; test.describe('Login-Flow', () => {
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112'; test.use({ storageState: { cookies: [], origins: [] } });
async function login(page) { test('1. Login funktioniert', async ({ page }) => {
await page.goto('/'); await loginViaForm(page);
await page.waitForLoadState('networkidle'); await page.screenshot({ path: 'screenshots/01-nach-login.png' });
await page.fill('input[type="email"]', TEST_EMAIL); console.log('Login erfolgreich');
await page.fill('input[type="password"]', TEST_PASSWORD); });
await page.click('button:has-text("Anmelden")');
await page.waitForLoadState('networkidle');
}
test('1. Login funktioniert', async ({ page }) => {
await page.goto('/');
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button:has-text("Anmelden")');
await page.waitForLoadState('networkidle');
const loginButton = page.locator('button:has-text("Anmelden")');
await expect(loginButton).toHaveCount(0, { timeout: 10000 });
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
console.log('Login erfolgreich');
}); });
test('2. Dashboard laedt ohne Fehler', async ({ page }) => { test('2. Dashboard laedt ohne Fehler', async ({ page }) => {
await login(page); await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 }); await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await page.screenshot({ path: 'screenshots/02-dashboard.png' }); await page.screenshot({ path: 'screenshots/02-dashboard.png' });
console.log('Dashboard OK'); console.log('Dashboard OK');
}); });
test('3. Erfassung erreichbar', async ({ page }) => { test('3. Erfassen erreichbar', async ({ page }) => {
await login(page); await page.goto('/');
await page.click('text=Erfassung');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await clickBottomNav(page, 'Erfassen');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/capture/);
await page.screenshot({ path: 'screenshots/03-erfassung.png' }); await page.screenshot({ path: 'screenshots/03-erfassung.png' });
console.log('Erfassung OK'); console.log('Erfassen OK');
}); });
test('4. Analyse erreichbar', async ({ page }) => { test('4. Analyse erreichbar', async ({ page }) => {
await login(page); await page.goto('/');
await page.click('text=Analyse');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await clickBottomNav(page, 'Analyse');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/analysis/);
await page.screenshot({ path: 'screenshots/04-analyse.png' }); await page.screenshot({ path: 'screenshots/04-analyse.png' });
console.log('Analyse OK'); console.log('Analyse OK');
}); });
test('5. Keine kritischen Console-Fehler', async ({ page }) => { test('5. Keine kritischen Console-Fehler', async ({ page }) => {
const errors = []; const errors = [];
page.on('console', msg => { page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text()); if (msg.type() === 'error') errors.push(msg.text());
}); });
await login(page); await page.goto('/');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const kritisch = errors.filter(e => const kritisch = errors.filter(
!e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404') (e) => !e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404')
); );
if (kritisch.length > 0) { if (kritisch.length > 0) {
console.log('Console-Fehler:', kritisch.join(', ')); console.log('Console-Fehler:', kritisch.join(', '));
} else { } else {
console.log('Keine kritischen Console-Fehler'); console.log('Keine kritischen Console-Fehler');
} }
}); });
test('FEATURE: Ernährung — Einzelerfassung, Import-Policy-Hinweis, Zuordnen', async ({ page }) => {
await page.goto('/nutrition');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('button', { name: /Einzelerfassung/i })).toBeVisible();
await page.getByRole('button', { name: /Import/i }).click();
await expect(page.locator('.card').filter({ hasText: 'FDDB CSV Import' })).toBeVisible();
await expect(page.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible();
await page.getByRole('button', { name: /Zuordnen/i }).click();
await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible();
await expect(page.getByRole('button', { name: /FDDB-Listen/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Zuordnungen & Listen exportieren/ })).toBeVisible();
});
test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
await expect(page.getByText('Ernährungs-Import')).toBeVisible();
await expect(page.locator('select').filter({ has: page.locator('option[value="prompt"]') })).toBeVisible();
});
test('API: nutrition unmapped + marks erreichbar', async ({ page }) => {
await page.goto('/nutrition');
await page.waitForLoadState('networkidle');
const token = await page.evaluate(() => localStorage.getItem('bodytrack_token'));
expect(token).toBeTruthy();
const headers = { 'X-Auth-Token': token };
const unmapped = await page.request.get('/api/nutrition/unmapped', { headers });
expect(unmapped.ok()).toBeTruthy();
const marks = await page.request.get('/api/nutrition/marks', { headers });
expect(marks.ok()).toBeTruthy();
const foods = await page.request.get('/api/bls/foods?q=hafer', { headers });
expect(foods.ok()).toBeTruthy();
});

64
tests/global-setup.js Normal file
View File

@ -0,0 +1,64 @@
const { chromium } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
const AUTH_FILE = path.join(__dirname, '.auth', 'user.json');
async function tryLogin(page, baseURL) {
await page.goto(`${baseURL}/`);
await page.waitForLoadState('networkidle');
const loginBtn = page.locator('button:has-text("Anmelden")');
if ((await loginBtn.count()) === 0) {
return { ok: true, status: 200 };
}
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
const responsePromise = page
.waitForResponse(
(r) => r.url().includes('/api/auth/login') && r.request().method() === 'POST',
{ timeout: 15000 }
)
.catch(() => null);
await page.click('button:has-text("Anmelden")');
const response = await responsePromise;
await page.waitForLoadState('networkidle');
const loginVisible = await page.locator('button:has-text("Anmelden")').count();
return { ok: loginVisible === 0, status: response?.status() ?? null };
}
module.exports = async (config) => {
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage();
const baseURL = config.projects?.[0]?.use?.baseURL || 'https://dev.mitai.jinkendo.de';
const waits = [0, 15000, 30000, 45000];
let lastStatus = null;
for (let i = 0; i < waits.length; i++) {
if (waits[i] > 0) {
console.log(`Global setup: warte ${waits[i] / 1000}s (Login-Rate-Limit 5/min)…`);
await page.waitForTimeout(waits[i]);
}
const result = await tryLogin(page, baseURL);
lastStatus = result.status;
if (result.ok) {
await page.context().storageState({ path: AUTH_FILE });
await browser.close();
return;
}
}
await browser.close();
throw new Error(
`Global setup: Login fehlgeschlagen (letzter HTTP-Status: ${lastStatus ?? 'unbekannt'})`
);
};

23
tests/helpers.js Normal file
View File

@ -0,0 +1,23 @@
const { expect } = require('@playwright/test');
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
/** Nutzt storageState aus global-setup; nur für Tests nötig, die explizit Login prüfen. */
async function loginViaForm(page) {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button:has-text("Anmelden")');
await page.waitForLoadState('networkidle');
await expect(page.locator('button:has-text("Anmelden")')).toHaveCount(0, { timeout: 15000 });
}
async function clickBottomNav(page, label) {
const link = page.locator('nav.bottom-nav a.nav-item').filter({ hasText: label });
await expect(link).toBeVisible({ timeout: 10000 });
await link.click();
}
module.exports = { TEST_EMAIL, TEST_PASSWORD, loginViaForm, clickBottomNav };

52
tests/issue-audit.spec.js Normal file
View File

@ -0,0 +1,52 @@
/**
* Gitea Issue-Audit UI-Verifikation gegen dev.mitai.jinkendo.de
* Einmaliger Audit-Lauf; keine dauerhafte CI-Pflicht.
*/
const { test, expect } = require('@playwright/test');
test.describe('Issue-Audit UI', () => {
test('#40 Logout-Button im Mobile-Header sichtbar', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const logoutBtn = page.locator('header.app-header--mobile button[title="Abmelden"]');
await expect(logoutBtn).toBeVisible();
});
test('#25 Goals-Seite erreichbar und rendert Inhalt', async ({ page }) => {
await page.goto('/goals');
await page.waitForLoadState('networkidle');
await expect(page.locator('body')).not.toContainText('404', { timeout: 5000 });
const hasGoalsUi =
(await page.getByText(/Ziel/i).count()) > 0 ||
(await page.locator('.card').count()) > 0;
expect(hasGoalsUi).toBeTruthy();
});
test('#65 Dashboard-Layout-Konfiguration erreichbar', async ({ page }) => {
await page.goto('/settings/dashboard-layout');
await page.waitForLoadState('networkidle');
const body = await page.locator('body').innerText();
expect(body).toMatch(/Widget|Übersicht|Layout|Dashboard/i);
});
test('#30 Desktop-Sidebar bei breitem Viewport', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.setViewportSize({ width: 1280, height: 800 });
await expect(page.locator('aside.desktop-sidebar')).toBeVisible({ timeout: 10000 });
});
test('#38 Nutrition CSV Import — FDDB-Panel mit Usage-Integration', async ({ page }) => {
await page.goto('/nutrition');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /Import/i }).click();
await page.waitForLoadState('networkidle');
const importCard = page.locator('.card').filter({ hasText: 'FDDB CSV Import' });
await expect(importCard).toBeVisible();
await expect(importCard.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible();
const titleRow = importCard.locator('.card-title.badge-container-right');
if (await titleRow.count()) {
await expect(titleRow).toBeVisible();
}
});
});