Compare commits

...

12 Commits

Author SHA1 Message Date
c1873a47b1 fix: Zuordnungen zuverlässig speichern und Mengen-Varianten zusammenfassen
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Mapping bleibt erhalten, auch wenn der Nährwert-Rebuild länger dauert. Offene Liste führt 1 ml/2 ml Olivenöl als ein Lebensmittel. Dazu JSON-Sicherung, eigener Katalogeintrag und Gramm-pro-Einheit.

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,31 @@
# 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)
Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. Fehlt ein Treffer, kann ein **eigener Katalogeintrag** (Name + Makros/100 g) angelegt und sofort zugeordnet werden. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping.
## FDDB-Listen / eigene Rezepte
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Rezeptname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Rezept verknüpft → **Zutaten** zuordnen, nicht das Rezept 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.
## Später
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).

View File

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

View File

@ -0,0 +1,40 @@
# BLS Food Reference technische Spec
**Stand:** 2026-09-12 · Migration **062** + **063**
## Tabellen
- `food_attributes` — dynamischer Stoff-/Merkmalskatalog (`attr_key`, `data_type`, `origin`)
- `food_catalog` — Lebensmittel (`bls_code` UNIQUE bei official_bls; manuell ohne BLS-Code)
- `food_attribute_values` — typisiertes EAV
- `food_name_mappings` — FDDB-Name → `food_id` (User / global)
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
- `nutrition_day_marks``fasting` | `incomplete`
- `food_recipes` / `food_recipe_ingredients` — FDDB-Listen; `nutrition_items.recipe_id`
## 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, Rezept-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen)
- `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, eigene Foods, eigene Mappings
- `/api/admin/bls/*` — Import, Katalog, Attribute
- `/api/admin/food-mappings` — Admin-CRUD
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{id}/apply`
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
- Frontend: `FoodSearchModal` (Name-Suche, eigener Eintrag, Gramm/Einheit), Listen-Import auf dem Tab Zuordnen
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen (Rebuild darf das Mapping nicht zurückrollen)
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

@ -117,6 +117,14 @@ frontend/src/
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind. - **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert). - **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
### Updates (12.09.2026 - BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch)
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry) ### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75 - **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75

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

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

View File

@ -0,0 +1,140 @@
"""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,
)
return {
"inserted": inserted,
"updated": updated,
"foods_inserted": inserted,
"foods_updated": updated,
"values_written": len(value_rows),
"foods_total": len(foods),
}

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,253 @@
"""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, Decimal):
return float(value)
if isinstance(value, UUID):
return str(value)
if hasattr(value, "isoformat"):
return value.isoformat()
return 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": 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)
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,
})
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")),
"sort_order": ing.get("sort_order") or 0,
}
for ing in rec.get("ingredients") or []
],
})
return {
"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"})
if existing:
from routers.admin_bls import _write_manual_macros
_write_manual_macros(cur, existing, food.get("macros_per_100g"))
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"])
from routers.admin_bls import _write_manual_macros
_write_manual_macros(cur, food_id, food.get("macros_per_100g"))
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)
return {
"ok": True,
"manual_foods": foods_upserted,
"mappings": mappings_ok,
"mappings_skipped": mappings_skipped,
"items_updated": items_updated,
"skipped_foods": skipped[:20],
**recipe_stats,
}

View File

@ -0,0 +1,362 @@
"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply."""
from __future__ import annotations
import re
import unicodedata
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 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())
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"})
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 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"
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 = q.split(",")[0].strip() or q
like_full = f"%{q}%"
like_primary = f"%{primary}%"
prefix = f"{primary}%"
norm = normalize_food_name(primary)
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
)
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
)
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,
norm, prefix, q, limit,
),
)
return [dict(r) for r in cur.fetchall()]

View File

@ -0,0 +1,192 @@
"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
from __future__ import annotations
import uuid
from typing import Any
from data_layer.food_mapping import get_food_mapping_with_cursor, normalize_food_name
from data_layer.nutrition_items import catalog_macros_for_item, _f
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
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, ing["source_name_raw"], inorm,
ing.get("quantity_raw"), ing.get("quantity_g"), ing.get("sort_order") or 0,
),
)
ingredients += 1
linked, dates = link_recipes_to_items(cur, profile_id)
return {
"inserted": inserted,
"updated": updated,
"ingredients": ingredients,
"items_linked": linked,
"dates_linked": dates,
}
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
cur.execute(
"""
SELECT DISTINCT i.date::text AS date
FROM nutrition_items i
JOIN food_recipes r ON r.profile_id = i.profile_id
AND i.source_name_normalized = r.name_normalized
WHERE i.profile_id = %s AND i.food_id IS NULL
""",
(profile_id,),
)
dates = [r["date"] for r in cur.fetchall()]
cur.execute(
"""
UPDATE nutrition_items i
SET recipe_id = r.id, updated_at = NOW()
FROM food_recipes r
WHERE i.profile_id = %s AND r.profile_id = %s
AND i.source_name_normalized = r.name_normalized
AND i.food_id IS NULL
""",
(profile_id, profile_id),
)
return cur.rowcount or 0, dates
def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT id, name_raw, name_normalized, portions, description, source
FROM food_recipes
WHERE profile_id = %s
ORDER BY name_normalized
""",
(profile_id,),
)
recipes = [dict(r) for r in cur.fetchall()]
if not recipes:
return []
ids = [r["id"] for r in recipes]
cur.execute(
"""
SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order
FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s)
ORDER BY sort_order, source_name_raw
""",
(ids,),
)
by_r: dict[str, list] = {str(i): [] for i in ids}
for row in cur.fetchall():
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
for rec in recipes:
rec["ingredients"] = by_r.get(str(rec["id"]), [])
return recipes
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
cur.execute(
"""
UPDATE nutrition_items
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
""",
(recipe_id, profile_id, source_name_normalized),
)
return cur.rowcount or 0
def mapped_ingredient_quantities(
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
) -> list[dict[str, Any]] | None:
cur.execute(
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
rec = cur.fetchone()
if not rec:
return None
cur.execute(
"""
SELECT source_name_raw, quantity_g
FROM food_recipe_ingredients
WHERE recipe_id = %s
ORDER BY sort_order
""",
(recipe_id,),
)
ings = cur.fetchall()
if not ings:
return None
total_g = sum(_f(i.get("quantity_g")) for i in 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
out = []
for ing in ings:
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
if not mapping:
return None
out.append({
"food_id": mapping["food_id"],
"quantity_g": _f(ing.get("quantity_g")) * scale,
})
return out
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
if not parts:
return None
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
for part in parts:
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
if not cat:
return None
for k in acc:
acc[k] += cat[k]
return acc

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,282 @@
"""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)
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:
if not macros:
return
mapping = {"kcal": "ENERCC", "protein_g": "PROT625", "fat_g": "FAT", "carbs_g": "CHO"}
for field, key in mapping.items():
if field not in macros or macros[field] is None:
continue
cur.execute("SELECT id FROM food_attributes WHERE attr_key = %s", (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"], float(macros[field])),
)

View File

@ -0,0 +1,103 @@
"""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 upsert_food_mapping
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"
@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.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}

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

@ -0,0 +1,188 @@
"""Authenticated catalog search and user-owned foods / mappings."""
from __future__ import annotations
import logging
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,
normalize_food_name,
suggest_catalog_foods,
upsert_food_mapping,
)
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
class MappingUpsert(BaseModel):
source_name: str
food_id: str
source_system: str = "fddb"
grams_per_unit: Optional[float] = None
source_unit: Optional[str] = None
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)
@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(cur, q, pid, limit=min(max(limit, 1), 50))
@router.post("/foods/manual")
def create_user_food(
body: UserFoodCreate,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
from routers.admin_bls import _write_manual_macros
import uuid
pid = _pid(session, x_profile_id)
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, 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_manual_macros(cur, food["id"], body.macros_per_100g)
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 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),
)
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=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)
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping %s fehlgeschlagen", norm)
return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
@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))
try:
with get_db() as conn:
_rebuild_days(get_cursor(conn), pid, dates)
except Exception:
logger.exception("Nährwert-Rebuild nach Mapping-Löschen fehlgeschlagen")
return {"ok": True}
# keep get_pid imported for consistency with other routers
_ = get_pid

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,42 @@
from data_layer.food_knowledge import (
BUNDLE_FORMAT,
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"

View File

@ -0,0 +1,47 @@
from csv_parser.executor import guess_nutrition_item_fields
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name, parse_quantity, parse_quantity_g
from data_layer.nutrition_items import macros_differ
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_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_macros_differ_rounds():
assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})

View File

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

View File

@ -7,9 +7,9 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor - PATCH: Bugfix, kleine Änderung, Refactor
""" """
APP_VERSION = "0.9t" APP_VERSION = "0.9v"
BUILD_DATE = "2026-04-20" BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import DB_SCHEMA_VERSION = "20260912" # 064 mapping units
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.0", "auth": "1.2.0",
@ -19,8 +19,9 @@ MODULE_VERSIONS = {
"weight": "1.0.3", "weight": "1.0.3",
"circumference": "1.0.1", "circumference": "1.0.1",
"caliper": "1.0.1", "caliper": "1.0.1",
"activity": "1.2.0", # GET /activity: optional days= window + limit "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.0.2", "nutrition": "1.2.2", # mapping save + manual food + units
"bls": "1.0.1",
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
"prompts": "1.1.0", "prompts": "1.1.0",
@ -31,11 +32,34 @@ MODULE_VERSIONS = {
"membership": "2.1.0", "membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_* "app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise "csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) "admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.9v",
"date": "2026-09-12",
"changes": [
"BLS 4.0 Stammdaten (dynamische Attribute, Upsert über bls_code)",
"Lernendes FDDB-Mapping ohne KI, änder- und löschbar",
"Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken",
"BLS-Import als Hintergrundjob (kein Proxy-504)",
"Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code",
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
"Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)",
"Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten",
],
},
{
"version": "0.9u",
"date": "2026-07-24",
"changes": [
"Gitea #37: Activity legacy CSV import — activity_entries feature enforcement + UsageBadge UI",
"Gitea #38: Nutrition FDDB import — UsageBadge + deaktivierte Import-Zone bei Limit",
"Gitea #71: Universal CSV — Dry-Run/Format-Check Parität import_row_processing; Mapping-Validierung bei Copy/Import; strukturierte error_details in Nutzer-UI",
],
},
{ {
"version": "0.9t", "version": "0.9t",
"date": "2026-04-20", "date": "2026-04-20",

View File

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

View File

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

View File

@ -0,0 +1,20 @@
# 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-Rezeptzeilen in Zutaten auflösen
- Zuordnungen und Listen als JSON zwischen Instanzen übertragen
- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping

View File

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

View File

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

View File

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

View File

@ -0,0 +1,244 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
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, onSelect, onClose }) {
const [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [creating, setCreating] = useState(false)
const [showCreate, setShowCreate] = useState(false)
const [manual, setManual] = useState({
name_de: initialQuery || '',
kcal: '', protein_g: '', fat_g: '', carbs_g: '',
})
const suggestedUnit = detectUnit(quantityHint, initialQuery)
const [gramsPerUnit, setGramsPerUnit] = useState(
suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : ''
)
const inputRef = useRef(null)
const timer = useRef(null)
const extras = () => {
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()
if (query.length < 2) {
setHits([])
return
}
setLoading(true)
setError(null)
try {
setHits(await api.searchBlsFoods(query, 30))
} catch (e) {
setError(e.message)
setHits([])
} finally {
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)
}
}, [])
const onChange = (value) => {
setQ(value)
clearTimeout(timer.current)
timer.current = setTimeout(() => runSearch(value), 250)
}
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({
name_de: name,
macros_per_100g: {
kcal: parseFloat(String(manual.kcal).replace(',', '.')) || 0,
protein_g: parseFloat(String(manual.protein_g).replace(',', '.')) || 0,
fat_g: parseFloat(String(manual.fat_g).replace(',', '.')) || 0,
carbs_g: parseFloat(String(manual.carbs_g).replace(',', '.')) || 0,
},
})
onSelect(food, extras())
} catch (e) {
setError(e.message)
} finally {
setCreating(false)
}
}
const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000,
padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="food-search-title"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 720px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 id="food-search-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Lebensmittel suchen'}</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input
ref={inputRef}
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
placeholder="Name eingeben, z. B. Haferflocken"
value={q}
onChange={(e) => onChange(e.target.value)}
/>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
Suche nach dem Namen. Fehlt der Treffer, legst du unten einen eigenen Eintrag an.
</p>
{suggestedUnit && (
<label style={{ display: 'block', marginTop: 10, fontSize: 13 }}>
1 {unitLabel} ={' '}
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 90, display: 'inline-block', textAlign: 'left' }}
value={gramsPerUnit}
onChange={(e) => setGramsPerUnit(e.target.value)}
placeholder={COUNT_UNITS.has(suggestedUnit) ? 'z. B. 60' : ''}
/>
{' '}g
</label>
)}
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche</p>}
{error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>}
{!loading && q.trim().length >= 2 && hits.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)}
{hits.map((h) => (
<button
key={h.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h, extras())}
>
<span>
<strong style={{ display: 'block' }}>{h.name_de}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</span>
</span>
</button>
))}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{!showCreate ? (
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
setShowCreate(true)
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
}}>
Eigenes Lebensmittel anlegen
</button>
) : (
<div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag (pro 100 g)</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Name"
value={manual.name_de}
onChange={(e) => setManual({ ...manual, name_de: e.target.value })}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
<label key={key} style={{ fontSize: 12, color: 'var(--text2)' }}>
{label}
<input
className="form-input"
type="number"
min="0"
step="0.1"
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
value={manual[key]}
onChange={(e) => setManual({ ...manual, [key]: e.target.value })}
/>
</label>
))}
</div>
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: 10 }}
disabled={creating}
onClick={createManual}
>
{creating ? 'Speichere…' : 'Anlegen und zuordnen'}
</button>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,364 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api'
import FoodSearchModal from './FoodSearchModal'
function suggestQuery(raw) {
let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
s = s.replace(/^!+/, '').trim()
const comma = s.indexOf(',')
if (comma > 2) s = s.slice(0, comma).trim()
return s
}
function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
const [q, setQ] = useState(suggestQuery(sourceName))
const filtered = recipes.filter((r) => {
const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase()
return !q.trim() || hay.includes(q.trim().toLowerCase())
})
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000, padding: 16,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: 480, maxHeight: 'min(88vh, 560px)',
background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Eigenes Rezept wählen</h2>
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
</div>
<div style={{ padding: '12px 16px' }}>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left' }}
autoFocus
placeholder="Rezeptname filtern"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept. Zuerst Listen-CSV importieren.</p>}
{filtered.map((r) => (
<button
key={r.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onPick(r.id)}
>
<span>
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{(r.ingredients || []).length} Zutaten
{r.portions ? ` · ${r.portions} Portionen` : ''}
</span>
</span>
</button>
))}
</div>
</div>
</div>
)
}
export default function NutritionFoodMap({ onChanged }) {
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 [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null)
const bundleRef = useRef(null)
const loadGen = useRef(0)
const load = async () => {
const gen = ++loadGen.current
try {
const [u, m, r] = await Promise.all([
api.listUnmappedFoods(),
api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []),
])
if (gen !== loadGen.current) return
setUnmapped(u)
setLearned(m)
setRecipes(Array.isArray(r) ? r : [])
} catch (e) {
if (gen !== loadGen.current) return
setError(e.message)
}
}
useEffect(() => { load() }, [])
const assign = async (sourceName, foodId, extras = {}) => {
setSaving(sourceName)
setError(null)
try {
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)
setNotice(`Zuordnung gespeichert: ${sourceName}`)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const applyRecipe = async (sourceName, recipeId) => {
setSaving(sourceName)
setError(null)
try {
await api.applyNutritionRecipe(recipeId, sourceName)
setRecipeFor(null)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
} finally {
setSaving(null)
}
}
const remove = async (id) => {
if (!confirm('Zuordnung wirklich löschen?')) return
try {
await api.deleteMyFoodMapping(id)
await load()
onChanged?.()
} catch (e) {
setError(e.message)
}
}
const exportBundle = async () => {
setBusy(true)
setError(null)
setNotice(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)
setNotice(null)
try {
const res = await api.importFoodKnowledge(file)
await load()
onChanged?.()
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
const lists = (res.inserted || 0) + (res.updated || 0)
setNotice(`${res.mappings || 0} Zuordnungen und ${lists} Listen übernommen${skip}.`)
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
const importLists = async (file) => {
if (!file) return
setImporting(true)
setError(null)
setNotice(null)
try {
const res = await api.importFddbLists(file)
await load()
onChanged?.()
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen als Rezept verknüpft. Offene Zeilen sind jetzt die Zutaten.`)
} catch (e) {
setError(e.message)
} finally {
setImporting(false)
}
}
return (
<div className="card section-gap">
<div className="card-title">Lebensmittel zuordnen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
Tippe auf Im Katalog suchen du suchst nach dem Namen, nicht nach einem Code.
Eigene FDDB-Listen zuerst importieren, dann wird die Tagebuchzeile in Zutaten aufgelöst.
Für den Umzug nach Prod: Zuordnungen und Listen als JSON exportieren und dort wieder importieren.
</p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
<input
ref={listRef}
type="file"
accept=".csv,text/csv"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (f) importLists(f)
}}
/>
<button
type="button"
className="btn btn-secondary btn-full"
disabled={importing}
onClick={() => listRef.current?.click()}
>
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
</button>
{recipes.length > 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>
)}
<input
ref={bundleRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (f) importBundle(f)
}}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>
Zuordnungen & Listen exportieren
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => bundleRef.current?.click()}>
Zuordnungen & Listen importieren
</button>
</div>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3>
{unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>}
{unmapped.map((u) => {
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen-Varianten` : ''}
{u.first_date ? ` · ${u.first_date} ${u.last_date}` : ''}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button
type="button"
className="btn btn-primary"
disabled={saving === u.source_name_raw}
onClick={() => setSearchFor(u)}
>
Im Katalog suchen
</button>
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
<button
type="button"
className="btn btn-secondary"
disabled={saving === u.source_name_raw}
onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}
>
Als eigenes Rezept auflösen
</button>
)}
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
<button
type="button"
className="btn btn-secondary"
disabled={saving === u.source_name_raw}
onClick={() => setRecipeFor(u)}
>
Eigenes Rezept wählen
</button>
)}
</div>
</div>
)
})}
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Gelernt ({learned.length})</h3>
{learned.map((m) => (
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
{m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
</div>
</div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
</div>
))}
{searchFor && (
<FoodSearchModal
title={`Suchen: ${searchFor.source_name_raw}`}
initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
onClose={() => setSearchFor(null)}
onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)}
/>
)}
{recipeFor && (
<RecipePickModal
recipes={recipes}
sourceName={recipeFor.source_name_raw}
onClose={() => setRecipeFor(null)}
onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)}
/>
)}
</div>
)
}
export function DayMarkButtons({ date, markType, onChanged }) {
const setMark = async (type) => {
try {
if (markType === type) await api.deleteNutritionDayMark(date)
else await api.putNutritionDayMark(date, { mark_type: type })
onChanged?.()
} catch (e) {
alert(e.message)
}
}
return (
<span style={{ display: 'inline-flex', gap: 8 }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}>
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('incomplete')}>
{markType === 'incomplete' ? '✓ Lücke' : 'Lücke'}
</button>
</span>
)
}

View File

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

View File

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

View File

@ -0,0 +1,57 @@
import { useState } from 'react'
import { api } from '../utils/api'
export default function AdminBlsFoodsPage() {
const [q, setQ] = useState('')
const [rows, setRows] = useState([])
const [detail, setDetail] = useState(null)
const [error, setError] = useState(null)
const search = async () => {
try {
setRows(await api.adminBlsFoods(q))
} catch (e) {
setError(e.message)
}
}
return (
<div className="card">
<h1 className="page-title">Lebensmittelkatalog</h1>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
<div className="form-row">
<input className="form-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name oder BLS-Code" />
<button type="button" className="btn btn-primary" onClick={search}>Suchen</button>
</div>
{rows.map((r) => (
<button
key={r.id}
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: 6, textAlign: 'left' }}
onClick={() => api.adminBlsFoodDetail(r.id).then(setDetail).catch((e) => setError(e.message))}
>
{r.bls_code ? `${r.bls_code} · ` : ''}{r.name_de}
{r.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
</button>
))}
{detail && (
<div style={{ marginTop: 16 }}>
<h3>{detail.bls_code} {detail.name_de}</h3>
<p style={{ fontSize: 12, color: 'var(--text3)' }}>{detail.catalog_kind} · {detail.bls_version || '—'}</p>
<table style={{ width: '100%', fontSize: 12 }}>
<tbody>
{(detail.attributes || []).filter((a) => a.value_num != null || a.is_trace).slice(0, 40).map((a) => (
<tr key={a.attr_key}>
<td>{a.attr_key}</td>
<td>{a.name_de}</td>
<td>{a.is_trace ? 'Spuren' : a.value_num} {a.unit || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,68 @@
import { useEffect, 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 load = () => {
Promise.all([api.adminListFoodMappings(false), api.adminFoodMappingCoverage()])
.then(([m, c]) => { setRows(m); setCoverage(c) })
.catch((e) => setError(e.message))
}
useEffect(() => { load() }, [])
return (
<div className="card">
<h1 className="page-title">FDDBBLS Mappings</h1>
{error && <p style={{ color: 'var(--danger)' }}>{error}</p>}
{coverage && (
<p style={{ fontSize: 13 }}>
{coverage.mapped_items}/{coverage.total_items} Zeilen gemappt ·
{coverage.unmapped_names} ungemappte Namen
</p>
)}
<div className="form-row">
<input className="form-input" placeholder="FDDB-Bezeichner" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="form-row">
<input className="form-input" placeholder="Lebensmittel suchen" value={foodQ} onChange={async (e) => {
setFoodQ(e.target.value)
if (e.target.value.length > 1) setHits(await api.searchBlsFoods(e.target.value))
}} />
</div>
{hits.map((h) => (
<button key={h.id} type="button" className="btn btn-secondary" style={{ margin: 4 }} onClick={() => { setFoodId(h.id); setFoodQ(h.name_de) }}>
{h.bls_code} {h.name_de}
</button>
))}
<button
type="button"
className="btn btn-primary"
disabled={!name || !foodId}
onClick={async () => {
try {
await api.adminCreateFoodMapping({ source_name: name, food_id: foodId })
setName(''); setFoodId(''); setFoodQ(''); load()
} catch (e) { setError(e.message) }
}}
>
Globales Mapping anlegen
</button>
{rows.map((r) => (
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderTop: '1px solid var(--border)' }}>
<div>
{r.source_name_raw} {r.bls_code} {r.food_name_de}
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{r.profile_id ? 'user' : 'global'}</div>
</div>
<button type="button" className="btn btn-secondary" onClick={async () => { await api.adminDeleteFoodMapping(r.id); load() }}>Löschen</button>
</div>
))}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

23
tests/helpers.js Normal file
View File

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

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

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