diff --git a/.claude/docs/GITEA_ISSUES_INDEX.md b/.claude/docs/GITEA_ISSUES_INDEX.md index 7c44f43..5074d49 100644 --- a/.claude/docs/GITEA_ISSUES_INDEX.md +++ b/.claude/docs/GITEA_ISSUES_INDEX.md @@ -1,6 +1,6 @@ # 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 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 | | 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 diff --git a/.claude/docs/README.md b/.claude/docs/README.md index 8699b03..b824cd9 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -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) | | 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` | +| 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 | | 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 | diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md new file mode 100644 index 0000000..0546193 --- /dev/null +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -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“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. Zutatenlisten liegen im Tab **Listen** (sichtbar, durchsuchbar, bearbeitbar). Im Listendialog sieht man pro Zutat, ob ein Mapping fehlt, und kann dort bestätigen oder suchen — zusätzlich zum Tab Zuordnen. Die Zutatensuche zeigt zuerst Namen aus importierten/angelegten Listen, dann eigene Lebensmittel, dann den BLS. „Andere Liste wählen“ verknüpft eine Tagebuchzeile mit einer bestehenden Liste; der Dialog zeigt alle Listen, nicht nur Namens-Treffer. Nach dem Verknüpfen verschwindet die Zeile unter Zuordnen — Kennzeichnung „Liste: …“, solange sie noch offen ist. + +## 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,5–0,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. Persönlicher Zugang (URL + API-Token) unter Einstellungen — nicht systemweit; Token nur auf dem Server, nie im Export. Verbindungstest: `GET /api/user/` (Fallback Rezeptliste). Zutaten werden später live gelesen und auf BLS gemappt, kein Zutaten-Sync. Noch kein Rezeptimport; später `cooked_yield_g` und Katalogeintrag Fertiggericht pro 100 g. diff --git a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md index 1e3cd54..7094d12 100644 --- a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md +++ b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md @@ -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). -**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). --- diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md new file mode 100644 index 0000000..1481d8a --- /dev/null +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -0,0 +1,48 @@ +# BLS Food Reference – technische Spec + +**Stand:** 2026-09-12 · Migration **062**–**068** + +## 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 (Modul `tandoor`): Migration **067** `profile_tandoor_settings`; `GET/PUT /api/tandoor/settings` (Token nur `token_set` / `token_hint`); `POST /api/tandoor/test` (10/min, kein Redirect-Follow). Client: `data_layer/tandoor_client.py`. Import und `cooked_yield_g` später. +- Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g). +- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`. +- `GET /nutrition/recipes?brief=true` — Listen ohne Zutaten, nur `ingredient_count`. `GET /nutrition/recipes/{id}` lädt eine Liste. FDDB-Listenimport antwortet nach Upsert; `rebuild_daily_nutrients` im Hintergrund (`rebuild_pending`). +- Listen-Verknüpfung (`apply` / `link_recipes_to_items`) matcht über `names_match_list` (Mengen-Prefix in gespeichertem `source_name_normalized` zählt). Unmapped-Zeilen bekommen `matching_recipe_id` + `matching_recipe_name`. Frontend: Tab Ernährung → Listen. +- `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile. +- 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). diff --git a/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md b/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md index e47174c..eef19d0 100644 --- a/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md +++ b/.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md @@ -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. -- 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). +- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`). +- **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`). --- diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-25.md b/.claude/docs/working/gitea-close-2026-07-23/comment-25.md new file mode 100644 index 0000000..d0e627b --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-25.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-35.md b/.claude/docs/working/gitea-close-2026-07-23/comment-35.md new file mode 100644 index 0000000..369f363 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-35.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-36.md b/.claude/docs/working/gitea-close-2026-07-23/comment-36.md new file mode 100644 index 0000000..924d292 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-36.md @@ -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 111–112) + +Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx` diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-37.md b/.claude/docs/working/gitea-close-2026-07-23/comment-37.md new file mode 100644 index 0000000..bd18a95 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-37.md @@ -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) diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-38.md b/.claude/docs/working/gitea-close-2026-07-23/comment-38.md new file mode 100644 index 0000000..b73506c --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-38.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-40.md b/.claude/docs/working/gitea-close-2026-07-23/comment-40.md new file mode 100644 index 0000000..2c5a76a --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-40.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-43.md b/.claude/docs/working/gitea-close-2026-07-23/comment-43.md new file mode 100644 index 0000000..fa1a03c --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-43.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-54.md b/.claude/docs/working/gitea-close-2026-07-23/comment-54.md new file mode 100644 index 0000000..5cde62a --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-54.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-57.md b/.claude/docs/working/gitea-close-2026-07-23/comment-57.md new file mode 100644 index 0000000..0d7fed1 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-57.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-58.md b/.claude/docs/working/gitea-close-2026-07-23/comment-58.md new file mode 100644 index 0000000..0d7fed1 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-58.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-65.md b/.claude/docs/working/gitea-close-2026-07-23/comment-65.md new file mode 100644 index 0000000..804aea4 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-65.md @@ -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. diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-66.md b/.claude/docs/working/gitea-close-2026-07-23/comment-66.md new file mode 100644 index 0000000..a76b300 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-66.md @@ -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). diff --git a/.claude/docs/working/gitea-close-2026-07-23/comment-71.md b/.claude/docs/working/gitea-close-2026-07-23/comment-71.md new file mode 100644 index 0000000..b586fe6 --- /dev/null +++ b/.claude/docs/working/gitea-close-2026-07-23/comment-71.md @@ -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` diff --git a/.claude/rules/LESSONS_LEARNED.md b/.claude/rules/LESSONS_LEARNED.md index 1b01cfd..f1ffac4 100644 --- a/.claude/rules/LESSONS_LEARNED.md +++ b/.claude/rules/LESSONS_LEARNED.md @@ -176,6 +176,20 @@ known_limitations=( **Problem:** Versuch, `.claude/task/` Files zu committen **Lösung:** Nur `backend/` Code committen, `.claude/` ist local docs +## 9. Unmapped-Query: Kreuzjoin Liste × Tagebuch (12.09.2026) + +**Was:** Offene Lebensmittel über `food_recipes` mit `nutrition_items` per `recipe_id OR name_normalized` verbinden, um Zutaten ein Datum zu geben. +**Problem:** Nach FDDB-Listenimport Minuten bis Timeout; Zutaten ohne `last_date` fielen im 28-Tage-Filter weg — leere Offene-Liste. +**Lösung:** Nur Items mit `recipe_id` gruppieren; Fallback `recipe.updated_at`. Listen-GET `brief=true`. Rebuild nach Import im Hintergrund. + +**Regel:** Kein Join „Name der Liste gegen alle Tagebuchzeilen“. Datum aus der bereits gesetzten FK oder aus dem Eltern-Datensatz. + +## 10. `from __future__ import annotations` + FastAPI-Limiter (13.09.2026) + +**Was:** `tandoor.py` mit future-annotations und `body: TandoorProbeIn | None` unter `@limiter.limit`. +**Problem:** Pydantic löst den Typ nicht auf → Import-Crash → Backend startet nicht → Login bekommt HTML (`Unexpected token '<'`). +**Lösung:** Kein future-annotations in Routern; `Optional[TandoorProbeIn]`. Import-Test für den Router. + --- **Zusammenfassung Nutrition Cluster:** diff --git a/.gitignore b/.gitignore index 55e7229..26da15d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ coverage/ # Temp tmp/ *.tmp +test-results/ +screenshots/ +tests/.auth/ # Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md) .claude/** diff --git a/CLAUDE.md b/CLAUDE.md index 9e0c534..cc76692 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - **`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. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**. +- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“. +- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten. +- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. +- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`. +- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 +- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. + ### 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 diff --git a/backend/bls/__init__.py b/backend/bls/__init__.py new file mode 100644 index 0000000..e183941 --- /dev/null +++ b/backend/bls/__init__.py @@ -0,0 +1 @@ +"""BLS 4.0 ingest (official MRI XLSX).""" diff --git a/backend/bls/import_service.py b/backend/bls/import_service.py new file mode 100644 index 0000000..064917a --- /dev/null +++ b/backend/bls/import_service.py @@ -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), + } diff --git a/backend/bls/jobs.py b/backend/bls/jobs.py new file mode 100644 index 0000000..286a760 --- /dev/null +++ b/backend/bls/jobs.py @@ -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)) diff --git a/backend/bls/parser.py b/backend/bls/parser.py new file mode 100644 index 0000000..63e90ee --- /dev/null +++ b/backend/bls/parser.py @@ -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} diff --git a/backend/bls/recipe_parser.py b/backend/bls/recipe_parser.py new file mode 100644 index 0000000..4b2ee11 --- /dev/null +++ b/backend/bls/recipe_parser.py @@ -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\d+(?:[.,]\d+)?)\s*(?Pg|kg|ml|l)\b\s*(?P.+?)" + 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 diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index 6a63874..056c128 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -11,7 +11,31 @@ from typing import Any 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 ( aggregate_mapped_rows, resolve_import_row_processing, @@ -181,6 +205,8 @@ def run_universal_csv_import( "error_details": error_details[:50], "new_entries": stats.get("new_entries", stats.get("inserted", 0)), "affected_ids": dict(affected_ids), + "items_written": stats.get("items_written", 0), + "unmapped_names": stats.get("unmapped_names", 0), } return out @@ -203,6 +229,11 @@ def _import_nutrition( for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header): rows_total += 1 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")) if d is None: 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") + 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 updated = 0 new_entries = 0 diff --git a/backend/csv_parser/mapping_suggest.py b/backend/csv_parser/mapping_suggest.py index 905bf44..9fcedfe 100644 --- a/backend/csv_parser/mapping_suggest.py +++ b/backend/csv_parser/mapping_suggest.py @@ -20,6 +20,8 @@ _MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = { "protein_g": frozenset({"protein", "eiwei", "eiweiss"}), "fat_g": frozenset({"fett", "fat", "lipid"}), "carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}), + "food_name": frozenset({"bezeichnung", "lebensmittel", "food", "gericht"}), + "quantity_raw": frozenset({"menge", "quantity", "portion", "gramm"}), }, "weight": { "date": frozenset({"datum", "date", "tag", "day", "zeit"}), diff --git a/backend/csv_parser/module_registry.py b/backend/csv_parser/module_registry.py index 3786327..1d16b96 100644 --- a/backend/csv_parser/module_registry.py +++ b/backend/csv_parser/module_registry.py @@ -20,6 +20,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = { "protein_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"}, + "food_name": {"type": "string", "required": False, "label_de": "Lebensmittel"}, + "quantity_raw": {"type": "string", "required": False, "label_de": "Menge"}, }, "duplicate_key": ["profile_id", "date"], "duplicate_strategy": "update", diff --git a/backend/data_layer/food_attributes.py b/backend/data_layer/food_attributes.py new file mode 100644 index 0000000..980ce00 --- /dev/null +++ b/backend/data_layer/food_attributes.py @@ -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()} diff --git a/backend/data_layer/food_knowledge.py b/backend/data_layer/food_knowledge.py new file mode 100644 index 0000000..98ec49f --- /dev/null +++ b/backend/data_layer/food_knowledge.py @@ -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, + } diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py new file mode 100644 index 0000000..2bede24 --- /dev/null +++ b/backend/data_layer/food_mapping.py @@ -0,0 +1,655 @@ +"""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() + + +def item_name_keys(raw: str | None, stored_norm: str | None = None) -> set[str]: + """Compare diary names to list names despite stored quantity prefixes.""" + keys: set[str] = set() + for v in (raw, stored_norm): + if not v: + continue + text = str(v).strip() + if text: + keys.add(text.lower()) + norm = normalize_food_name(text) + if norm: + keys.add(norm) + return keys + + +def names_match_list(raw: str | None, stored_norm: str | None, list_norm: str | None) -> bool: + target = normalize_food_name(list_norm) if list_norm else "" + if not target: + return False + return target in item_name_keys(raw, stored_norm) + + +_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 fetch_unmapped_rows(cur, profile_id: str) -> list[dict[str, Any]]: + """Diary lines without food/recipe plus list ingredients without mapping. + + Ingredient dates come from recipe.updated_at or last diary use of that list + (items that already have recipe_id) — not a join of every recipe name to + every diary row. + """ + cur.execute( + """ + SELECT source_name_normalized + FROM food_name_mappings + WHERE profile_id = %s + """, + (profile_id,), + ) + 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 + """, + (profile_id,), + ) + diary = [dict(r) | {"kind": "diary"} for r in cur.fetchall()] + cur.execute( + """ + SELECT id, name_raw, name_normalized + FROM food_recipes + WHERE profile_id = %s + """, + (profile_id,), + ) + lists = [dict(r) for r in cur.fetchall()] + for row in diary: + keys = item_name_keys(row.get("source_name_raw"), row.get("source_name_normalized")) + hit = next( + ( + rec + for rec in lists + if rec.get("name_normalized") in keys + or normalize_food_name(rec.get("name_raw")) in keys + ), + None, + ) + if hit: + row["matching_recipe_id"] = str(hit["id"]) + row["matching_recipe_name"] = hit["name_raw"] + cur.execute( + """ + SELECT i.source_name_raw, i.source_name_normalized, + COUNT(*) AS count, + MIN(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) AS first_date, + MAX(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) 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 ( + SELECT recipe_id, MIN(date) AS first_used, MAX(date) AS last_used + FROM nutrition_items + WHERE profile_id = %s AND recipe_id IS NOT NULL + GROUP BY recipe_id + ) u ON u.recipe_id = r.id + WHERE r.profile_id = %s + AND NOT EXISTS ( + SELECT 1 FROM food_name_mappings m + WHERE m.profile_id = r.profile_id + AND m.source_name_normalized = i.source_name_normalized + ) + GROUP BY i.source_name_raw, i.source_name_normalized + """, + (profile_id, profile_id), + ) + ings = [dict(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()] + out = [] + for row in merge_unmapped_rows(diary + ings): + key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw")) + if not key or 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) + return out + + +def list_unmapped_payload( + cur, + profile_id: str, + since_days: int = 0, + count_only: bool = False, + meta: bool = False, +) -> Any: + days = max(0, min(int(since_days or 0), 3650)) + out = fetch_unmapped_rows(cur, profile_id) + items = sort_unmapped_rows(filter_unmapped_since(out, days) if days else out, days) + if count_only: + return {"count": len(items), "total": len(out), "since_days": days} + if meta: + return {"items": items, "count": len(items), "total": len(out), "since_days": days} + return items + + +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()] diff --git a/backend/data_layer/food_recipes.py b/backend/data_layer/food_recipes.py new file mode 100644 index 0000000..6924ae8 --- /dev/null +++ b/backend/data_layer/food_recipes.py @@ -0,0 +1,431 @@ +"""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, + names_match_list, + 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 = 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"), + ) + 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 id, name_raw, name_normalized FROM food_recipes WHERE profile_id = %s", + (profile_id,), + ) + recipes = [dict(r) for r in cur.fetchall()] + if not recipes: + return 0, [] + cur.execute( + """ + SELECT id, date::text AS date, source_name_raw, source_name_normalized + FROM nutrition_items + WHERE profile_id = %s AND food_id IS NULL AND recipe_id IS NULL + """, + (profile_id,), + ) + by_rid: dict[str, list[str]] = {} + dates: set[str] = set() + for item in cur.fetchall(): + rid = next( + ( + str(rec["id"]) + for rec in recipes + if names_match_list(item.get("source_name_raw"), item.get("source_name_normalized"), rec.get("name_normalized")) + ), + None, + ) + if not rid: + continue + by_rid.setdefault(rid, []).append(str(item["id"])) + if item.get("date"): + dates.add(str(item["date"])) + linked = 0 + for rid, ids in by_rid.items(): + cur.execute( + """ + UPDATE nutrition_items + SET recipe_id = %s, updated_at = NOW() + WHERE id = ANY(%s::uuid[]) + """, + (rid, ids), + ) + linked += cur.rowcount or 0 + return linked, list(dates) + + +def list_known_ingredient_names(cur, profile_id: str, limit: int = 2000) -> list[dict[str, Any]]: + """Distinct list-ingredient names, with mapping if already learned.""" + cur.execute( + """ + SELECT i.source_name_raw, i.source_name_normalized, + MIN(m.food_id::text) AS food_id, + MIN(f.name_de) AS food_name_de, + MIN(f.bls_code) AS bls_code, + BOOL_OR(m.id IS NOT NULL) AS mapped + 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 food_catalog f ON f.id = m.food_id + WHERE r.profile_id = %s + GROUP BY i.source_name_raw, i.source_name_normalized + ORDER BY i.source_name_normalized + LIMIT %s + """, + (profile_id, limit), + ) + out = [] + for row in cur.fetchall(): + rec = dict(row) + rec["mapped"] = bool(rec.get("mapped")) + out.append(rec) + return out + + +def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> 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] + if not with_ingredients: + cur.execute( + """ + SELECT recipe_id, COUNT(*) AS n + FROM food_recipe_ingredients + WHERE recipe_id = ANY(%s::uuid[]) + GROUP BY recipe_id + """, + (ids,), + ) + counts = {str(r["recipe_id"]): int(r["n"]) for r in cur.fetchall()} + for rec in recipes: + rec["id"] = str(rec["id"]) + rec["ingredient_count"] = counts.get(rec["id"], 0) + rec["ingredients"] = [] + return recipes + by_r = _ingredients_by_recipe(cur, ids) + for rec in recipes: + rec["id"] = str(rec["id"]) + rec["ingredients"] = by_r.get(rec["id"], []) + rec["ingredient_count"] = len(rec["ingredients"]) + return recipes + + +def _ingredients_by_recipe(cur, recipe_ids: list[str]) -> dict[str, list]: + if not recipe_ids: + return {} + 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 + """, + (recipe_ids,), + ) + by_r: dict[str, list] = {str(i): [] for i in recipe_ids} + for row in cur.fetchall(): + by_r.setdefault(str(row["recipe_id"]), []).append(dict(row)) + return by_r + + +def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None: + cur.execute( + """ + SELECT id, name_raw, name_normalized, portions, description, source + FROM food_recipes + WHERE profile_id = %s AND id = %s + """, + (profile_id, recipe_id), + ) + rec = cur.fetchone() + if not rec: + return None + rec = dict(rec) + rec["id"] = str(rec["id"]) + rec["ingredients"] = _ingredients_by_recipe(cur, [rec["id"]]).get(rec["id"], []) + rec["ingredient_count"] = len(rec["ingredients"]) + return rec + + +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: + target = normalize_food_name(source_name_normalized) or (source_name_normalized or "") + cur.execute( + """ + SELECT id, source_name_raw, source_name_normalized + FROM nutrition_items + WHERE profile_id = %s AND food_id IS NULL + """, + (profile_id,), + ) + ids = [ + str(row["id"]) + for row in cur.fetchall() + if names_match_list(row.get("source_name_raw"), row.get("source_name_normalized"), target) + ] + if not ids: + return 0 + cur.execute( + """ + UPDATE nutrition_items + SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW() + WHERE id = ANY(%s::uuid[]) + """, + (recipe_id, ids), + ) + 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 diff --git a/backend/data_layer/food_suggest.py b/backend/data_layer/food_suggest.py new file mode 100644 index 0000000..570945a --- /dev/null +++ b/backend/data_layer/food_suggest.py @@ -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 diff --git a/backend/data_layer/nutrition_items.py b/backend/data_layer/nutrition_items.py new file mode 100644 index 0000000..46ec4f3 --- /dev/null +++ b/backend/data_layer/nutrition_items.py @@ -0,0 +1,489 @@ +"""Nutrition diary items, three macro sums, import policy, attribute resolve.""" +from __future__ import annotations + +import logging +import threading +import uuid +from datetime import date, datetime +from typing import Any + +logger = logging.getLogger(__name__) + +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 schedule_daily_nutrient_rebuild(profile_id: str, dates: list[str], context: str) -> None: + days = [str(d) for d in (dates or []) if d] + if not days: + return + + def _run() -> None: + try: + from db import get_cursor, get_db + + with get_db() as conn: + cur = get_cursor(conn) + for day in days: + rebuild_daily_nutrients(cur, profile_id, day) + except Exception: + logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context) + + threading.Thread(target=_run, daemon=True, name="nutrition-rebuild").start() + + +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()] diff --git a/backend/data_layer/tandoor_client.py b/backend/data_layer/tandoor_client.py new file mode 100644 index 0000000..f80118f --- /dev/null +++ b/backend/data_layer/tandoor_client.py @@ -0,0 +1,120 @@ +"""HTTP client for a user's Tandoor instance. Never logs the token.""" +from __future__ import annotations + +import json +import ssl +import urllib.error +import urllib.request +from typing import Any +from urllib.parse import urljoin, urlparse + + +class TandoorError(ValueError): + pass + + +def normalize_base_url(raw: str | None) -> str: + url = (raw or "").strip().rstrip("/") + if not url: + raise TandoorError("Tandoor-URL fehlt") + parsed = urlparse(url) + if parsed.scheme not in ("https", "http") or not parsed.netloc: + raise TandoorError("Tandoor-URL muss mit https:// oder http:// beginnen") + if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"): + raise TandoorError("Tandoor-URL muss https verwenden") + return url + + +def token_hint(token: str | None) -> str | None: + t = (token or "").strip() + if len(t) < 4: + return "gesetzt" if t else None + return f"…{t[-4:]}" + + +def public_settings(row: dict[str, Any] | None) -> dict[str, Any]: + if not row: + return { + "configured": False, + "base_url": "", + "token_set": False, + "token_hint": None, + "last_ok_at": None, + "last_error": None, + } + return { + "configured": True, + "base_url": row.get("base_url") or "", + "token_set": bool((row.get("api_token") or "").strip()), + "token_hint": token_hint(row.get("api_token")), + "last_ok_at": row.get("last_ok_at").isoformat() if row.get("last_ok_at") else None, + "last_error": row.get("last_error"), + } + + +def _request(base_url: str, token: str, path: str, timeout: float = 10.0) -> tuple[int, str, str]: + url = urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Token {token.strip()}", + "Accept": "application/json", + "User-Agent": "Mitai-Jinkendo/tandoor-connector", + }, + method="GET", + ) + ctx = ssl.create_default_context() + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002 + return None + + opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ctx)) + try: + with opener.open(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + ctype = resp.headers.get("Content-Type", "") + return resp.status, body, ctype + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") if e.fp else "" + ctype = e.headers.get("Content-Type", "") if e.headers else "" + return e.code, body, ctype + except TimeoutError as e: + raise TandoorError("Tandoor antwortet nicht (Zeitüberschreitung)") from e + except urllib.error.URLError as e: + raise TandoorError(f"Tandoor nicht erreichbar: {e.reason}") from e + + +def probe_connection(base_url: str, token: str) -> dict[str, Any]: + url = normalize_base_url(base_url) + tok = (token or "").strip() + if not tok: + raise TandoorError("API-Token fehlt") + status, body, ctype = _request(url, tok, "api/user/") + if status in (301, 302, 303, 307, 308) or "text/html" in (ctype or "") and status < 400: + raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen") + if status in (401, 403): + raise TandoorError("Token ungültig oder ohne Rechte") + if status == 404: + status, body, ctype = _request(url, tok, "api/recipe/?page=1&page_size=1") + if status in (401, 403): + raise TandoorError("Token ungültig oder ohne Rechte") + if status >= 400: + raise TandoorError(f"Tandoor antwortete mit HTTP {status}") + if "json" not in (ctype or "") and not (body or "").lstrip().startswith(("{", "[")): + raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen") + try: + data = json.loads(body) if body else {} + except json.JSONDecodeError as e: + raise TandoorError("Tandoor-Antwort war kein JSON") from e + name = None + if isinstance(data, dict): + results = data.get("results") + if isinstance(results, list) and results and isinstance(results[0], dict): + name = results[0].get("display_name") or results[0].get("username") or results[0].get("name") + else: + name = data.get("display_name") or data.get("username") or data.get("name") + count = data.get("count") + else: + count = None + return {"ok": True, "user": name, "recipe_count": count} diff --git a/backend/data_layer/tandoor_settings.py b/backend/data_layer/tandoor_settings.py new file mode 100644 index 0000000..da002fd --- /dev/null +++ b/backend/data_layer/tandoor_settings.py @@ -0,0 +1,67 @@ +"""Persist per-profile Tandoor credentials. Token never returned in full.""" +from __future__ import annotations + +from typing import Any + +from data_layer.tandoor_client import public_settings + + +def get_settings(cur, profile_id: str) -> dict[str, Any] | None: + cur.execute( + """ + SELECT profile_id, base_url, api_token, last_ok_at, last_error, updated_at + FROM profile_tandoor_settings + WHERE profile_id = %s + """, + (profile_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def get_public_settings(cur, profile_id: str) -> dict[str, Any]: + return public_settings(get_settings(cur, profile_id)) + + +def upsert_settings(cur, profile_id: str, base_url: str, token: str | None, *, clear_token: bool = False) -> dict[str, Any]: + existing = get_settings(cur, profile_id) + if clear_token: + cur.execute("DELETE FROM profile_tandoor_settings WHERE profile_id = %s", (profile_id,)) + return public_settings(None) + stored = (token or "").strip() or ((existing or {}).get("api_token") or "") + if not stored: + raise ValueError("API-Token fehlt") + cur.execute( + """ + INSERT INTO profile_tandoor_settings (profile_id, base_url, api_token, last_error, updated_at) + VALUES (%s, %s, %s, NULL, NOW()) + ON CONFLICT (profile_id) DO UPDATE SET + base_url = EXCLUDED.base_url, + api_token = EXCLUDED.api_token, + last_error = NULL, + updated_at = NOW() + """, + (profile_id, base_url, stored), + ) + return get_public_settings(cur, profile_id) + + +def record_probe(cur, profile_id: str, ok: bool, error: str | None = None) -> None: + if ok: + cur.execute( + """ + UPDATE profile_tandoor_settings + SET last_ok_at = NOW(), last_error = NULL, updated_at = NOW() + WHERE profile_id = %s + """, + (profile_id,), + ) + else: + cur.execute( + """ + UPDATE profile_tandoor_settings + SET last_error = %s, updated_at = NOW() + WHERE profile_id = %s + """, + ((error or "")[:300], profile_id), + ) diff --git a/backend/main.py b/backend/main.py index 584ff80..d75d7a8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -38,6 +38,8 @@ from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout 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 admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics +from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping +from routers import tandoor # per-user Tandoor connection # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -133,6 +135,10 @@ 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_training_parameters.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 +app.include_router(tandoor.router) # /api/tandoor/* # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/062_bls_food_catalog.sql b/backend/migrations/062_bls_food_catalog.sql new file mode 100644 index 0000000..0541e0a --- /dev/null +++ b/backend/migrations/062_bls_food_catalog.sql @@ -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 $$; diff --git a/backend/migrations/063_fddb_recipes.sql b/backend/migrations/063_fddb_recipes.sql new file mode 100644 index 0000000..1c9b727 --- /dev/null +++ b/backend/migrations/063_fddb_recipes.sql @@ -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 $$; diff --git a/backend/migrations/064_food_mapping_units.sql b/backend/migrations/064_food_mapping_units.sql new file mode 100644 index 0000000..5386786 --- /dev/null +++ b/backend/migrations/064_food_mapping_units.sql @@ -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 $$; diff --git a/backend/migrations/065_food_attribute_omega_extensions.sql b/backend/migrations/065_food_attribute_omega_extensions.sql new file mode 100644 index 0000000..5abffec --- /dev/null +++ b/backend/migrations/065_food_attribute_omega_extensions.sql @@ -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; diff --git a/backend/migrations/066_recipe_ingredient_units.sql b/backend/migrations/066_recipe_ingredient_units.sql new file mode 100644 index 0000000..29b66cd --- /dev/null +++ b/backend/migrations/066_recipe_ingredient_units.sql @@ -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 $$; diff --git a/backend/migrations/067_profile_tandoor_settings.sql b/backend/migrations/067_profile_tandoor_settings.sql new file mode 100644 index 0000000..90cc00b --- /dev/null +++ b/backend/migrations/067_profile_tandoor_settings.sql @@ -0,0 +1,18 @@ +-- Per-user Tandoor connection (URL + API token). Not a system-wide connector. + +CREATE TABLE IF NOT EXISTS profile_tandoor_settings ( + profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + base_url VARCHAR(300) NOT NULL, + api_token TEXT NOT NULL, + last_ok_at TIMESTAMPTZ, + last_error TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE profile_tandoor_settings IS 'Persönliche Tandoor-API (Familie); Token nie systemweit'; +COMMENT ON COLUMN profile_tandoor_settings.api_token IS 'Tandoor Access Token; nur Backend, nie im JSON-Export'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 067: profile_tandoor_settings'; +END $$; diff --git a/backend/migrations/068_nutrition_unmapped_indexes.sql b/backend/migrations/068_nutrition_unmapped_indexes.sql new file mode 100644 index 0000000..a6fd31c --- /dev/null +++ b/backend/migrations/068_nutrition_unmapped_indexes.sql @@ -0,0 +1,19 @@ +-- Migration 068: schnellere Unmapped-Liste und Listen-Nutzung + +CREATE INDEX IF NOT EXISTS idx_nutrition_items_unmapped_open + ON nutrition_items (profile_id, source_name_normalized) + WHERE food_id IS NULL AND recipe_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_nutrition_items_profile_recipe_date + ON nutrition_items (profile_id, recipe_id, date) + WHERE recipe_id IS NOT NULL; + +COMMENT ON INDEX idx_nutrition_items_unmapped_open IS + 'Tagebuchzeilen ohne Lebensmittel und ohne Liste'; +COMMENT ON INDEX idx_nutrition_items_profile_recipe_date IS + 'Letzte Nutzung einer Liste ohne Join über den Listennamen'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 068: nutrition unmapped/recipe indexes'; +END $$; diff --git a/backend/models.py b/backend/models.py index 8adac6a..7e21eab 100644 --- a/backend/models.py +++ b/backend/models.py @@ -30,6 +30,7 @@ class ProfileUpdate(BaseModel): goal_bf_pct: Optional[float] = None quality_filter_level: Optional[str] = None # Issue #31: Global quality filter email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück + nutrition_import_conflict_policy: Optional[str] = None # ── Tracking Models ─────────────────────────────────────────────────────────── diff --git a/backend/requirements.txt b/backend/requirements.txt index d435df3..3b07881 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,4 @@ python-dateutil==2.9.0 tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows matplotlib==3.8.4 reportlab==4.2.0 +openpyxl==3.1.5 diff --git a/backend/routers/activity.py b/backend/routers/activity.py index da3e3ef..94f40b9 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -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. """ 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() try: text = raw.decode('utf-8') 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: logger.warning(f"Import row failed: {e}") skipped+=1 + + for _ in range(inserted): + increment_feature_usage(pid, "activity_entries") + return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} diff --git a/backend/routers/admin_bls.py b/backend/routers/admin_bls.py new file mode 100644 index 0000000..8546512 --- /dev/null +++ b/backend/routers/admin_bls.py @@ -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)) diff --git a/backend/routers/admin_food_mappings.py b/backend/routers/admin_food_mappings.py new file mode 100644 index 0000000..dbdfc2c --- /dev/null +++ b/backend/routers/admin_food_mappings.py @@ -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} diff --git a/backend/routers/bls.py b/backend/routers/bls.py new file mode 100644 index 0000000..8ea6384 --- /dev/null +++ b/backend/routers/bls.py @@ -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 diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py index c6dc535..5c45625 100644 --- a/backend/routers/csv_import.py +++ b/backend/routers/csv_import.py @@ -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.import_errors import enrich_row_error 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 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) +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: return { "id": m["id"], @@ -184,6 +217,16 @@ def copy_csv_mapping( n += 1 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( """ INSERT INTO csv_field_mappings ( @@ -213,7 +256,21 @@ def copy_csv_mapping( ), ) 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") @@ -538,6 +595,7 @@ async def csv_import_execute( ) _check_module_feature_access(pid, exec_module) + _ensure_mapping_valid(cur, m) cur.execute( """ @@ -654,6 +712,8 @@ async def csv_import_execute( "updated": result["rows_updated"], "skipped": result["rows_skipped"], "errors": result["rows_errors"], + "items_written": result.get("items_written", 0), + "unmapped_names": result.get("unmapped_names", 0), }, "error_details": result["error_details"], } diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 6496935..e8d1ca8 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -11,6 +11,8 @@ from typing import Optional from datetime import datetime 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 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__) +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 ──────────────────────────────────────────────────────────────────── def _pf(s): """Parse float from string (handles comma decimal separator).""" @@ -31,8 +49,13 @@ def _pf(s): # ── Endpoints ───────────────────────────────────────────────────────────────── @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)): - """Import FDDB nutrition CSV.""" +async def import_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) # 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') if text.startswith('\ufeff'): text = text[1:] 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=';') + item_rows = [] days: dict = {} count = 0 for row in reader: rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"') if not rd: continue try: - p = rd.split(' ')[0].split('.') + parts = rd.split(' ') + p = parts[0].split('.') iso = f"{p[2]}-{p[1]}-{p[0]}" - except: continue - days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0}) - days[iso]['kcal'] += _pf(row.get('kj',0))/4.184 - days[iso]['fat_g'] += _pf(row.get('fett_g',0)) - days[iso]['carbs_g'] += _pf(row.get('kh_g',0)) - days[iso]['protein_g'] += _pf(row.get('protein_g',0)) - count+=1 - inserted=0 - new_entries=0 + logged_at = None + if len(parts) > 1: + try: + logged_at = datetime.strptime(rd.strip(), '%d.%m.%Y %H:%M') + except ValueError: + logged_at = None + except Exception: + continue + 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: cur = get_cursor(conn) - for iso,vals in days.items(): - kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1) - carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],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: - # UPDATE existing - cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s", - (kcal,prot,fat,carbs,pid,iso)) - else: - # INSERT new - 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)", - (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs)) - new_entries += 1 - inserted+=1 + policy = get_import_policy(cur, pid) + override = "overwrite_catalog" if overwrite else None + if item_rows: + ingest_result = replace_csv_items_for_dates( + cur, pid, item_rows, policy=policy, policy_override=override, + ) + new_entries = ingest_result.get("new_log_days") or 0 + inserted = ingest_result.get("days_written") or 0 + else: + for iso, vals in days.items(): + kcal = round(vals['kcal'], 1) + fat = round(vals['fat_g'], 1) + carbs = round(vals['carbs_g'], 1) + prot = round(vals['protein_g'], 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): increment_feature_usage(pid, 'nutrition_entries') - return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries, - "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} + return { + "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("") @@ -122,7 +194,7 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car # UPDATE existing entry cur.execute(""" 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 """, (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']} @@ -145,8 +217,8 @@ def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, car # INSERT new entry new_id = str(uuid.uuid4()) 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, 'manual', CURRENT_TIMESTAMP) + 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', 'manual', CURRENT_TIMESTAMP) """, (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1))) # 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: cur = get_cursor(conn) 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}") @@ -228,6 +326,350 @@ def import_history(x_profile_id: Optional[str]=Header(default=None), session: di 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, + meta: bool = False, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_mapping import list_unmapped_payload + + pid = x_profile_id or session["profile_id"] + with get_db() as conn: + return list_unmapped_payload(get_cursor(conn), pid, since_days, count_only, meta) + + +@router.get("/ingredient-names") +def list_food_ingredient_names( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import list_known_ingredient_names + pid = get_pid(x_profile_id) + with get_db() as conn: + return list_known_ingredient_names(get_cursor(conn), pid) + + +@router.get("/recipes") +def list_food_recipes( + brief: bool = False, + 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, with_ingredients=not brief) + + +@router.get("/recipes/{recipe_id}") +def get_food_recipe( + recipe_id: str, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import get_recipe + pid = get_pid(x_profile_id) + with get_db() as conn: + rec = get_recipe(get_cursor(conn), pid, recipe_id) + if not rec: + raise HTTPException(404, "Rezept nicht gefunden") + return rec + + +@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) + dates = stats.pop("dates_linked", []) or [] + from data_layer.nutrition_items import schedule_daily_nutrient_rebuild + schedule_daily_nutrient_rebuild(pid, dates, "FDDB-Listenimport") + return {"ok": True, "recipes": len(recipes), "rebuild_pending": len(dates), **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) + cur.execute( + """ + SELECT DISTINCT date::text AS date + FROM nutrition_items + WHERE profile_id = %s AND recipe_id = %s + """, + (pid, recipe_id), + ) + days = [r["date"] for r in cur.fetchall()] + if not days: + days = dates_for_normalized_name(cur, pid, norm) + for d in days: + 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}") 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)): @@ -242,7 +684,7 @@ def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, cur.execute(""" 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 """, (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid)) diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index b9937ea..73b4490 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -109,9 +109,17 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): data["verification_expires"] = None nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} + allowed_nutrition_policy = { + "prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing", + } for k, v in patch.items(): if k == "email": 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: data[k] = None elif v is not None: diff --git a/backend/routers/tandoor.py b/backend/routers/tandoor.py new file mode 100644 index 0000000..4bc8586 --- /dev/null +++ b/backend/routers/tandoor.py @@ -0,0 +1,76 @@ +"""Per-user Tandoor connection. No recipe import yet.""" +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from starlette.requests import Request +from slowapi import Limiter +from slowapi.util import get_remote_address + +from auth import require_auth +from data_layer.tandoor_client import TandoorError, normalize_base_url, probe_connection +from data_layer.tandoor_settings import ( + get_public_settings, + get_settings, + record_probe, + upsert_settings, +) +from db import get_cursor, get_db + +router = APIRouter(prefix="/api/tandoor", tags=["tandoor"]) +limiter = Limiter(key_func=get_remote_address) + + +class TandoorSettingsIn(BaseModel): + base_url: str = "" + token: Optional[str] = None + clear_token: bool = False + + +class TandoorProbeIn(BaseModel): + base_url: Optional[str] = None + token: Optional[str] = Field(default=None, description="Nur zum Testen; leer = gespeichertes Token") + + +@router.get("/settings") +def read_settings(session: dict = Depends(require_auth)): + pid = session["profile_id"] + with get_db() as conn: + return get_public_settings(get_cursor(conn), pid) + + +@router.put("/settings") +def write_settings(body: TandoorSettingsIn, session: dict = Depends(require_auth)): + pid = session["profile_id"] + try: + if body.clear_token: + url = "" + else: + url = normalize_base_url(body.base_url) + with get_db() as conn: + return upsert_settings( + get_cursor(conn), pid, url, body.token, clear_token=body.clear_token + ) + except (TandoorError, ValueError) as e: + raise HTTPException(400, str(e)) from e + + +@router.post("/test") +@limiter.limit("10/minute") +def test_connection(request: Request, body: Optional[TandoorProbeIn] = None, session: dict = Depends(require_auth)): + pid = session["profile_id"] + body = body or TandoorProbeIn() + with get_db() as conn: + cur = get_cursor(conn) + stored = get_settings(cur, pid) + url = (body.base_url or "").strip() or ((stored or {}).get("base_url") or "") + token = (body.token or "").strip() or ((stored or {}).get("api_token") or "") + try: + result = probe_connection(url, token) + if stored: + record_probe(cur, pid, True) + return result + except TandoorError as e: + if stored: + record_probe(cur, pid, False, str(e)) + raise HTTPException(400, str(e)) from e diff --git a/backend/tests/test_activity_import_feature_enforcement.py b/backend/tests/test_activity_import_feature_enforcement.py new file mode 100644 index 0000000..365331d --- /dev/null +++ b/backend/tests/test_activity_import_feature_enforcement.py @@ -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"] diff --git a/backend/tests/test_bls_parser.py b/backend/tests/test_bls_parser.py new file mode 100644 index 0000000..d399c33 --- /dev/null +++ b/backend/tests/test_bls_parser.py @@ -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 diff --git a/backend/tests/test_csv_mapping_validation.py b/backend/tests/test_csv_mapping_validation.py new file mode 100644 index 0000000..3c90b35 --- /dev/null +++ b/backend/tests/test_csv_mapping_validation.py @@ -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 diff --git a/backend/tests/test_food_attributes.py b/backend/tests/test_food_attributes.py new file mode 100644 index 0000000..7bb2883 --- /dev/null +++ b/backend/tests/test_food_attributes.py @@ -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 diff --git a/backend/tests/test_food_knowledge.py b/backend/tests/test_food_knowledge.py new file mode 100644 index 0000000..9e49e9c --- /dev/null +++ b/backend/tests/test_food_knowledge.py @@ -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] diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py new file mode 100644 index 0000000..ab709ea --- /dev/null +++ b/backend/tests/test_food_mapping.py @@ -0,0 +1,162 @@ +from datetime import date + +from csv_parser.executor import guess_nutrition_item_fields +from data_layer.food_mapping import ( + fetch_unmapped_rows, + filter_unmapped_since, + list_quantity_units, + list_unmapped_payload, + merge_unmapped_rows, + names_match_list, + 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 + + +class _FakeCur: + def __init__(self, batches): + self.batches = list(batches) + self._i = 0 + self._rows = [] + + def execute(self, sql, params=None): + self._rows = self.batches[self._i] + self._i += 1 + + def fetchall(self): + return self._rows + + +def test_fetch_unmapped_keeps_ingredient_dates_and_skips_mapped(): + cur = _FakeCur([ + [{"source_name_normalized": "haferflocken"}], + [], + [], + [ + { + "source_name_raw": "Salz", + "source_name_normalized": "salz", + "count": 2, + "first_date": date(2026, 9, 10), + "last_date": date(2026, 9, 11), + "sample_quantity_raw": "1 g", + }, + { + "source_name_raw": "Haferflocken", + "source_name_normalized": "haferflocken", + "count": 1, + "first_date": date(2026, 9, 1), + "last_date": date(2026, 9, 8), + "sample_quantity_raw": "40 g", + }, + ], + ]) + rows = fetch_unmapped_rows(cur, "p") + assert [r["source_name_normalized"] for r in rows] == ["salz"] + assert rows[0]["last_date"] == "2026-09-11" + assert rows[0]["kind"] == "recipe_ingredient" + + +def test_list_name_matches_despite_quantity_prefix(): + assert names_match_list("50 g Müsli Mix", "50 g müsli mix", "müsli mix") + assert names_match_list("Müsli Mix", "müsli mix", "müsli mix") + assert not names_match_list("Haferflocken", "haferflocken", "müsli mix") + + +def test_unmapped_meta_includes_recent_ingredients(): + batches = [ + [], + [], + [], + [{ + "source_name_raw": "Salz", + "source_name_normalized": "salz", + "count": 1, + "first_date": date(2026, 9, 10), + "last_date": date(2026, 9, 11), + "sample_quantity_raw": "1 g", + }], + ] + payload = list_unmapped_payload(_FakeCur(batches), "p", since_days=0, meta=True) + assert payload["total"] == 1 + assert payload["count"] == 1 + assert payload["items"][0]["source_name_normalized"] == "salz" + + +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}) diff --git a/backend/tests/test_food_recipes.py b/backend/tests/test_food_recipes.py new file mode 100644 index 0000000..a6fc40d --- /dev/null +++ b/backend/tests/test_food_recipes.py @@ -0,0 +1,30 @@ +from data_layer.food_recipes import list_known_ingredient_names, save_recipe + + +class _FakeCur: + def __init__(self, rows): + self.rows = rows + + def execute(self, sql, params=None): + return None + + def fetchall(self): + return self.rows + + +def test_known_ingredient_names_flags_mapping(): + rows = list_known_ingredient_names(_FakeCur([ + {"source_name_raw": "Hafer", "source_name_normalized": "hafer", "food_id": "1", "food_name_de": "Haferflocken", "bls_code": "B123", "mapped": True}, + {"source_name_raw": "Salz", "source_name_normalized": "salz", "food_id": None, "food_name_de": None, "bls_code": None, "mapped": False}, + ]), "p") + assert rows[0]["mapped"] is True + assert rows[1]["mapped"] is False + + +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") diff --git a/backend/tests/test_food_suggest.py b/backend/tests/test_food_suggest.py new file mode 100644 index 0000000..3c4dc03 --- /dev/null +++ b/backend/tests/test_food_suggest.py @@ -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"]) diff --git a/backend/tests/test_recipe_parser.py b/backend/tests/test_recipe_parser.py new file mode 100644 index 0000000..d741705 --- /dev/null +++ b/backend/tests/test_recipe_parser.py @@ -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") diff --git a/backend/tests/test_tandoor_client.py b/backend/tests/test_tandoor_client.py new file mode 100644 index 0000000..5292b4a --- /dev/null +++ b/backend/tests/test_tandoor_client.py @@ -0,0 +1,48 @@ +from data_layer.tandoor_client import TandoorError, normalize_base_url, public_settings, token_hint + + +def test_tandoor_router_imports(): + from routers import tandoor + + paths = {r.path for r in tandoor.router.routes} + assert "/api/tandoor/settings" in paths + assert "/api/tandoor/test" in paths + + +def test_normalize_https_strips_slash(): + assert normalize_base_url("https://tandoor.stommer.de/") == "https://tandoor.stommer.de" + + +def test_normalize_rejects_empty_and_bare_host(): + try: + normalize_base_url(" ") + except TandoorError as e: + assert "fehlt" in str(e) + else: + raise AssertionError("expected TandoorError") + try: + normalize_base_url("tandoor.stommer.de") + except TandoorError: + pass + else: + raise AssertionError("expected TandoorError") + + +def test_normalize_rejects_public_http(): + try: + normalize_base_url("http://tandoor.stommer.de") + except TandoorError as e: + assert "https" in str(e) + else: + raise AssertionError("expected TandoorError") + + +def test_token_hint_never_full(): + assert token_hint("abcdefghijklmnop") == "…mnop" + assert "abcd" not in (token_hint("secret-token-value") or "") + assert public_settings(None)["token_set"] is False + pub = public_settings({"base_url": "https://tandoor.example", "api_token": "super-secret-token"}) + assert pub["token_set"] is True + assert pub["token_hint"] == "…oken" + assert "super" not in (pub["token_hint"] or "") + assert "api_token" not in pub diff --git a/backend/version.py b/backend/version.py index e2fa4c9..84f4f38 100644 --- a/backend/version.py +++ b/backend/version.py @@ -7,9 +7,9 @@ Semantic Versioning: MAJOR.MINOR.PATCH - PATCH: Bugfix, kleine Änderung, Refactor """ -APP_VERSION = "0.9t" -BUILD_DATE = "2026-04-20" -DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import +APP_VERSION = "0.9v" +BUILD_DATE = "2026-09-12" +DB_SCHEMA_VERSION = "20260912e" # 068 nutrition unmapped/recipe indexes MODULE_VERSIONS = { "auth": "1.2.0", @@ -19,8 +19,10 @@ MODULE_VERSIONS = { "weight": "1.0.3", "circumference": "1.0.1", "caliper": "1.0.1", - "activity": "1.2.0", # GET /activity: optional days= window + limit - "nutrition": "1.0.2", + "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement + "nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS + "tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) + "bls": "1.0.5", # GET /bls/units "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", @@ -31,11 +33,44 @@ MODULE_VERSIONS = { "membership": "2.1.0", "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_* - "csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise - "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) + "csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI + "admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity } 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", + "Tandoor: persönlicher Zugang (URL + Token) in Einstellungen, Verbindungstest, kein Import", + ], + }, + { + "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", "date": "2026-04-20", diff --git a/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md b/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md index c96fccf..197e626 100644 --- a/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md +++ b/docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md @@ -26,7 +26,7 @@ |---------|--------| | Hauptnavigation | `frontend/src/config/appNav.js` (`getMainNavItems`) | | 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`, … | | 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` | diff --git a/docs/issues/gitea-bls-issue-body.md b/docs/issues/gitea-bls-issue-body.md new file mode 100644 index 0000000..ffd572c --- /dev/null +++ b/docs/issues/gitea-bls-issue-body.md @@ -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 diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md new file mode 100644 index 0000000..1dead09 --- /dev/null +++ b/docs/issues/issue-bls-food-mapping.md @@ -0,0 +1,27 @@ +# 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) +- Tandoor: persönlicher URL+Token in Einstellungen, Verbindungstest (kein Import) +- Folge: Tandoor-Gerichte live lesen + BLS-Mapping + `cooked_yield_g` — kein Zutaten-Vollsync +- Fix 2026-09-12: Offene Liste war leer, weil der 28-Tage-Filter Zutaten ohne `last_date` verwarf und die Unmapped-Query Listen×Tagebuch kreuzte (Minuten). Import wartete auf synchrone Tages-Rebuilds. +- Fix 2026-09-13: Listen-Tab (CRUD + FDDB-Import); „Liste wählen“ ohne Vorfilter auf den Eintragsnamen; Verknüpfen trotz Mengen-Prefix; Kennzeichnung „Liste: …“. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d3eedef..d8e49da 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -7,10 +7,10 @@ server { proxy_pass http://backend:8000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - client_max_body_size 20M; - proxy_read_timeout 300s; + client_max_body_size 50M; + proxy_read_timeout 600s; proxy_connect_timeout 60s; - proxy_send_timeout 60s; + proxy_send_timeout 600s; } location / { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 55ebe0c..d995905 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -57,6 +57,10 @@ import CustomGoalsPage from './pages/CustomGoalsPage' import UniversalCsvImportPage from './pages/UniversalCsvImportPage' import AdminCsvTemplatesPage from './pages/AdminCsvTemplatesPage' 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 DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' @@ -267,6 +271,10 @@ function AppShell() { }/> } /> } /> + } /> + } /> + } /> + } /> }/> diff --git a/frontend/src/components/CsvImportErrorDetails.jsx b/frontend/src/components/CsvImportErrorDetails.jsx new file mode 100644 index 0000000..26209f4 --- /dev/null +++ b/frontend/src/components/CsvImportErrorDetails.jsx @@ -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 ( +
+ + {title || `Zeilenfehler (${errors.length})`} + +
    + {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 ( +
  • +
    + {row != null && ( + Zeile {row}: + )} + {message} + {err.code && ( + + {err.code} + + )} +
    + {err.hint && ( +
    {err.hint}
    + )} +
  • + ) + })} +
+ {rest > 0 && ( +

+ … und {rest} weitere Fehler (Details in der Import-Historie). +

+ )} +
+ ) +} diff --git a/frontend/src/components/FoodNutrientFields.jsx b/frontend/src/components/FoodNutrientFields.jsx new file mode 100644 index 0000000..e92a670 --- /dev/null +++ b/frontend/src/components/FoodNutrientFields.jsx @@ -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 ( +
+

+ Zahlen in der Einheit des Stoffs. Bei Etikett „pro 8 ml / 8 g“ die Portionsgröße setzen — gespeichert wird immer pro 100 g. +

+ +
+ {[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => ( + + ))} +
+

Weitere Stoffe (EPA, DHA, Ballaststoffe, …)

+ { setQ(e.target.value); search(e.target.value) }} + /> + {loading &&

Suche…

} + {hits.map((h) => ( + + ))} + {extras.map((row) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/FoodRecipeDialog.jsx b/frontend/src/components/FoodRecipeDialog.jsx new file mode 100644 index 0000000..01a8bb5 --- /dev/null +++ b/frontend/src/components/FoodRecipeDialog.jsx @@ -0,0 +1,628 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../utils/api' +import FoodSearchModal from './FoodSearchModal' + +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 + const stripped = n.replace(/^\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim() + return (learned || []).find((m) => { + const raw = (m.source_name_raw || '').toLowerCase() + const norm = (m.source_name_normalized || '').toLowerCase() + return raw === n || norm === n || raw === stripped || norm === stripped + }) || 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) +} + +function filterKnownIngredients(term, names) { + const t = (term || '').trim().toLowerCase() + if (t.length < 2) return [] + return (names || []).filter((n) => { + const hay = `${n.source_name_raw || ''} ${n.source_name_normalized || ''} ${n.food_name_de || ''}`.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 [knownNames, setKnownNames] = useState([]) + const [suggestions, setSuggestions] = useState({}) + const [mapFor, setMapFor] = useState(null) + const nameRef = useRef(null) + const qtyRefs = useRef({}) + const timer = useRef(null) + const abortRef = useRef(null) + const seqRef = useRef(0) + + useEffect(() => { + setLocalLearned(learned || []) + setIngs((list) => list.map((ing) => { + const m = mappingFor(ing.source_name_raw, learned) + if (!m) return ing + return { + ...ing, + foodId: ing.foodId || m.food_id, + mappedLabel: ing.mappedLabel || m.food_name_de, + gramsPerUnit: ing.gramsPerUnit || m.grams_per_unit || '', + } + })) + }, [learned]) + + useEffect(() => { + api.listNutritionIngredientNames().then((rows) => { + setKnownNames(Array.isArray(rows) ? rows : []) + }).catch(() => {}) + }, []) + + const unmappedKey = ings + .filter((i) => i.source_name_raw && !mappingFor(i.source_name_raw, localLearned) && !i.foodId) + .map((i) => i.source_name_raw) + .join('\n') + + useEffect(() => { + const names = unmappedKey ? unmappedKey.split('\n') : [] + if (!names.length) return + api.suggestFoodsBatch(names, 3).then((packed) => { + if (!packed || typeof packed !== 'object') return + setSuggestions((prev) => ({ ...prev, ...packed })) + }).catch(() => {}) + }, [unmappedKey]) + + 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 listHits = filterKnownIngredients(q, knownNames).filter((n) => { + const raw = (n.source_name_raw || '').toLowerCase() + return !mappedHits.some((m) => (m.source_name_raw || '').toLowerCase() === raw) + }) + const mappedFoodNames = new Set(mappedHits.map((m) => (m.food_name_de || '').toLowerCase())) + const userHits = catalogHits.filter((h) => h.catalog_kind && h.catalog_kind !== 'official_bls') + const blsHits = catalogHits.filter((h) => (!h.catalog_kind || h.catalog_kind === 'official_bls') && !mappedFoodNames.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 addKnown = (n) => addIngredient({ + source_name_raw: n.source_name_raw, + foodId: n.food_id || null, + mappedLabel: n.food_name_de || '', + }) + + const rememberMapping = (sourceName, food, extras = {}) => { + setLocalLearned((list) => [{ + id: food.mapping_id, + source_name_raw: sourceName, + source_name_normalized: food.source_name_normalized, + food_id: food.id || food.food_id, + food_name_de: food.name_de || food.food_name_de, + bls_code: food.bls_code, + grams_per_unit: extras.grams_per_unit, + source_unit: extras.source_unit, + }, ...list.filter((m) => (m.source_name_raw || '').toLowerCase() !== sourceName.toLowerCase())]) + setIngs((list) => list.map((x) => ( + (x.source_name_raw || '').toLowerCase() === sourceName.toLowerCase() + ? { ...x, foodId: food.id || food.food_id, mappedLabel: food.name_de || food.food_name_de || '' } + : x + ))) + } + + const assignIngredient = async (sourceName, food, extras = {}) => { + const res = await api.upsertMyFoodMapping({ + source_name: sourceName, + food_id: food.id || food.food_id, + grams_per_unit: extras.grams_per_unit || null, + source_unit: extras.source_unit || null, + }) + rememberMapping(sourceName, { ...food, ...res }, extras) + setMapFor(null) + } + + 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 ( +
+
+
+

{title || 'Liste'}

+ +
+ +
+

+ Roh-Kombination ohne Kochen (z. B. Müsli). Zuerst Namen aus deinen Listen, dann eigene Lebensmittel, dann BLS. Jede Zutat kannst du hier dem Katalog zuordnen — offene Zutaten bleiben rot markiert. +

+ {error &&
{error}
} + + + setName(e.target.value)} + /> + + setPortions(e.target.value)} + /> + +

Zutat suchen

+ onSearch(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addFreeText() } }} + /> + {showResults && ( +
+ {listHits.length > 0 && ( +
Aus deinen Listen
+ )} + {listHits.map((n) => ( + + ))} + {mappedHits.length > 0 && ( +
Bereits zugeordnet
+ )} + {mappedHits.map((m) => ( + + ))} + {searching &&

Katalog…

} + {userHits.length > 0 && ( +
Eigene Lebensmittel
+ )} + {userHits.map((h) => ( + + ))} + {blsHits.length > 0 && ( +
BLS-Katalog
+ )} + {blsHits.map((h) => ( + + ))} + +
+ )} + +

+ Zutaten ({ings.length} + {ings.filter((i) => !mappingFor(i.source_name_raw, localLearned) && !i.foodId).length + ? ` · ${ings.filter((i) => !mappingFor(i.source_name_raw, localLearned) && !i.foodId).length} ohne Mapping` + : ings.length ? ' · alle zugeordnet' : ''}) +

+ {ings.length === 0 && ( +

Noch keine Zutaten — oben suchen und übernehmen.

+ )} + {ings.map((ing, i) => { + const mapped = mappingFor(ing.source_name_raw, localLearned) || (ing.foodId ? { food_name_de: ing.mappedLabel, food_id: ing.foodId } : null) + const packed = suggestions[ing.source_name_raw] || {} + const hints = packed.suggestions || [] + 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 ( +
+
+ setIngs((list) => list.map((x, j) => j === i ? { ...x, source_name_raw: e.target.value, foodId: null, mappedLabel: '' } : x))} + /> + +
+
+ { 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))} + /> + +
+ {!isMass && ( + + )} +
+ {mapped + ? `Zugeordnet: ${mapped.food_name_de || ing.mappedLabel}${mapped.bls_code ? ` · ${mapped.bls_code}` : ''}` + : 'Noch kein Mapping'} + {approx != null ? ` · ≈ ${String(approx).replace('.', ',')} g` : (needsFactor ? ' · Gramm pro Einheit fehlt' : '')} +
+ {!mapped && hints[0] && ( +
+ → {hints[0].name_de}{hints[0].bls_code ? ` · ${hints[0].bls_code}` : ''} + +
+ )} +
+ +
+
+ ) + })} +
+ +
+ + +
+
+ {mapFor && ( + setMapFor(null)} + onSelect={(food, extras) => assignIngredient(mapFor.source_name_raw, food, extras).catch((e) => setError(e.message))} + /> + )} +
+ ) +} diff --git a/frontend/src/components/FoodRecipeEditor.jsx b/frontend/src/components/FoodRecipeEditor.jsx new file mode 100644 index 0000000..26dcce3 --- /dev/null +++ b/frontend/src/components/FoodRecipeEditor.jsx @@ -0,0 +1,192 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../utils/api' +import FoodRecipeDialog from './FoodRecipeDialog' + +export default function FoodRecipeEditor({ learned: learnedProp, highlightId, onChanged }) { + const [recipes, setRecipes] = useState([]) + const [learned, setLearned] = useState(learnedProp || []) + const [q, setQ] = useState('') + const [visible, setVisible] = useState(40) + const [editing, setEditing] = useState(null) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [importing, setImporting] = useState(false) + const [loading, setLoading] = useState(true) + const listRef = useRef(null) + const highlightRef = useRef(null) + const openedHighlight = useRef(null) + + const load = async () => { + try { + const [r, m] = await Promise.all([ + api.listNutritionRecipes(true), + learnedProp ? Promise.resolve(learnedProp) : api.listMyFoodMappings().catch(() => []), + ]) + setRecipes(Array.isArray(r) ? r : []) + setLearned(Array.isArray(m) ? m : (learnedProp || [])) + setError(null) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { load() }, []) + useEffect(() => { if (learnedProp) setLearned(learnedProp) }, [learnedProp]) + + const startNew = () => { + setEditing('new') + setError(null) + } + + const startEdit = async (r) => { + setError(null) + try { + const full = (r.ingredients && r.ingredients.length) + ? r + : await api.getNutritionRecipe(r.id) + setEditing(full) + } catch (e) { + setError(e.message) + } + } + + useEffect(() => { + if (!highlightId || !recipes.length) return + highlightRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + if (openedHighlight.current === highlightId) return + const r = recipes.find((x) => x.id === highlightId) + if (!r) return + openedHighlight.current = highlightId + startEdit(r) + }, [highlightId, recipes]) + + const filtered = recipes.filter((r) => { + const term = q.trim().toLowerCase() + if (!term) return true + return `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase().includes(term) + }) + const shown = filtered.slice(0, visible) + + const save = async (body) => { + if (editing === 'new') await api.createNutritionRecipe(body) + else await api.updateNutritionRecipe(editing.id, body) + setEditing(null) + await load() + onChanged?.() + } + + const remove = async (id) => { + if (!confirm('Liste wirklich löschen? Tagebuchzeilen fallen dann wieder unter Zuordnen.')) return + try { + await api.deleteNutritionRecipe(id) + if (editing && editing !== 'new' && editing.id === id) setEditing(null) + await load() + onChanged?.() + } catch (e) { + setError(e.message) + } + } + + const importLists = async (file) => { + if (!file) return + setImporting(true) + setError(null) + try { + const res = await api.importFddbLists(file) + await load() + onChanged?.() + const pending = res.rebuild_pending ? ' Tageswerte werden im Hintergrund nachgerechnet.' : '' + setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.${pending}`) + } catch (e) { + setError(e.message) + } finally { + setImporting(false) + } + } + + return ( +
+
Zutatenlisten
+

+ Roh-Kombinationen (Müsli, Bowl, Smoothie) — nicht gekochte Gerichte. + Import aus FDDB oder selbst anlegen. Zutaten danach unter Zuordnen mappen. +

+ {error &&
{error}
} + {notice &&
{notice}
} + + { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} /> +
+ + +
+ +

{loading ? 'Lade Listen…' : `${recipes.length} Listen`}

+ {recipes.length > 8 && ( + { setQ(e.target.value); setVisible(40) }} + /> + )} + {!loading && recipes.length === 0 && ( +

Noch keine Listen. FDDB-Export (`lists_*.csv`) importieren oder eine neue anlegen.

+ )} + {shown.map((r) => ( +
+
{r.name_raw}
+
+ {r.ingredient_count ?? (r.ingredients || []).length} Zutaten · {r.portions || 1} Portionen + {r.source === 'fddb_list' ? ' · FDDB' : r.source === 'manual' ? ' · selbst angelegt' : ''} +
+
+ + +
+
+ ))} + {visible < filtered.length && ( + + )} + + {editing === 'new' && ( + setEditing(null)} + /> + )} + {editing && editing !== 'new' && ( + setEditing(null)} + /> + )} +
+ ) +} diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx new file mode 100644 index 0000000..d13c0a8 --- /dev/null +++ b/frontend/src/components/FoodSearchModal.jsx @@ -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, zIndex }) { + 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 ( +
+
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', + }} + > +
+

{title || 'Lebensmittel suchen'}

+ +
+
+ onChange(e.target.value)} + /> +

+ 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. +

+ {suggestedUnit && ( + + )} +
+
+ {loading &&

Suche…

} + {error &&

{error}

} + {!loading && q.trim().length >= 2 && hits.length === 0 && ( +

Kein Treffer im Katalog.

+ )} + {hits.map((h) => ( + + ))} + +
+ {!showCreate ? ( + + ) : !createKind ? ( +
+

Was möchtest du anlegen?

+

Lebensmittel = ein Katalogeintrag. Liste = Roh-Kombination (z. B. Müsli) mit Zutatensuche.

+
+ + + +
+
+ ) : ( +
+

Eigener Eintrag

+ setManual({ ...manual, name_de: e.target.value })} + /> + +
+ {allowRecipe && onWantRecipe && ( + + )} + +
+
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/components/NutritionFoodMap.jsx b/frontend/src/components/NutritionFoodMap.jsx new file mode 100644 index 0000000..c4465d1 --- /dev/null +++ b/frontend/src/components/NutritionFoodMap.jsx @@ -0,0 +1,585 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { api } from '../utils/api' +import FoodSearchModal from './FoodSearchModal' +import FoodNutrientFields, { foodCreatePayload } from './FoodNutrientFields' +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('') + const hint = 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 ( +
+
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', + }} + > +
+

Liste verknüpfen

+ +
+
+

+ Diese Tagebuchzeile{hint ? ` („${hint}“)` : ''} einer bestehenden Zutatenliste zuordnen. + Der Eintrag verschwindet danach hier; die Zutaten der Liste ordnest du einzeln zu. +

+ setQ(e.target.value)} /> +
+
+ {recipes.length === 0 &&

Noch keine Listen. Unter „Listen“ anlegen oder FDDB importieren.

} + {recipes.length > 0 && filtered.length === 0 &&

Keine Liste passt zum Filter.

} + {filtered.map((r) => ( + + ))} +
+
+
+ ) +} + +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 ( +
+

Lebensmittel (ein Eintrag im Katalog) oder Liste (Roh-Kombination, z. B. Müsli)?

+
+ + {allowRecipe && ( + + )} +
+
+ ) + } + return ( +
+

Neuer Katalogeintrag. Salz: Makros 0. Öl/Supplement: Stoffe wie EPA dazusuchen.

+ setName(e.target.value)} placeholder="Name" /> + +
+ + +
+
+ ) +} + +export default function NutritionFoodMap({ onChanged, onMapped, onManageLists, onCreatedList }) { + 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] = await Promise.all([ + api.listUnmappedFoods(sinceDays, true), + api.listMyFoodMappings(), + api.listNutritionRecipes(true).catch(() => []), + ]) + if (gen !== loadGen.current) return + askedSuggest.current = new Set() + const items = Array.isArray(u) ? u : (u?.items || []) + setUnmapped(items) + setTotalOpen(Number(u?.total) || items.length) + 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) + setUnmapped((list) => list.filter((x) => x.source_name_normalized !== row.source_name_normalized)) + setNotice('Liste angelegt und mit der Tagebuchzeile verknüpft. Zutaten erscheinen unter Zuordnen, die Liste unter Listen.') + onCreatedList?.(rec) + if (!onCreatedList) 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) + setUnmapped((list) => list.filter((x) => x.source_name_raw !== sourceName && x.source_name_normalized !== sourceName)) + setNotice('Liste verknüpft. Offene Zutaten der Liste erscheinen hier 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?.() + const pending = res.rebuild_pending ? ' Tageswerte werden im Hintergrund nachgerechnet.' : '' + setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.${pending}`) + } catch (e) { + setError(e.message) + } finally { + setImporting(false) + } + } + + const withSuggest = unmapped.filter((u) => (u.suggestions || []).length).length + + return ( +
+
Lebensmittel zuordnen
+

+ 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. +

+ {error &&
{error}
} + {notice &&
{notice}
} + + { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} /> +
+ + +
+

+ {recipes.length ? `${recipes.length} Listen vorhanden.` : 'Noch keine Listen.'} Anlegen, bearbeiten und FDDB-Import unter Listen. +

+ { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} /> +
+ + +
+ +

+ Offen ({filtered.length}{sinceDays && totalOpen > unmapped.length ? ` von ${totalOpen}` : filtered.length !== unmapped.length ? ` / ${unmapped.length}` : ''}) +

+

{withSuggest} mit Vorschlag

+
+ {PERIODS.map((p) => ( + + ))} +
+ { setQ(e.target.value); setVisible(80) }} /> +
+ {[['all', 'Alle Treffer'], ['suggested', 'Mit Vorschlag'], ['none', 'Ohne Vorschlag']].map(([id, label]) => ( + + ))} +
+ + {shown.length === 0 && ( +

+ {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.'} +

+ )} + {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 ( +
+
+
+
{suggestQuery(u.source_name_raw) || u.source_name_raw}
+
+ {u.kind === 'recipe_ingredient' ? 'Listenzutat' : `${u.count}×`} + {u.matching_recipe_id ? ` · Liste${u.matching_recipe_name ? `: ${u.matching_recipe_name}` : ''}` : ''} + {u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''} + {u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''} +
+ {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( +
+ Entspricht einer Zutatenliste — verknüpfen, dann die Zutaten zuordnen. +
+ )} +
+ {(u.ambiguous || suggestions.length > 1) && ( + + mehrere möglich + + )} +
+ + {best && ( +
+ + → {best.name_de} + {best.bls_code ? ` · ${best.bls_code}` : ' · manuell'} + + +
+ )} + {suggestions.slice(1).map((s) => ( +
+ oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''} + +
+ ))} + {!best &&

Kein Katalogvorschlag — selbst anlegen.

} + +
+ + + {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( + + )} + {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( + + )} + {u.kind !== 'recipe_ingredient' && ( + + )} +
+ {createFor === key && ( + createAndAssign(u, body)} + onWantRecipe={() => { + setCreateFor(null) + setSearchFor(null) + setRecipeDraft({ + row: u, + defaultName: suggestQuery(u.source_name_raw) || u.source_name_raw, + }) + }} + /> + )} +
+ ) + })} + {visible < filtered.length && ( + + )} + +

+ Gelernt ({learned.length}){' '} + +

+ {showLearned && learned.map((m) => ( +
+
+
{m.source_name_raw}
+
+ → {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''} + {m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''} +
+
+ +
+ ))} + + {searchFor && ( + 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 && ( + setRecipeDraft(null)} + onSave={async (body) => { + await createRecipeAndAssign(recipeDraft.row, body) + setRecipeDraft(null) + }} + /> + )} + {recipeFor && ( + setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} /> + )} +
+ ) +} + +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 ( + + + + + ) +} diff --git a/frontend/src/components/TandoorSettings.jsx b/frontend/src/components/TandoorSettings.jsx new file mode 100644 index 0000000..1d79461 --- /dev/null +++ b/frontend/src/components/TandoorSettings.jsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from 'react' +import { api } from '../utils/api' + +export default function TandoorSettings() { + const [baseUrl, setBaseUrl] = useState('') + const [token, setToken] = useState('') + const [tokenSet, setTokenSet] = useState(false) + const [tokenHint, setTokenHint] = useState(null) + const [lastOk, setLastOk] = useState(null) + const [lastError, setLastError] = useState(null) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + + const load = async () => { + const s = await api.getTandoorSettings() + setBaseUrl(s.base_url || '') + setTokenSet(Boolean(s.token_set)) + setTokenHint(s.token_hint || null) + setLastOk(s.last_ok_at || null) + setLastError(s.last_error || null) + setToken('') + } + + useEffect(() => { + load().catch((e) => setMsg({ type: 'err', text: e.message })) + }, []) + + const save = async () => { + setBusy(true) + setMsg(null) + try { + const s = await api.putTandoorSettings({ + base_url: baseUrl, + token: token.trim() || undefined, + }) + setToken('') + setTokenSet(Boolean(s.token_set)) + setTokenHint(s.token_hint || null) + setLastError(s.last_error || null) + setMsg({ type: 'ok', text: 'Gespeichert. Das Token bleibt nur auf dem Server.' }) + } catch (e) { + setMsg({ type: 'err', text: e.message }) + } finally { + setBusy(false) + } + } + + const test = async () => { + setBusy(true) + setMsg(null) + try { + const res = await api.testTandoorConnection({ + base_url: baseUrl || undefined, + token: token.trim() || undefined, + }) + const who = res.user ? ` als ${res.user}` : '' + setMsg({ type: 'ok', text: `Verbindung ok${who}.` }) + setLastError(null) + setLastOk(new Date().toISOString()) + } catch (e) { + setMsg({ type: 'err', text: e.message }) + } finally { + setBusy(false) + } + } + + const clear = async () => { + if (!confirm('Tandoor-Zugang wirklich entfernen?')) return + setBusy(true) + setMsg(null) + try { + await api.putTandoorSettings({ base_url: '', clear_token: true }) + setBaseUrl('') + setToken('') + setTokenSet(false) + setTokenHint(null) + setLastOk(null) + setLastError(null) + setMsg({ type: 'ok', text: 'Zugang entfernt.' }) + } catch (e) { + setMsg({ type: 'err', text: e.message }) + } finally { + setBusy(false) + } + } + + return ( +
+
Tandoor (Familienrezepte)
+

+ Persönlicher Zugang zu eurem Tandoor — nicht systemweit. Gerichte bleiben dort; Mitai rechnet später Nährwerte. + Token unter Tandoor → Einstellungen → API-Tokens anlegen. +

+
+ + setBaseUrl(e.target.value)} + autoComplete="off" + /> +
+
+ + setToken(e.target.value)} + autoComplete="new-password" + /> +
+ {lastOk && ( +

+ Zuletzt erfolgreich: {String(lastOk).slice(0, 16).replace('T', ' ')} +

+ )} + {lastError && !msg && ( +

Letzter Fehler: {lastError}

+ )} + {msg && ( +
+ {msg.text} +
+ )} +
+ + + {tokenSet && ( + + )} +
+
+ ) +} diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index 848306f..3945ea0 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -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', label: 'Basiseinstellungen', diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 45a872c..4d31b7d 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -364,13 +364,15 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) { } // ── Import Panel ────────────────────────────────────────────────────────────── -function ImportPanel({ onImported }) { +function ImportPanel({ onImported, usage = null }) { const fileRef = useRef() const [status, setStatus] = useState(null) const [error, setError] = useState(null) const [dragging, setDragging] = useState(false) + const atLimit = usage && !usage.allowed const runImport = async (file) => { + if (atLimit) return setStatus('loading'); setError(null) try { const result = await api.importActivityCsv(file) @@ -382,24 +384,29 @@ function ImportPanel({ onImported }) { return (
-
📥 Apple Health Import
+
+ 📥 Apple Health Import + {usage && } +

Health Auto Export App → Workouts exportieren → CSV → hier hochladen.
Nur die Workouts-…csv Datei wird benötigt (nicht die Detaildateien).

{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
{e.preventDefault();setDragging(true)}} + onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}} onDragLeave={()=>setDragging(false)} - onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}} - onClick={()=>fileRef.current.click()} + onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; const f=e.dataTransfer.files[0]; if(f) runImport(f) }} + 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, 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'}}>
- {dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'} + {atLimit ? '🔒 Limit erreicht — Import nicht möglich' : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen'}
{status==='loading' && ( @@ -945,7 +952,12 @@ export default function ActivityPage() {
)} - {tab==='import' && } + {tab==='import' && ( + { load(); loadUsage() }} + /> + )} {tab==='categorize' && (
diff --git a/frontend/src/pages/AdminBlsFoodsPage.jsx b/frontend/src/pages/AdminBlsFoodsPage.jsx new file mode 100644 index 0000000..c2937e9 --- /dev/null +++ b/frontend/src/pages/AdminBlsFoodsPage.jsx @@ -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 ( +
+

Lebensmittelkatalog

+ {error &&

{error}

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

{detail.bls_code} {detail.name_de}

+

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

+ + + {(detail.attributes || []).filter((a) => a.value_num != null || a.is_trace).slice(0, 40).map((a) => ( + + + + + + ))} + +
{a.attr_key}{a.name_de}{a.is_trace ? 'Spuren' : a.value_num} {a.unit || ''}
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/AdminBlsImportPage.jsx b/frontend/src/pages/AdminBlsImportPage.jsx new file mode 100644 index 0000000..851174e --- /dev/null +++ b/frontend/src/pages/AdminBlsImportPage.jsx @@ -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 ( +
+ + { + const next = e.target.files?.[0] || null + setFile(next) + checkFile(next) + }} + /> + + {busy === 'check' &&

Prüfe…

} + {busy === 'apply' && ( +

+ Importiere…{progress?.total ? ` ${progress.current}/${progress.total}` : ''} +

+ )} + {error &&

{error}

} + {job?.status === 'checked' && !error && ( +

+ Prüfung: {summarizeCheck(kind, job.check)} +

+ )} + {job?.status === 'done' && result && ( +

+ {(result.inserted ?? result.foods_inserted) ?? 0} neu · {(result.updated ?? result.foods_updated) ?? 0} aktualisiert + {result.values_written != null ? ` · ${result.values_written} Werte` : ''} +

+ )} + +
+ ) +} + +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 ( +
+

BLS 4.0 importieren

+

+ Offizielle Dateien von blsdb.de (frei verfügbar, MRI). Codes bleiben erhalten. + Datei wählen prüft automatisch. Danach Importieren. Zuerst Components, dann Daten. +

+ {status && ( +

+ {status.official_foods} offizielle Lebensmittel · {status.official_attributes} Stoffe · + {status.manual_foods} manuell · {status.source} +

+ )} + {error &&

{error}

} + + +
+ ) +} diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx index 5440e87..0881ab9 100644 --- a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx +++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx @@ -122,6 +122,68 @@ function buildImportRowProcessingSimple(modFields, fm, groupBy, mode, multiRowPo 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. */ function normalizeDecimalInputString(raw) { let s = String(raw).trim().replace(/\s/g, '') @@ -621,11 +683,28 @@ export default function AdminCsvTemplateEditorPage() { } setValidating(true) 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({ module, field_mappings: fieldMappings, type_conversions: tc, - import_row_processing: null, + import_row_processing, column_signature: columnSignature.length ? columnSignature : null, }) setValidationReport(r) @@ -692,44 +771,25 @@ export default function AdminCsvTemplateEditorPage() { } let import_row_processing = null - if (!aggregateSleepImport && rowAggUseCustom) { - if (rowAggIrregular) { - try { - import_row_processing = JSON.parse(rowAggJsonText || '{}') - if (!import_row_processing || typeof import_row_processing !== 'object') throw new Error('bad') - } catch { - setError('Zeilenaggregation: ungültiges JSON.') - return - } - const gb = import_row_processing.group_by - if (!Array.isArray(gb) || !gb.length) { - setError('Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.') - return - } - } else { - if (!rowAggGroupBy.length) { - setError('Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.') - 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, - ) + { + const resolved = resolveEditorImportRowProcessing({ + aggregateSleepImport, + rowAggUseCustom, + rowAggIrregular, + rowAggJsonText, + rowAggGroupBy, + rowAggMode, + rowAggMultiRowPolicy, + rowAggDedupeIdentical, + modFields: modMeta?.fields, + fieldMappings, + assignedTargets, + }) + if (resolved.error) { + setError(resolved.error) + return } + import_row_processing = resolved.import_row_processing } const payload = { @@ -1494,12 +1554,13 @@ export default function AdminCsvTemplateEditorPage() { {validationReport.valid ? — speicherfähig : — Fehler beheben}

- Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht. + Inkl. Zeilenaggregation (wie beim Speichern). Warnungen blockieren nicht.

{validationReport.errors?.length ? (
    {validationReport.errors.map((e, i) => (
  • + {e.code ? {e.code} : null} {e.message} {e.hint ? {e.hint} : null}
  • @@ -1510,6 +1571,7 @@ export default function AdminCsvTemplateEditorPage() {
      {validationReport.warnings.map((w, i) => (
    • + {w.code ? {w.code} : null} {w.message} {w.hint ? {w.hint} : null}
    • diff --git a/frontend/src/pages/AdminFoodAttributesPage.jsx b/frontend/src/pages/AdminFoodAttributesPage.jsx new file mode 100644 index 0000000..e57343e --- /dev/null +++ b/frontend/src/pages/AdminFoodAttributesPage.jsx @@ -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 ( +
      +

      Stoffe & Attribute

      +

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

      + {error &&

      {error}

      } +
      + setForm({ ...form, attr_key: e.target.value })} /> + setForm({ ...form, name_de: e.target.value })} /> + setForm({ ...form, unit: e.target.value })} /> + + +
      + {rows.map((r) => ( +
      + {r.attr_key} · {r.name_de} · {r.data_type} · {r.origin} +
      + ))} +
      + ) +} diff --git a/frontend/src/pages/AdminFoodMappingsPage.jsx b/frontend/src/pages/AdminFoodMappingsPage.jsx new file mode 100644 index 0000000..9b7d45f --- /dev/null +++ b/frontend/src/pages/AdminFoodMappingsPage.jsx @@ -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 ( +
      +

      FDDB→BLS Mappings

      + {error &&

      {error}

      } + {coverage && ( +

      + {coverage.mapped_items}/{coverage.total_items} Zeilen gemappt · + {coverage.unmapped_names} ungemappte Namen +

      + )} +
      + setName(e.target.value)} /> +
      +
      + { + setFoodQ(e.target.value) + clearTimeout(searchTimer.current) + searchTimer.current = setTimeout(() => searchFoods(e.target.value, setHits), 300) + }} /> +
      + {hits.map((h) => ( + + ))} + + + setFilter(e.target.value)} /> + {shown.map((r) => ( +
      +
      +
      + {r.source_name_raw} → {r.bls_code} {r.food_name_de} +
      {r.profile_id ? 'user' : 'global'}
      +
      +
      + + +
      +
      + {editId === r.id && ( +
      + { + setEditQ(e.target.value) + clearTimeout(editTimer.current) + editTimer.current = setTimeout(() => searchFoods(e.target.value, setEditHits), 300) + }} + /> + {editHits.map((h) => ( + + ))} +
      + )} +
      + ))} +
      + ) +} diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 09ed0ba..e26d179 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -1,5 +1,8 @@ import { useState, useEffect, useRef } from 'react' import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react' +import UsageBadge from '../components/UsageBadge' +import NutritionFoodMap, { DayMarkButtons } from '../components/NutritionFoodMap' +import FoodRecipeEditor from '../components/FoodRecipeEditor' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter @@ -193,6 +196,49 @@ function EntryForm({ onSaved }) { } // ── 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 ( +
      + + {error &&

      {error}

      } + {open && Array.isArray(items) && ( +
        + {items.length === 0 &&
      • Keine Zeilen für diesen Tag
      • } + {items.map((it) => ( +
      • + {it.source_name_raw} + {it.quantity_raw ? ` · ${it.quantity_raw}` : ''} + {it.food_name_de ? ` → ${it.food_name_de}` : ' · noch nicht zugeordnet'} +
      • + ))} +
      + )} +
      + ) +} + function DataTab({ entries, onUpdate }) { const [editId, setEditId] = useState(null) const [editValues, setEditValues] = useState({}) @@ -323,9 +369,19 @@ function DataTab({ entries, onUpdate }) { {e.source && (
      - 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` : ''}
      )} + {e.item_count > 0 ? ( + + ) : ( +
      Keine einzelnen Lebensmittelzeilen
      + )} +
      + +
      ) : ( <> @@ -433,20 +489,25 @@ function ImportHistory() { } // ── Import Panel ────────────────────────────────────────────────────────────── -function ImportPanel({ onImported }) { +function ImportPanel({ onImported, usage = null }) { const fileRef = useRef() const [status, setStatus] = useState(null) const [error, setError] = useState(null) const [dragging,setDragging]= useState(false) const [tab, setTab] = useState('file') // 'file' | 'paste' const [pasteText, setPasteText] = useState('') + const [overwrite, setOverwrite] = useState(false) + const [conflicts, setConflicts] = useState([]) + const atLimit = usage && !usage.allowed const runImport = async (file) => { - setStatus('loading'); setError(null) + if (atLimit) return + setStatus('loading'); setError(null); setConflicts([]) 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)) setStatus(result) + setConflicts(result.conflicts || []) onImported() } catch(err) { setError('Import fehlgeschlagen: ' + err.message) @@ -468,7 +529,7 @@ function ImportPanel({ onImported }) { } const handlePasteImport = async () => { - if (!pasteText.trim()) return + if (!pasteText.trim() || atLimit) return const blob = new Blob([pasteText], { type: 'text/csv' }) const file = new File([blob], 'paste.csv', { type: 'text/csv' }) await runImport(file) @@ -476,10 +537,18 @@ function ImportPanel({ onImported }) { return (
      -
      📥 FDDB CSV Import
      +
      + 📥 FDDB CSV Import + {usage && } +

      In FDDB: Mein Tagebuch → Exportieren → CSV — dann hier importieren. + Zeilen (Name, Menge, Uhrzeit) werden gespeichert, wenn die CSV sie enthält.

      + {/* Tab switcher */}
      @@ -498,23 +567,26 @@ function ImportPanel({ onImported }) { <> {/* Drag & Drop Zone */}
      {e.preventDefault();setDragging(true)}} + onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}} onDragLeave={()=>setDragging(false)} - onDrop={handleDrop} - onClick={()=>fileRef.current.click()} + onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; handleDrop(e) }} + 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, padding:'24px 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', }}>
      - {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'}
      .csv Dateien
      - + )} @@ -523,14 +595,20 @@ function ImportPanel({ onImported }) {