Compare commits

..

165 Commits

Author SHA1 Message Date
c1f45d3ba1 Merge pull request 'BLS Stoffgruppen, Lebensmittel verwalten, etc' (#108) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m10s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
Reviewed-on: #108
2026-09-14 13:15:10 +02:00
44e3e216ab feat: Stoffe nach BLS-Gruppen sortieren und aufklappbar listen
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
Energie und Makros zuerst, restliche Naehrstoffgruppen zuklappbar, damit die Liste bedienbar bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:09:44 +02:00
94ddf84262 feat: Stoff-Bezeichnungen editieren und BLS-Glossar fuellen
All checks were successful
Deploy Development / deploy (push) Successful in 1m8s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Kryptische Schluessel bleiben Identitaet; Namen/Einheit sind aenderbar. Nur ENERCC/PROT625/FAT/CHO schreiben die Makro-Haupttabelle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:06:53 +02:00
e452d9a8fe fix: zentrale Lebensmittel fuer Admins aenderbar
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 23s
Persoenliche Liste bleibt beim Owner; zentrale Eintraege oeffnen den Admin-Editor. Admin-Katalog trennt beide Listen, persoenliche bleiben sichtbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:01:41 +02:00
9a0767c8ac fix: manuelle Lebensmittel in der Liste wieder bearbeiten
All checks were successful
Deploy Development / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Admin-Katalog und Nutzer-Liste oeffnen den Editor statt nur die Lesansicht; PUT gilt fuer zentrale und persoenliche Manuals.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:55:09 +02:00
8b1e1da5d8 feat: Eigene und zentrale Lebensmittel bearbeiten, mit Sichtbarkeit im Mapping
All checks were successful
Deploy Development / deploy (push) Successful in 1m5s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 24s
Nutzer können persönliche Foods anlegen und ändern; der Admin legt zentrale Einträge für alle an. Suche und Namens-Mapping berücksichtigen persönlich, zentral und BLS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 12:47:12 +02:00
d35087ad84 feat: Eigene Stoffe am Lebensmittel anlegen
All checks were successful
Deploy Development / deploy (push) Successful in 1m8s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
EPA-Chips und mg/µg vom Etikett; fehlende Parameter ohne Admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 15:47:49 +02:00
037e8bab47 Merge pull request 'Food Enhancesment - Rezepte. Listen, BLS Support' (#107) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Reviewed-on: #107
2026-09-13 15:25:21 +02:00
a5adff6196 feat: Zutaten im Listendialog dem BLS zuordnen
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
Mapping-Status pro Zutat, Suche zuerst Listen und eigene Foods.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 14:38:42 +02:00
b37005be01 feat: Listen-Tab und sichtbare Zutatenlisten-Verwaltung
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Listen anlegen, bearbeiten und importieren liegt unter Ernährung.
Tagebuchzeilen werden auch mit Mengen-Prefix verknüpft.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 12:40:53 +02:00
3b46630a37 fix: Backend-Start nach Tandoor-Router-Import
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Future-annotations plus Limiter haben TandoorProbeIn unauflösbar gemacht.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 10:40:13 +02:00
d6838abda7 fix: Offene Lebensmittel nach Listen-Import wieder anzeigen
Some checks failed
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Failing after 2s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 27s
Unmapped ohne Kreuzjoin, Listen-GET kurz, Rebuild im Hintergrund.
Tandoor-Zugang (URL/Token) in den Einstellungen mit ausliefern.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 10:36:08 +02:00
fa542e0fb9 feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

version: 0.9u
module: activity 1.2.1, nutrition 1.0.3, csv_import 0.4.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:59:12 +02:00
67b508f09b Merge pull request 'BugFix manuelle Eingabe TRainings' (#105) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m9s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 21s
Reviewed-on: #105
2026-07-22 11:14:33 +02:00
532e17c4cd feat: add Jinkendo Foundation design principles documentation
All checks were successful
Deploy Development / deploy (push) Successful in 1m5s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 24s
- Introduced a new section for the Jinkendo Foundation, detailing design principles for the product family.
- Updated README files to include references to the new design principles documentation.
- Enhanced the overall documentation structure to improve navigation and accessibility of design resources.
- Ensured consistency across documentation related to the Jinkendo Foundation and its principles.
2026-07-22 11:11:07 +02:00
d232b11411 feat: enhance charting capabilities in report generation
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
- Added support for dual-axis line charts and stacked bar charts in the report visualization.
- Introduced new helper functions for safely converting raw data to float series and for handling color conversions.
- Updated existing chart rendering logic to accommodate new chart types and improve data handling.
- Enhanced payload generation for daily macros and calorie vs. weight visualizations, ensuring accurate representation in reports.
- Improved error handling and data validation for chart datasets, enhancing overall robustness.
2026-04-29 22:22:56 +02:00
fc2905f9da Merge pull request 'feat: add DashboardConfigurePage to the frontend' (#104) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 19s
Reviewed-on: #104
2026-04-29 22:04:34 +02:00
387ee6840f feat: add DashboardConfigurePage to the frontend
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
- Introduced the `DashboardConfigurePage` component to enhance dashboard configuration options.
- Updated the main application file to include the new page, improving navigation and user experience.
- Ensured consistency with recent enhancements in report management and widget configurations.
2026-04-29 22:02:22 +02:00
0638a7d76b Merge pull request 'pdf generator' (#103) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
Reviewed-on: #103
2026-04-29 21:55:58 +02:00
ed2b457da3 feat: enhance report management and PDF generation capabilities
All checks were successful
Deploy Development / deploy (push) Successful in 1m5s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
- Introduced new API endpoints for managing report definitions, including listing, creating, and updating reports.
- Updated the frontend to include a dedicated section for configuring reports, enhancing user navigation and experience.
- Modified existing components to link to the new report settings, ensuring seamless access to report functionalities.
- Improved the report catalog API to support multiple definitions per profile and added validation for report limits.
- Updated documentation and tests to reflect the new features and ensure proper functionality.
2026-04-29 12:11:26 +02:00
3ab5dae130 feat: add viz_bundle support to report generation and enhance schema
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 20s
- Introduced the `viz_bundle` block type to the report profile schema, allowing for the inclusion of bundled visualizations in PDF reports.
- Updated the `build_structured_report_pdf` function to handle `VizBundleBlock` and append its content to the report.
- Enhanced the report catalog API to include details for the new `viz_bundle` block type.
- Added configuration editors for various visualization bundles in the frontend settings page.
- Updated tests to validate the new `viz_bundle` functionality and ensure proper handling of report profiles.
- Bumped application version to reflect these enhancements.
2026-04-29 11:46:34 +02:00
62729d0648 feat: add report_export widget and enhance report generation capabilities
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 19s
- Introduced the `report_export` widget to the dashboard, allowing users to generate structured PDF reports.
- Updated widget configuration to include `report_export` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `report_export` entry.
- Implemented API endpoints for managing report profiles and generating PDFs.
- Added frontend components for configuring and displaying report settings.
- Updated tests to ensure proper validation and functionality of the new report generation features.
- Bumped application version to reflect the addition of the new widget and related functionalities.
2026-04-29 11:28:04 +02:00
141df021c1 refactor: rename Dashboard-Lab-Widgets to Dashboard-Widgets and update related documentation
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Renamed references from "Dashboard-Lab-Widgets" to "Dashboard-Widgets" across documentation and codebase for consistency.
- Removed the deprecated Dashboard-Lab page and integrated its functionality into the new Dashboard-Widgets layout.
- Updated widget registration and configuration handling to reflect the new naming convention.
- Adjusted documentation in `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` and other related files to ensure clarity on the updated structure.
- Bumped application version to reflect these changes.
2026-04-23 16:18:10 +02:00
ddc87ba5ae feat: remove deprecated demo route and enhance dashboard widget registration
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Removed outdated visualization demo route and fixed demo layout in the frontend.
- Updated widget registration logic in `frontend/src/widgetSystem/registerDashboardWidgets.js` to ensure proper integration of core widgets.
- Adjusted documentation in `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` and comments in `backend/widget_catalog.py` to reflect changes.
- Added new dashboard widgets for activity and body overview, enhancing user experience and data visualization capabilities.
- Bumped application version to reflect these changes.
2026-04-23 15:24:13 +02:00
2dbfd95cca Merge pull request 'Widget Update' (#102) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
Reviewed-on: #102
2026-04-23 09:12:58 +02:00
725e7ffe4b feat: update history_overview_viz configuration and validation
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Replaced the `show_area_summaries` option with individual section visibility settings (`show_section_body`, `show_section_nutrition`, `show_section_fitness`, `show_section_recovery`) in the `history_overview_viz` widget configuration.
- Implemented migration logic to handle legacy `show_area_summaries` settings, ensuring backward compatibility.
- Updated validation logic to enforce visibility requirements for the new section keys.
- Enhanced tests to cover new configuration scenarios and validate the migration logic.
- Bumped application version to reflect these changes.
2026-04-22 12:04:37 +02:00
97dbb0f80b feat: add history_overview_viz widget and enhance configuration handling
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced the `history_overview_viz` widget to the dashboard, allowing users to visualize consolidated history data across various metrics.
- Updated widget configuration to include `history_overview_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `history_overview_viz` entry.
- Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs.
- Added tests to ensure proper validation of the `history_overview_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
2026-04-22 11:55:11 +02:00
e20b321b64 feat: add recovery_history_viz widget and enhance configuration handling
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
- Introduced the `recovery_history_viz` widget to the dashboard, enabling users to visualize recovery history data.
- Updated widget configuration to include `recovery_history_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `recovery_history_viz` entry.
- Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs.
- Added tests to ensure proper validation of the `recovery_history_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
2026-04-22 10:18:02 +02:00
d22e0ba0a7 feat: add fitness_history_viz widget and enhance configuration handling
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
- Introduced the `fitness_history_viz` widget to the dashboard, enabling users to visualize fitness history data.
- Updated widget configuration to include `fitness_history_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `fitness_history_viz` entry.
- Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs.
- Added tests to ensure proper validation of the `fitness_history_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
2026-04-22 10:13:21 +02:00
db5557e4aa feat: add nutrition_history_viz widget and enhance configuration handling
Some checks failed
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Failing after 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
- Introduced the `nutrition_history_viz` widget to the dashboard, allowing users to visualize nutrition history data.
- Updated widget configuration to include `nutrition_history_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `nutrition_history_viz` entry.
- Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs.
- Added tests to ensure proper validation of the `nutrition_history_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
2026-04-22 10:03:23 +02:00
20f195aca1 feat: enhance body_history_viz widget configuration and validation
Some checks failed
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Failing after 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Added new configuration options for the `body_history_viz` widget, including visibility settings for various charts and KPIs.
- Implemented default values for the widget's configuration to streamline user experience.
- Enhanced validation logic to ensure proper handling of configuration inputs, including error handling for unknown keys and visibility requirements.
- Updated tests to cover new configuration scenarios and validation rules for the `body_history_viz` widget.
- Bumped application version to reflect these changes.
2026-04-22 08:51:06 +02:00
01c0d1745f feat: implement merge_missing_catalog_widgets function to enhance dashboard layout
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added the `merge_missing_catalog_widgets` function to append missing widget IDs from the catalog to the dashboard layout while preserving the existing order.
- Updated the admin and app dashboard routes to utilize the new function, ensuring that new catalog entries are visible without requiring users to reset their layouts.
- Enhanced tests to validate the functionality of the new merging logic, ensuring proper integration with existing layouts.
- Bumped application version to reflect these changes.
2026-04-22 08:38:38 +02:00
2453da0da1 feat: add body_history_viz widget and enhance configuration handling
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Introduced the `body_history_viz` widget to the dashboard, allowing users to visualize body history data.
- Updated widget configuration to include `body_history_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `body_history_viz` entry.
- Added tests to ensure proper validation of the `body_history_viz` widget configuration.
- Updated application version to reflect the addition of the new widget.
2026-04-22 07:00:24 +02:00
3eb7ef3ae6 feat: enhance navigation state handling for history overview
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
- Updated NavLink components in App and DesktopSidebar to conditionally pass state for the '/history' route, improving user experience by maintaining the selected tab on navigation.
2026-04-21 08:19:34 +02:00
1cf3d5997d Merge pull request 'C1-C4 Diagramme' (#101) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
Reviewed-on: #101
2026-04-21 08:14:07 +02:00
1c512b0d0a refactor: simplify best lag value handling in energy correlation calculations
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Updated the `_correlate_energy_weight` function to streamline the unpacking of the `best` variable, removing unnecessary tuple elements for improved clarity and efficiency in the correlation logic.
2026-04-21 08:12:21 +02:00
0365d9eb52 feat: improve history overview visualization and data handling
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
- Added `safe_float` utility to enhance float handling in correlation calculations, preventing potential errors.
- Refactored lag correlation logic in `get_history_overview_viz_bundle` to utilize absolute values safely, improving accuracy in metric comparisons.
- Enhanced nutrition body merge logic to ensure proper date handling and data integrity, optimizing the retrieval of nutrition and weight logs.
- Introduced new functions in the frontend for processing lag details, improving the visualization of correlation data in the History page.
2026-04-21 08:08:17 +02:00
3106ebedae feat: enhance lag correlation calculations and chart metadata
All checks were successful
Deploy Development / deploy (push) Successful in 1m3s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 19s
- Updated `calculate_lag_correlation` to include detailed interpretations and lag details for energy balance vs. weight change, protein vs. lean mass, and load vs. vital metrics.
- Improved handling of insufficient data scenarios in correlation charts, providing clearer messages and metadata for user insights.
- Refactored chart functions to utilize best lag values and correlation data more effectively, enhancing the visualization of relationships between metrics.
2026-04-21 08:03:43 +02:00
5cac1ededd Merge pull request 'Refaktor Level 2b' (#100) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
Reviewed-on: #100
2026-04-20 17:31:02 +02:00
3f6673b636 feat: update app version to 0.9t and enhance nutrition visualization
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
- Bumped application version to 0.9t and updated changelog with new features.
- Integrated new chart payloads for energy balance, protein adequacy, and nutrition adherence to optimize data retrieval and reduce HTTP requests.
- Updated NutritionCharts component to utilize prefetched chart payloads, improving loading efficiency and user experience.
- Refactored History page to pass chart payloads, enhancing the visualization of nutrition trends without additional requests.
2026-04-20 14:51:27 +02:00
da1e0410cc feat: update app version to 0.9s and enhance body history visualization
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Bumped application version to 0.9s and updated changelog with new features.
- Introduced `_weight_trend_kpi` function to analyze weight trends and provide verdicts based on historical data.
- Updated `get_body_history_viz_bundle` to include the new weight trend KPI, improving insights on weight changes.
- Refactored the History component to utilize the new trend KPI, enhancing user experience with clearer weight trend interpretations.
2026-04-20 14:47:22 +02:00
ba2bd3a4a2 feat: update versioning and add activity last updated endpoint
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Bumped application version to 0.9r and updated build date to 2026-04-20.
- Added a new endpoint `/activity-last-updated` to retrieve the last activity date for a user, optimizing data retrieval for activity history.
- Updated the frontend to utilize the new endpoint, enhancing the ActivitySection with the last activity date display.
- Refactored the History component to streamline data loading and improve user experience with activity insights.
2026-04-20 14:43:31 +02:00
41d809c68c Merge pull request 'Überartbeitung History - neue Gesamtübersicht' (#99) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #99
2026-04-20 14:39:54 +02:00
5d67a77a12 feat: enhance History page with new scatter chart and correlation insights
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Added ScatterChart and related functions to visualize correlation data, improving user understanding of relationships between metrics.
- Introduced new utility functions for processing chart data and determining status tones, enhancing the clarity of visual representations.
- Updated the NutritionSection to include additional insights on calorie balance and protein vs. lean mass, providing a more comprehensive overview of nutrition trends.
2026-04-20 14:31:35 +02:00
7ac9752c3d feat: enhance nutrition data processing and visualization with new correlation insights
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Refactored the `calculate_lag_correlation` function to normalize lag payloads and improve correlation calculations for various nutrition metrics.
- Introduced a new function `build_nutrition_correlation_heuristic_items` to generate heuristic insights based on merged nutrition data, enhancing user understanding of dietary impacts on weight and body composition.
- Updated the `get_nutrition_history_viz_bundle` function to include daily calorie balance and protein vs. lean mass data, providing a comprehensive view of nutrition trends.
- Enhanced the frontend to visualize calorie balance and protein vs. lean mass insights, improving the user experience with clear graphical representations of dietary correlations.
2026-04-20 13:45:28 +02:00
45fb506a5e fix: update sleep debt chart payload to ensure accurate date handling and current debt representation
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Modified the `build_sleep_debt_chart_payload` function to correctly handle date types, ensuring only valid dates are processed.
- Enhanced the logic to append today's date and current debt value to the chart data if the last recorded date is earlier than today, aligning the chart with KPI metrics.
- Updated metadata calculations to reflect the correct number of data points based on labels, improving the accuracy of the recovery chart payload.
2026-04-20 12:57:47 +02:00
d66eadf88f Merge pull request 'feat: enhance recovery metrics and dashboard with sleep debt calculations and improved visualizations' (#98) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
Reviewed-on: #98
2026-04-20 12:52:20 +02:00
6c962bf6e5 feat: enhance recovery metrics and dashboard with sleep debt calculations and improved visualizations
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced new constants for sleep debt calculations, including target hours and rolling window days.
- Added a function to calculate sleep debt over a specified window, aligning with KPI logic.
- Updated SQL queries in recovery chart payloads to ensure accurate data retrieval for sleep metrics.
- Enhanced the RecoveryDashboardOverview component to reflect changes in sleep debt visualization and descriptions, improving user understanding of metrics.
- Refined chart labels and notes for clarity, ensuring users can easily interpret recovery and sleep data.
2026-04-20 12:47:03 +02:00
a1b458d228 Merge pull request 'Neue Aufbereitung Fitness Verlauf' (#97) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #97
2026-04-20 11:47:52 +02:00
61738cecb7 feat: enhance recovery dashboard with optional average sleep KPI and structured insights
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
- Added an `include_avg_sleep_kpi` parameter to the `build_recovery_dashboard_kpi_tiles` function to conditionally include average sleep data in the dashboard.
- Updated the `get_recovery_dashboard_viz_bundle` function to pass the new parameter, ensuring flexibility in data presentation.
- Refactored the insights generation in the `vitals_fitness_insights.py` file to utilize a new structured approach for better organization of heart and VO2 insights.
- Introduced new components in the frontend for displaying insights, improving the user experience and clarity of vital metrics.
2026-04-20 11:43:56 +02:00
857cc1043a refactor: streamline vital signs matrix handling and enhance recovery dashboard layout
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
- Removed unnecessary snapshot key omission in the `build_vital_signs_matrix_chart_payload` function for improved data clarity.
- Introduced new components for better organization and presentation of vital signs insights, including `SectionHeading` and `VitalZoneHint`.
- Enhanced axis tick formatting in the `RecoveryDashboardOverview` component for clearer data representation.
- Updated narrative rendering logic to improve user experience and contextual understanding of vital metrics.
2026-04-20 11:09:16 +02:00
ce84f330f0 feat: add German number formatting functions and enhance narrative context in vital signs insights
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Introduced `_de_num` and `_de_num_signed` functions for formatting decimal numbers with a comma, improving text presentation in German.
- Updated `_build_consolidated_paragraphs` to utilize new formatting functions for HRV and resting heart rate comparisons, enhancing clarity in insights.
- Refined narrative descriptions for better contextual understanding of vital signs trends and their implications.
2026-04-20 10:55:49 +02:00
8cb5ad992f feat: enhance recovery dashboard with vital signs analytics and visualization improvements
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 18s
- Updated the `build_vital_signs_matrix_chart_payload` function to accept optional keys for omitting specific snapshot data, improving flexibility in data presentation.
- Enhanced the `build_recovery_dashboard_kpi_tiles` function to conditionally merge heart and autonomic tiles based on new parameters, refining the dashboard's insights.
- Integrated new analytics features in the `RecoveryDashboardOverview` component, including consolidated paragraphs for better narrative context and visual representation of trends.
- Improved the handling of vital signs data in the frontend, ensuring clearer messaging and enhanced user experience when displaying vital metrics.
2026-04-20 10:29:43 +02:00
e7bcdc3228 feat: add vitals history analytics to recovery dashboard
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Integrated the `build_vitals_history_and_analytics` function into the recovery dashboard to provide historical insights on vital signs.
- Updated the `get_recovery_dashboard_viz_bundle` function to include a new chart for vitals history, enhancing the data visualization capabilities.
- Enhanced the `RecoveryDashboardOverview` component to render vitals history, including improved messaging for insufficient data and visual representation of trends.
2026-04-20 09:36:10 +02:00
6743814904 Merge pull request 'Fitness History + recovery' (#96) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
Reviewed-on: #96
2026-04-20 08:46:25 +02:00
819914b7cc refactor: improve vital signs data handling and frontend display
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Introduced a new function `_merge_vitals_baseline_rows` to streamline the retrieval and merging of vital signs data, ensuring the latest non-empty values are prioritized.
- Updated SQL queries in `build_vital_signs_matrix_chart_payload` to enhance data retrieval efficiency and accuracy.
- Refactored the `renderVitalSigns` function in the `RecoveryDashboardOverview` component to improve handling of vital signs data, including better fallback messaging and chart rendering logic.
- Enhanced user feedback by providing clearer messages when no vital data is available, improving overall user experience.
2026-04-20 08:44:25 +02:00
d4868b3797 feat: enhance vital signs matrix chart payload and visualization
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Introduced new functions to handle vital signs data retrieval and processing, including fallback mechanisms for missing values.
- Updated SQL queries in `build_vital_signs_matrix_chart_payload` to improve date filtering and data accuracy.
- Enhanced the frontend `RecoveryDashboardOverview` component to display vital signs with contextual coloring based on health tones.
- Adjusted the data structure for chart rendering, ensuring a more informative and visually appealing representation of vital metrics.
2026-04-20 08:36:45 +02:00
6ef07a77a5 Merge pull request 'Fitness historie' (#95) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #95
2026-04-20 08:26:46 +02:00
d3cb9d4ad9 fix: update SQL query in recovery chart payloads for accurate date filtering
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Modified the SQL query in `build_vital_signs_matrix_chart_payload` to use `measured_at::date` for date comparisons, ensuring correct data retrieval based on the measurement date.
- Adjusted the order of results to sort by `measured_at` instead of `date`, improving the accuracy of the latest vital signs data fetched.
2026-04-20 08:24:23 +02:00
33b08a8d82 fix: update sleep chart payloads to use duration_minutes and quality_score
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Changed SQL queries in `build_sleep_duration_quality_chart_payload` and `build_sleep_debt_chart_payload` to select `duration_minutes` instead of `total_sleep_min`.
- Updated calculations for sleep duration and quality scores to reflect the new field names, ensuring accurate data representation in the recovery charts.
2026-04-20 08:21:50 +02:00
f42d3a9c92 feat: introduce recovery dashboard visualization and refactor recovery charts
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Added a new endpoint for the recovery dashboard visualization in `charts.py`, integrating multiple recovery metrics and insights.
- Implemented the `get_recovery_dashboard_viz` function to streamline data retrieval for recovery-related charts.
- Refactored the `RecoveryCharts` component to utilize the new `RecoveryDashboardOverview`, simplifying the component structure and enhancing maintainability.
- Updated the `RecoveryChartsPanelWidget` and `History` page to reflect the new recovery dashboard, improving user navigation and experience.
- Deprecated the old recovery charts component, encouraging the use of the new overview for better data presentation.
2026-04-20 08:11:23 +02:00
bf84e3c2a5 feat: enhance fitness dashboard with new metrics and insights
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Refactored the `calculate_proxy_internal_load_7d` function to `calculate_proxy_internal_load_window`, allowing for dynamic day range input.
- Introduced new functions for calculating training volume deltas and building fitness progress insights, enhancing user feedback on training metrics.
- Updated the fitness dashboard to include new charts for quality sessions and load monitoring, improving data visualization.
- Integrated these new metrics into the fitness dashboard overview, providing users with comprehensive insights into their training performance.
- Streamlined the router to utilize the new chart-building functions, ensuring consistency and maintainability across the application.
2026-04-20 08:04:50 +02:00
22c5f695c9 refactor: update fitness dashboard integration and terminology
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Changed the fitness overview path from `/activity` to `/history` and updated related navigation labels to reflect this change.
- Refactored the `FitnessDashboardOverview` component to accept external period control and conditionally display the period selector.
- Integrated the `FitnessDashboardOverview` into the `History` page, enhancing the user experience with consistent terminology and layout.
- Removed the fitness overview from the `ActivityPage` to streamline the interface and focus on activity data collection.
2026-04-19 21:37:12 +02:00
b5c5f2f612 feat: introduce fitness dashboard overview and enhance activity metrics
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added new functions to build fitness dashboard visualizations, including weekly training volume and training type distribution charts.
- Updated the `charts.py` router to include a new endpoint for the fitness dashboard, integrating data from activity metrics.
- Refactored existing activity-related functions to improve modularity and maintainability.
- Updated frontend components to reflect the new fitness terminology and integrate the fitness dashboard overview, enhancing user experience.
2026-04-19 21:27:12 +02:00
aa6644f44b Merge pull request 'Ernährungsasuwertungen' (#94) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 58s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
Reviewed-on: #94
2026-04-19 21:02:28 +02:00
d7304c1a44 feat: implement energy availability warning and enhance nutrition visualization
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added `get_energy_availability_warning_payload` function to assess energy availability and provide contextual warnings based on multiple health indicators.
- Integrated energy availability KPI tile into the nutrition history visualization, enhancing user insights on energy balance.
- Updated frontend components to conditionally display the energy availability warning, improving user experience and data interpretation.
- Refactored existing logic in `charts.py` to utilize the new energy availability functionality, streamlining data handling.
2026-04-19 17:43:29 +02:00
fc816da335 feat: enhance KPI tiles with contextual hints and improve chart legends
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Added contextual hints to KPI tiles in the nutrition interpretation to provide users with actionable insights regarding protein intake and weight assessment.
- Updated the KpiTilesOverview component to display these hints, improving user understanding of nutrition metrics.
- Introduced a new KcalVsWeightLegend component to clarify chart data representation, enhancing the overall user experience in the history visualization.
2026-04-19 17:36:45 +02:00
31fbf33031 refactor: update nutrition chart colors and enhance layout
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Changed color codes for macro nutrients in the nutrition interpretation and metrics files to improve visual consistency.
- Added new CSS styles for uniform chart height and layout adjustments in the frontend components, enhancing the overall user experience.
- Refactored the NutritionCharts component to utilize the new macro chart theme for better maintainability and readability.
2026-04-19 17:28:41 +02:00
b96b1931db feat: implement nutrition history visualization bundle and related API endpoint
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added a new `nutrition_interpretation.py` file to handle KPI tile generation for nutrition history.
- Introduced `nutrition_viz.py` to create a visualization bundle for nutrition data, integrating metrics and historical analysis.
- Implemented `get_nutrition_history_viz` endpoint in `charts.py` to serve the new visualization data.
- Updated frontend components to fetch and display nutrition history data, enhancing user experience with detailed insights.
- Refactored existing logic to streamline data handling and improve overall performance.
2026-04-19 17:20:24 +02:00
a8eafa8ba4 feat: add weekly macro distribution panel and enhance nutrition charts
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Introduced the WeeklyMacroDistributionPanel component to visualize weekly macro distribution alongside existing nutrition charts.
- Updated the NutritionCharts component to conditionally load the weekly macro data based on a new prop.
- Enhanced CSS styles for better layout and responsiveness of the new macro distribution panel.
- Added a new NutritionGoalsStrip component to display active nutrition-related goals with progress indicators in the History page.
- Refactored existing components to improve data handling and user experience.
2026-04-19 17:13:59 +02:00
08b7aa0ca1 refactor: integrate KpiTilesOverview component for enhanced KPI display
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced the KpiTilesOverview component to streamline the presentation of KPI tiles, replacing the previous BodyKpiOverview implementation.
- Updated the PilotKpiBoard and History components to utilize the new KpiTilesOverview for better touch and hover interactions.
- Refactored CSS styles to accommodate the new component structure, ensuring a responsive design across devices.
- Enhanced the logic for generating KPI tiles, improving data handling and user experience.
2026-04-19 17:00:05 +02:00
8c60601ed1 feat: implement touch-friendly KPI details with bottom sheet interaction
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added a new button for displaying KPI details on touch devices, replacing hover functionality.
- Introduced a bottom sheet component to present detailed information when the info button is clicked.
- Enhanced the BodyKpiOverview component to detect touch UI and adjust interactions accordingly.
- Updated CSS styles for new touch elements, ensuring a responsive and user-friendly design.
2026-04-19 16:46:05 +02:00
319a9cfafd Merge pull request 'Verlauf - Körper' (#93) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #93
2026-04-19 16:32:32 +02:00
8fc7d9c1c4 refactor: enhance body history visualization logic and frontend labels
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Updated the `get_body_history_viz_bundle` function to retrieve the two most recent circumference measurements for improved data accuracy.
- Refactored the handling of previous measurement data to ensure comprehensive interpolation for body metrics.
- Modified frontend labels in the `buildBodyKpiTiles` function to provide clearer descriptions in German, enhancing user understanding of body metrics.
2026-04-19 16:27:59 +02:00
b2175b9018 refactor: update KPI overview and evaluation tile components
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Renamed and refactored CSS classes for better clarity and consistency in the KPI overview section.
- Introduced a new `BodyKpiOverview` component to display KPI tiles with detailed hover information.
- Enhanced the `buildBodyKpiTiles` function to generate tiles based on various body metrics, improving data presentation.
- Updated styles for the KPI cards to enhance user interaction and visual appeal.
- Removed the old `EvaluationTileGrid` component in favor of the new structure for better maintainability.
2026-04-19 16:21:37 +02:00
461c358dc2 feat: add body history visualization endpoint and frontend integration
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced a new API endpoint `/body-history-viz` to retrieve body history visualization data.
- Updated the frontend to fetch and display body history data in the `BodySection` component.
- Enhanced the `EvaluationTileGrid` to include related placeholder keys for improved data interpretation.
- Refactored existing logic to streamline data handling and improve user experience.
2026-04-19 16:06:07 +02:00
157afd10b9 feat: add compact evaluation tiles and body goals section
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced a new `EvaluationTileGrid` component for displaying compact evaluation tiles with interactive features.
- Added a `BodyGoalsStrip` component to showcase active body-related goals with progress indicators.
- Enhanced CSS styles for the new components to ensure responsive design and improved user experience.
- Updated the `BodySection` to integrate the new components and manage grouped goals effectively.
2026-04-19 15:55:46 +02:00
0ab91416d0 Merge pull request 'feat: add reference values snapshot endpoints and data layer functions' (#92) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 12s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 15s
Reviewed-on: #92
2026-04-19 15:34:20 +02:00
42ae796448 feat: add reference values snapshot endpoints and data layer functions
All checks were successful
Deploy Development / deploy (push) Successful in 1m3s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Introduced `get_profile_reference_values_current_snapshot` and `get_profile_reference_values_recent_snapshot` functions to retrieve current and recent reference values for profiles.
- Updated the placeholder resolver to include new placeholders for current and recent reference values.
- Added new API endpoints for fetching current and recent reference values snapshots.
- Enhanced the frontend API utility to support the new snapshot endpoints.
- Improved unit tests to validate the new data layer functions and their behavior.
2026-04-19 10:52:31 +02:00
5fbaa7cc8b Merge pull request 'feat: add relaxed arm circumference measurement and update related features' (#91) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
Reviewed-on: #91
2026-04-19 10:44:18 +02:00
df0165bee3 feat: add relaxed arm circumference measurement and update related features
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
- Introduced `c_arm_relaxed` to the CircumferenceEntry model for tracking relaxed arm measurements.
- Updated database schema to include `c_arm_relaxed` in the circumference_log table.
- Implemented calculation for 28-day relaxed arm circumference change with `calculate_arm_relaxed_28d_delta`.
- Enhanced placeholder resolver and registration to support new relaxed arm measurement.
- Updated frontend components to accommodate the new measurement, including forms and CSV exports.
- Improved documentation and guide data to reflect the addition of relaxed arm measurements.
2026-04-19 10:34:51 +02:00
9b53a4ba68 Merge pull request 'feat: enhance photo upload and management features' (#90) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #90
2026-04-19 10:20:04 +02:00
0035d08149 feat: enhance photo upload and management features
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Added `taken_at` timestamp to the photos table for improved photo metadata.
- Updated the photo upload API to support optional EXIF data extraction and file last modified timestamp.
- Enhanced the photo upload process to allow skipping EXIF data, defaulting to today's date if no other date is provided.
- Improved the photo display in various components to utilize a unified caption format.
- Refactored photo sorting and grouping logic for better organization in the UI.
2026-04-19 10:13:22 +02:00
9720cb57ec Merge pull request 'feat: implement photo management features in the application' (#89) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
Reviewed-on: #89
2026-04-19 09:24:08 +02:00
c91317df8e feat: implement photo management features in the application
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 24s
- Added a new `resolve_photo_path` function to handle legacy and new photo paths effectively.
- Updated the photo upload process to store only filenames in the database, improving path resolution.
- Enhanced the photo retrieval and deletion processes to utilize the new path resolution logic.
- Introduced a dedicated PhotosCapturePage for managing photo uploads and viewing.
- Updated the dashboard and navigation to include links to the new photo management features.
- Improved the photo grid display with sorting and deletion capabilities for better user experience.
2026-04-19 09:20:28 +02:00
1e1605f878 Merge pull request 'Optimierung Platzhalter Umfang' (#88) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #88
2026-04-18 10:54:36 +02:00
7676897fda feat: enhance normalization of metric values for improved handling
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Updated `_normalize_metric_value_for_read` to compact numeric strings and ensure consistent formatting for string data types.
- Enhanced `normalize_prompt_number` to handle numeric strings and non-finite float values effectively.
- Improved unit tests to validate the new normalization behavior for session metrics and scalar formatting.
2026-04-18 10:43:21 +02:00
178534e9eb feat: enhance formatting and normalization of activity metrics
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced `format_scalar_for_prompt_text` function to standardize the representation of scalar values in activity summaries and details.
- Updated `get_activity_summary` and `get_activity_detail` functions to utilize the new formatting for improved readability.
- Added normalization for float values in session metrics to prevent excessively long representations.
- Enhanced unit tests to verify the new formatting and normalization behavior.
2026-04-18 10:32:29 +02:00
6756dc60f3 feat: enhance session metrics handling in activity summaries
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
- Integrated compact JSON payload generation for session metrics in `get_training_sessions_recent_weeks_data`.
- Updated the registration of activity session insights to reflect the new compact format for session metrics.
- Improved documentation to clarify the structure and semantics of the session metrics in the JSON output.
- Added normalization for prompt numbers to ensure consistent formatting in the metrics.
2026-04-18 10:24:44 +02:00
7226e04e9c feat: implement effective CSV delimiter resolution for imports
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Added `resolve_effective_csv_delimiter` function to determine the correct delimiter based on the uploaded file and template.
- Updated CSV import logic to utilize the new delimiter resolution method, ensuring accurate parsing of CSV files with varying delimiters.
- Enhanced documentation to reflect changes in delimiter handling.
- Added unit tests for the new delimiter resolution functionality.
2026-04-18 10:12:33 +02:00
4575bb23ee Merge pull request 'Bug-Fixing Analyse Fehler' (#87) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #87
2026-04-18 09:54:12 +02:00
0ad3ddd627 fix: update progress callback and event types for workflow execution
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Changed progress callback from "execution_complete" to "workflow_graph_finished" to provide intermediate updates.
- Updated documentation to clarify the distinction between "workflow_graph_finished" and "execution_complete".
- Adjusted frontend API handling to accommodate new event structure and ensure proper result serialization.
2026-04-18 09:11:07 +02:00
a002781ef9 chore: remove debug logging from require_auth_flexible
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 17s
Cleanup after successful route ordering fix. SSE authentication is
now working correctly via ssetoken query parameter.
2026-04-18 08:58:36 +02:00
879a3a58d7 fix: move /execute-stream route BEFORE /{prompt_id} catch-all (FastAPI route ordering)
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
Root cause: FastAPI matches routes in definition order. The /{prompt_id}
catch-all at line 257 was intercepting /execute-stream requests before
the specific route handler could match.

Fix: Moved execute-stream definition (with section header + imports)
to line 257, before the catch-all route (now at line 414).

This resolves the 'Connection to server lost' error in SSE streaming.
2026-04-18 08:55:43 +02:00
09d1b6f967 fix: move /execute-stream route BEFORE /{prompt_id} catch-all
Some checks failed
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Failing after 0s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 19s
- /execute-stream now at line 260 (was 1448)
- /{prompt_id} now at line 410 (was 257)
- FastAPI will now match /execute-stream correctly
- Fixes 'Connection to server lost' error in analysis page
2026-04-18 08:45:04 +02:00
35ba2d7fdb fix: identify route ordering issue - execute-stream must come before /{prompt_id}
ROOT CAUSE FOUND:
FastAPI matches routes in ORDER. The catch-all route /{prompt_id} at line 257
matches /execute-stream BEFORE the specific route at line 1448 can match.

Result: /api/prompts/execute-stream gets routed to get_prompt() which tries
to parse 'execute-stream' as a UUID, causing the error we've been seeing.

SOLUTION: Move /execute-stream route definition to BEFORE line 257 (before /{prompt_id})

This explains why require_auth_flexible was never called - the wrong endpoint
was being invoked entirely.
2026-04-18 08:42:30 +02:00
a5aad0da7e debug: add logging to execute_unified_prompt_stream function
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
2026-04-18 08:34:35 +02:00
ce5b96f373 debug: add module load and function entry logging
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
2026-04-18 08:32:31 +02:00
11fac3d123 debug: use print and logger.warning for auth debug
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
2026-04-18 08:28:31 +02:00
f0ad900565 debug: add logging to require_auth_flexible
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 14s
2026-04-18 08:24:32 +02:00
36478863a2 fix: restore prompts.py with correct ASCII quotes (Edit tool introduced smart quotes)
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
2026-04-18 08:22:37 +02:00
ec667a75b6 fix: remove test endpoint with syntax error
Some checks failed
Deploy Development / deploy (push) Successful in 52s
Build Test / pytest-backend (push) Failing after 0s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
2026-04-18 08:20:56 +02:00
f864f9894d debug: add POST test endpoint
Some checks failed
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Failing after 1s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 21s
2026-04-18 08:16:51 +02:00
73104a1a4c cleanup: Remove debug logging and test endpoint
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
2026-04-18 08:04:00 +02:00
d66e68a5df fix: SSE auth with ssetoken query parameter - WORKING
Some checks failed
Build Test / pytest-backend (push) Waiting to run
Build Test / lint-backend (push) Waiting to run
Build Test / build-frontend (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled
Root Cause:
- FastAPI cannot use same parameter name in endpoint and dependency
- Query param 'token' conflicted between endpoint and require_auth_flexible
- FastAPI cached dependency signatures at startup

Solution:
- Renamed to 'ssetoken' in require_auth_flexible (backend/auth.py)
- Updated frontend to use ssetoken (frontend/src/utils/api.js)
- Removed debug logging
- Added test endpoint /test-ssetoken

Testing:
✅ Header auth: X-Auth-Token works
✅ Query auth: ?ssetoken=XXX works
✅ SSE streaming: Ready for testing

Note: Required full rebuild, not just restart

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-18 08:03:36 +02:00
d2b4f74cd2 fix: Query parameter conflict in require_auth_flexible
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
Root Cause Analysis:
- FastAPI cannot distinguish between endpoint Query params and Dependency Query params
- When endpoint has Query(...), dependency Query(default=None, name='token') is ignored
- Token went to endpoint, not to require_auth_flexible

Solution:
- Renamed internal parameter to auth_token with alias='token'
- Now FastAPI correctly routes ?token=XXX to the dependency
- Uses Query(default=None, alias='token') to maintain API compatibility

Testing:
- Header auth: Works (X-Auth-Token)
- Query auth: Now works (?token=XXX)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-18 07:53:18 +02:00
1a826973a9 debug: Add logging to require_auth_flexible
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
2026-04-18 07:38:15 +02:00
d13e7cda26 fix: execute-stream nutzt require_auth_flexible
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Backend:
- Ersetzt manuelle Token-Validierung durch Depends(require_auth_flexible)
- Nutzt get_session() mit expires_at Check + profiles JOIN
- Token-Parameter nicht mehr nötig (require_auth_flexible holt ihn)

Root Cause (Live-Logs):
- Request kam an mit Token: 401 Unauthorized
- Manuelle Auth: SELECT profile_id FROM sessions WHERE token = %s
- Fehlte: expires_at Check + profiles JOIN
- require_auth_flexible nutzt vollständige get_session() Logik

Fixes:
- "Connection to server lost" - Token-Validierung funktioniert jetzt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-18 07:24:49 +02:00
ec85d5f5f6 fix: Token-Abfrage in executeUnifiedPromptStream
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 18s
Frontend:
- api.js Zeile 487: localStorage.getItem('token') → getToken()
- Token heißt 'bodytrack_token', nicht 'token'
- SSE-Requests bekamen undefined token → 401 Unauthorized

Root Cause:
- Admin verwendet executeUnifiedPrompt (normaler Request mit Header-Auth)
- Analyse verwendet executeUnifiedPromptStream (SSE mit Token im URL)
- SSE bekam keinen Token wegen falschem localStorage key

Fixes:
- "Connection to server lost" in Analyse-Seite

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-18 07:13:47 +02:00
1139b00743 fix: execute-stream POST → GET für EventSource
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Backend:
- prompts.py: @router.post → @router.get für /execute-stream
- EventSource unterstützt nur GET-Requests
- modules/timeframes nutzen Defaults (SSE kann keine komplexen Params)

Fixes:
- "Connection to server lost" bei Analyse-Ausführung

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-18 07:07:16 +02:00
e9712cef23 fix: BASE_URL typo + nginx timeout für Workflows
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
Frontend:
- api.js Zeile 499: BASE_URL → BASE (executePromptStreaming)
- nginx.conf: proxy_read_timeout 300s für lange Workflow-Ausführungen

Fixes:
- "BASE_URL is not defined" Fehler in Analyse-Seite
- 504 Gateway Timeout bei Workflow-Ausführung (>60s)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-18 07:03:48 +02:00
a62c952097 Merge pull request 'Erste Version Platzhalter EAV' (#86) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 52s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reviewed-on: #86
2026-04-17 21:52:13 +02:00
1220ee54fb feat: Enhance activity session handling and schema retrieval
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 15s
- Updated `get_activity_session_logical_unit` to support optional parameters for form training context, allowing for more flexible schema resolution.
- Introduced a new endpoint `/attribute-schema` to fetch activity attribute schemas without an existing session, improving manual data entry capabilities.
- Enhanced the `getActivitySession` API method to accept query parameters for training category and type, facilitating dynamic schema retrieval.
- Updated the frontend `ActivityPage` to utilize the new schema fetching logic, ensuring a smoother user experience when managing activity sessions and metrics.
2026-04-17 21:48:37 +02:00
92e334dcd2 feat: Enhance Admin Activity Attribute Profiles UI and styling
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
- Introduced a new layout for the Admin Activity Attribute Profiles page, improving the user interface with a dedicated class for styling.
- Added new CSS styles for input fields, labels, and layout structures to enhance the visual presentation and usability of the attribute profiles.
- Updated the form structure to include clearer labels and organization for input fields, ensuring better accessibility and user experience.
- Improved responsiveness of the layout for mobile devices, ensuring a consistent experience across different screen sizes.
2026-04-17 20:57:31 +02:00
bc8e9fb7fa feat: Enhance training parameters handling and documentation
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced new fields for descriptions in training parameters, improving clarity for AI context in `training_sessions_recent_json`.
- Added a glossary placeholder `{{training_parameters_glossary_md}}` to provide a Markdown table of active training parameters, including names and descriptions.
- Updated the `placeholder_resolver.py` and `activity_metrics.py` to support the new glossary functionality.
- Enhanced the `AdminActivityAttributeProfilesPage` to allow input for descriptions in both German and English, ensuring better context for metrics.
- Revised tests to validate the inclusion of description fields in parameter schema merges and metrics handling.
2026-04-17 20:42:11 +02:00
c3be745efa feat: Enhance activity metrics documentation and registry updates
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added details for Issue #53 regarding the audit of activity placeholders between Layer 1 and Layer 2a in `CLAUDE.md` and `README.md`.
- Updated the `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` to reflect the new registry checks and dynamic session metrics handling.
- Revised the `placeholder_resolver.py` and `activity_metrics.py` to clarify the registration of activity metrics and session insights, ensuring consistency in the handling of dynamic keys and metrics.
- Improved descriptions and semantic contracts in `activity_session_insights.py` to better outline the structure and limitations of session data.
2026-04-17 20:28:58 +02:00
680ecd1c06 feat: Update activity metrics handling to prioritize legacy columns over EAV
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Enhanced the `merge_column_backed_and_eav_metrics` function to ensure that when both legacy columns and EAV values are present, the legacy column takes precedence.
- Revised documentation in `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` and `ACTIVITY_SCALAR_KANON_TABLE.md` to reflect the new reading logic and clarify the handling of metrics.
- Updated the `activity_data_canon.py` to specify the new merge behavior, ensuring consistency in data retrieval.
- Added unit tests to validate the new logic, confirming that legacy columns are preferred when available.
2026-04-17 15:49:38 +02:00
38797d687d feat: Enhance export functionality and documentation for activity metrics
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Implemented JSON and CSV export features in `routers/exportdata.py`, including enriched session metrics for activity data.
- Updated `CLAUDE.md` to reflect new export capabilities and added details about the inclusion of `session_metrics_json` in the `activity.csv` file.
- Revised `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` to document the integration of export features and their relationship with session metrics.
- Improved the overall structure and clarity of the documentation to support the new export functionalities.
2026-04-17 13:09:49 +02:00
fa3e66fb31 feat: Update activity documentation and enhance API responses with session metrics
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
- Added new updates for Phase A and Phase B in `CLAUDE.md`, detailing the completion of Phase A and the introduction of enriched session metrics in the API response for `GET /api/activity`.
- Enhanced the README to include references to new documentation files for scalar canon and composite metrics implementation.
- Updated `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` to reflect the current status of phases and added navigation rules for data access.
- Improved `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` with links to new implementation concepts for composite metrics.
- Refactored the activity router to integrate enriched session metrics into the activity list responses, ensuring a more comprehensive data presentation.
2026-04-17 12:55:12 +02:00
cc0f57758a feat: Implement sorting and categorization for activity profile schema rows
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Introduced a new sorting mechanism for activity profile schema rows based on defined categories and UI groups, enhancing the organization of displayed metrics.
- Added constants for training parameter categories and their German labels to improve clarity in the UI.
- Refactored the `SessionMetricsFields` component to utilize the new sorting logic, replacing the previous mapping approach for better maintainability and user experience.
- Ensured that orphan metrics are sorted correctly for consistent display alongside the main metrics.
2026-04-16 13:46:29 +02:00
2a6c437a08 feat: Introduce activity schema headline binding for improved metrics handling
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 17s
- Added a new function `activitySchemaHeadlineBinding` to streamline the binding of profile parameters to their corresponding headline columns, enhancing clarity in the metrics display logic.
- Refactored the `SessionMetricsFields` component to utilize the new binding function, simplifying the filtering of schema entries and improving maintainability.
- Updated the logic in `ActivityPage` to leverage the binding function for determining the appropriate column for metrics, ensuring consistent data handling across the application.
2026-04-16 13:15:41 +02:00
c9d71c0179 fix: Refine EAV writing logic in session metrics upsert function
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Updated the `upsert_session_metrics_from_csv_mapped` function to ensure EAV writes are only skipped when the `source_field` is populated, preventing duplicate storage.
- Enhanced unit tests to cover scenarios where the `source_field` is set but the corresponding column is empty, ensuring correct EAV behavior.
- Adjusted test cases to reflect changes in the handling of `hr_avg` and other metrics, improving overall test coverage and accuracy.
2026-04-16 13:01:32 +02:00
9d5e16455c refactor: Update session metrics handling to improve EAV logic and filtering
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Revised the `upsert_session_metrics_from_csv_mapped` function to clarify EAV writing conditions, ensuring only relevant parameters are processed.
- Enhanced the `merge_column_backed_and_eav_metrics` function to exclude EAV rows for parameters not present in the schema, improving data integrity.
- Updated unit tests to reflect changes in EAV handling and ensure correct functionality when parameters are mapped or not mapped in the profile schema.
- Improved frontend logic to prevent duplicate display of metrics already handled in the entry form, enhancing user experience.
2026-04-16 12:53:14 +02:00
2a26e4fecf refactor: Remove source field handling from activity session metrics logic
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Eliminated checks for `source_field` in the `replace_activity_session_metrics` function to streamline EAV row replacement, ensuring consistency with existing logic.
- Updated frontend logic to simplify the filtering of metrics during payload construction, enhancing maintainability.
- Removed outdated unit tests related to `source_field` handling, reflecting the updated logic in the codebase.
2026-04-16 12:35:55 +02:00
94bb4a8199 feat: Update activity session metrics handling to skip source field inserts
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Modified the `replace_activity_session_metrics` function to skip EAV inserts for parameters with a defined `source_field`, aligning with the existing logic in `upsert_session_metrics_from_csv_mapped`.
- Enhanced the frontend logic to filter out metrics associated with `source_field` during payload construction, improving data integrity and user experience.
- Added unit tests to validate the new behavior, ensuring that metrics with `source_field` are correctly excluded from inserts.
2026-04-16 12:29:01 +02:00
fd7a2dac6d feat: Improve session metrics upsert logic and add unit tests for source field handling
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Enhanced the `upsert_session_metrics_from_csv_mapped` function to skip EAV writes when a `source_field` is provided, ensuring canonical handling of activity logs.
- Updated the docstring to clarify the behavior regarding `source_field` and EAV logic.
- Added unit tests to validate the new behavior, ensuring correct functionality when `source_field` is set or not set during CSV imports.
2026-04-16 12:22:44 +02:00
8d0a6dd487 feat: Refactor activity data handling to use dynamic registry fields
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Replaced hardcoded keys in `activity_data_canon.py` with a dynamic retrieval method from the module registry, ensuring that `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` reflects the current configuration.
- Updated `activity_persistence_orchestrator.py` to utilize the new dynamic field retrieval function, enhancing consistency across the data layer.
- Modified `activity_session_metrics.py` to reference the dynamic field keys, improving maintainability and reducing redundancy in the codebase.
2026-04-16 12:14:39 +02:00
06f83e2ffc revert: Wiederherstellung Codezustand von ca8cee9 (ohne Branch-Historie zu überschreiben)
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
Reverts cd29c7d..026c51b per git revert. Alle zwischenliegenden Commits bleiben in Gitea sichtbar; der Arbeitsbaum entspricht wieder dem Stand von ca8cee9.

Made-with: Cursor
2026-04-16 11:59:23 +02:00
026c51b6b5 feat: Add CSV import support for additional training parameters
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced `resolve_activity_attribute_schema_for_csv_import` to enhance the handling of training parameters during CSV imports, allowing for the inclusion of active parameters not present in the category/type profile.
- Updated `apply_activity_mapped_column_aliases` and `upsert_session_metrics_from_csv_mapped` to utilize the new CSV import function, ensuring comprehensive mapping and insertion of metrics.
- Added unit tests to validate the new functionality and ensure correct behavior when handling mapped training parameters during CSV imports.
2026-04-16 11:32:16 +02:00
7d6fdab812 feat: Enhance activity import functionality with additional metrics
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Updated the `_import_activity` function to include new metrics: duration_min, kcal_active, kcal_resting, hr_avg, hr_max, and distance_km during CSV imports.
- Modified the `insert_activity_csv_minimal` function to accept and store these additional metrics in the activity log.
- Enhanced the `run_activity_post_write_hooks_import` function to utilize the new metrics for auto-evaluation after activity imports.
- Updated the activity import router to pass the new metrics from the CSV file to the database functions, ensuring comprehensive data handling.
- Improved frontend handling of activity entry forms to accommodate the new metrics, enhancing user experience during activity log edits.
2026-04-16 11:04:43 +02:00
5cda485458 feat: Refactor activity data handling and improve CSV import logic
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Updated `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` to clarify the derivation of `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` from `csv_parser.module_registry`.
- Enhanced `activity_data_canon.py` to eliminate hardcoded key lists, ensuring all registry fields are derived dynamically.
- Refactored the `_import_activity` function to remove redundant parameters and streamline the import process.
- Improved the `insert_activity_csv_minimal` function to handle metrics exclusively through `update_activity_columns`, preventing hardcoded values.
- Updated frontend components to manage editable activity log fields more effectively, ensuring proper handling of metrics during CSV imports.
- Added unit tests to validate the new logic and ensure consistency in activity session metrics handling.
2026-04-16 10:35:08 +02:00
cd29c7d433 feat: Enhance activity session metrics handling and CSV import logic
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
- Updated the `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE` with new details on CSV import processes and EAV handling, improving documentation clarity.
- Refactored the `_import_activity` function to utilize `apply_activity_mapped_column_aliases`, ensuring proper mapping of training parameters and reducing redundancy.
- Introduced validation for numeric bounds in the `activity_csv_registry_updates_from_mapped` function, enhancing data integrity during CSV imports.
- Added new utility functions to manage column aliasing and streamline the upsert process for session metrics, preventing duplicate entries.
- Implemented unit tests to validate the new aliasing logic and ensure correct behavior during session metrics updates.
2026-04-16 07:25:39 +02:00
ca8cee990b feat: Enhance activity metrics handling and documentation
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Updated the README to include new activity production architecture and phases, improving clarity on the development roadmap.
- Enhanced the `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE` with details on the target architecture and phase plan for production readiness.
- Introduced a new function `merge_column_backed_and_eav_metrics` to streamline the merging of metrics from column-backed and EAV sources, ensuring data integrity and reducing duplication.
- Refactored session metrics handling to eliminate deprecated synchronization methods, improving the overall efficiency of data processing.
- Added unit tests for the new merging logic, ensuring robust validation of metrics handling.
2026-04-15 16:59:11 +02:00
a7859f0050 Merge pull request 'Erste Version - Universal CSV Importer für EAV und activity_log' (#85) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
Reviewed-on: #85
2026-04-15 11:46:30 +02:00
58ddde6b1e feat: Add time input fields for activity log and enhance time handling
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 15s
- Introduced start_time and end_time fields in the activity log entry form, allowing users to input specific times for activities.
- Implemented utility functions to format and validate time inputs from the API and user input, ensuring proper handling of time data.
- Updated the activity display to show formatted start and end times, improving clarity for users reviewing their activity logs.
2026-04-15 11:39:39 +02:00
08eae86ddc feat: Refactor activity import logic and enhance CSV handling
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Replaced the deprecated `resolve_activity_log_column_patch_from_csv` function with `activity_csv_registry_updates_from_mapped` to streamline updates from CSV mappings.
- Updated the `_import_activity` function to utilize the new registry updates, improving data integrity during activity imports.
- Enhanced the activity module registry by adding German labels for various fields, improving localization support.
- Refactored the session metrics handling to ensure only relevant fields are processed, enhancing the overall robustness of CSV imports.
2026-04-15 10:35:48 +02:00
9d47c4ef84 feat: Update session metrics handling for CSV-mapped values
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Enhanced the docstring for `upsert_session_metrics_from_csv_mapped` to clarify the handling of schema parameters and EAV logic.
- Modified the condition for skipping updates based on `source_field` to ensure only patchable columns are processed, improving data integrity during session metrics upsert operations.
2026-04-15 10:28:13 +02:00
e4e8c70cd2 feat: Enhance CSV header normalization and mapping for activity data
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced a new utility function `canonical_csv_header_label` to standardize CSV header labels, improving consistency in field mapping.
- Updated the `_lookup_db_field` function to support prefix matching for longer manual keys, enhancing the accuracy of field resolution.
- Added tests to validate handling of non-breaking space characters in CSV headers and ensure correct mapping to normalized keys, improving robustness of CSV parsing.
2026-04-15 10:04:32 +02:00
c570e67a09 feat: Enhance activity session metrics handling and frontend display
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
- Updated the `ACTIVITY_LOG_PATCHABLE_COLUMNS` and `ACTIVITY_LOG_PATCH_FORBIDDEN` sets to improve validation of CSV imports, ensuring only allowed fields are patched.
- Refactored the `_coerce_raw_value_for_parameter` function to handle string inputs for integer and float types, enhancing data coercion accuracy.
- Modified the `SessionMetricsFields` component to display orphan metrics that do not match the current schema, improving user visibility of imported data discrepancies.
- Enhanced the frontend to handle and display additional metrics, ensuring a more comprehensive representation of session data.
2026-04-15 08:55:43 +02:00
574af61349 feat: Enhance CSV import and validation for activity module
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Updated the CSV import logic to merge active training parameters with static fields for the activity module, improving field mapping accuracy.
- Enhanced validation functions to incorporate dynamic field definitions based on active training parameters, ensuring better data integrity during imports.
- Refactored related functions to streamline the process of handling CSV templates and field mappings, improving maintainability and clarity.
- Added new utility functions for resolving activity log column patches and upserting session metrics from CSV, enhancing the overall import functionality.
2026-04-15 08:12:58 +02:00
934b915357 First Version EAV Importer. feat: Enhance activity detail retrieval with EAV metrics and refactor activity import logic
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Updated the `get_activity_detail` function to include session metrics in the activity detail output, allowing for enriched data representation.
- Refactored the activity import logic to streamline the process of inserting and updating activity records, utilizing new helper functions for better maintainability.
- Improved the handling of duplicate activity entries by implementing a more robust identification mechanism.
- Enhanced the metadata for activity detail registration to reflect the inclusion of EAV metrics and updated source tables.
2026-04-15 07:25:39 +02:00
c6e8371d5a feat: Implement session deduplication in activity listing
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added a new query parameter `collapseDuplicateSessions` to the activity listing endpoint to enable deduplication of sessions based on date, type, start time, duration, and calories.
- Enhanced backend logic to handle deduplication and return the most recent entry for duplicate sessions.
- Updated frontend to support the new deduplication feature, improving the clarity of displayed activity data.
- Modified API utility to include the new parameter in requests for activity data.
2026-04-14 16:19:34 +02:00
f718785145 feat: Add monthly activity fetching and improve activity listing
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced a new query parameter for the activity listing endpoint to fetch entries by calendar month (format: YYYY-MM), excluding days and offset.
- Implemented backend validation for the month parameter to ensure correct format and range.
- Enhanced the frontend to support month selection, allowing users to load activities for specific months and dynamically update the displayed entries.
- Improved the user interface to show the selected month and the range of loaded months, enhancing user experience.
2026-04-14 14:34:10 +02:00
9fdb02ff8b feat: Refactor activity session metrics handling and enhance activity listing
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Updated the `replace_activity_session_metrics` function to improve validation logic and error handling for required fields.
- Enhanced the activity listing query to order results by date, start time, and ID, ensuring consistent output.
- Modified the frontend to handle null values in metrics payload and improved the display of activity statistics, including total entries in profile and sample size.
2026-04-14 14:25:17 +02:00
1f51c32521 feat: Enhance activity listing and statistics retrieval with pagination and quality filter options
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added pagination support to the activity listing endpoint with `limit` and `offset` parameters.
- Introduced a `skip_quality_filter` option to allow retrieval of all entries without applying the quality filter.
- Updated the frontend to implement dynamic loading of activity entries and statistics without the quality filter.
- Improved user experience with a "Load More" button for fetching additional entries on the ActivityPage.
2026-04-14 14:11:01 +02:00
766b64cd64 feat: Expand ActivityEntry model and enhance activity log handling
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Added new fields to the ActivityEntry model for improved tracking: hr_min, pace_min_per_km, cadence, avg_power, elevation_gain, temperature_celsius, humidity_percent, avg_hr_percent, and kcal_per_km.
- Updated the create_activity function to accommodate the new fields in the activity log.
- Modified session metrics handling to ensure accurate data retrieval and merging based on the updated schema.
2026-04-14 12:59:47 +02:00
3296dfca28 feat: Enhance activity log handling and session metrics synchronization
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s
- Added a new function to synchronize session metrics with activity log entries, ensuring data consistency.
- Updated the create and update activity endpoints to call the synchronization function after inserting or modifying activity logs.
- Introduced a set of allowed keys for activity log payloads to streamline data handling in the frontend.
- Improved data coercion logic for various data types in the frontend to ensure accurate data submission.
2026-04-14 12:53:35 +02:00
db9952525a feat: Add endpoints for activity statistics and uncategorized activities
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Implemented a new endpoint to retrieve activity statistics for the last 30 entries, including total calories and duration by activity type.
- Added an endpoint to list activities without assigned training types, grouped by activity type.
- Removed deprecated versions of the statistics and uncategorized activities endpoints for cleaner code.
2026-04-14 12:32:38 +02:00
196b6c5cf1 feat: Add update functionality for training category and type parameters
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced new endpoints for updating training category and type parameters in the backend.
- Added corresponding update functions in the frontend API utility.
- Enhanced the Admin Activity Attribute Profiles page to support editing and saving changes for category and type parameters.
- Implemented state management for editing parameters and improved error handling during updates.
2026-04-14 12:26:52 +02:00
cf7379b2f6 feat: Implement Activity Attribute Profiles and session metrics editing
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 16s
- Added new Admin UI for managing Activity Attribute Profiles.
- Enhanced ActivityPage to support dynamic loading and editing of session metrics.
- Updated API utility functions to handle new endpoints for training parameters and metrics.
- Improved form handling for session metrics, including validation and error management.
- Updated documentation to reflect new features and changes in session metrics handling.
2026-04-14 11:56:16 +02:00
48508c164e feat: Add Activity Session Metrics functionality
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
- Introduced Activity Session Metrics for enhanced tracking of session data.
- Updated backend to support new API endpoints for managing session metrics.
- Added new Pydantic models for activity metrics and replaced metrics functionality.
- Enhanced data layer to include session metrics in recent training session data.
- Updated documentation to reflect changes in session metrics handling.
2026-04-14 11:49:14 +02:00
278 changed files with 39896 additions and 4613 deletions

View File

@ -12,7 +12,8 @@ Dieser Ordner ist der **primäre Orientierungspunkt** für Claude Code / Cursor-
| 2 | **`rules/DOCUMENTATION.md`** – Ablage- und Dokumentationsregeln |
| 3 | `rules/ARCHITECTURE.md`, `rules/CODING_RULES.md`, `rules/LESSONS_LEARNED.md` |
| 4 | Issue-Landkarte: **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
| 5 | **Universal CSV Import** (Modul/Executor/Vorlagen): **`docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** (unter `.claude/`) |
| 5 | **Jinkendo Foundation** (Designprinzipien 1–9): **`docs/jinkendo-foundation/design-principles/README.md`** |
| 6 | **Universal CSV Import** (Modul/Executor/Vorlagen): **`docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** (unter `.claude/`) |
Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im **Projekt**-`docs/`, nicht hier).
@ -25,6 +26,7 @@ Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im
├── README.md ← Diese Datei
├── rules/ ← Verbindliche Regeln (versioniert, wenn konfiguriert)
├── docs/ ← Spezifikationen + Arbeitspapiere
│ ├── jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
│ ├── functional/ ← Fachlich (WAS)
│ ├── technical/ ← Technisch (WIE)
│ ├── architecture/ ← Querschnitt

View File

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

View File

@ -20,6 +20,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
├── ROADMAP.md ← Strategische Phasen (0–3)
├── CLEANUP_PLAN.md ← Historie Bereinigung März 2026
├── prompts/ ← Exportierte Prompt-Artefakte (JSON)
├── jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
├── functional/ ← Fachliche Spezifikationen (WAS)
├── technical/ ← Technische Spezifikationen & Referenz (WIE)
├── working/ ← Arbeitspapiere, Analysen, Session-Snapshots
@ -52,9 +53,12 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|--------|-------------|-------------------|
| Data Layer / Charts (Phase 0c) | `functional/DATA_ARCHITECTURE.md`, `technical/DATA_LAYER_EXTENSION_GUIDE.md` | `backend/data_layer/`, `backend/routers/charts.py` |
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
| Dashboard-Lab-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 |
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
| BLS / Lebensmittel | `functional/BLS_FOOD_REFERENCE.md`, `technical/BLS_FOOD_REFERENCE.md` | Migration 062, `backend/bls/`, `data_layer/food_mapping.py` |
| **Designprinzipien (Produktfamilie)** | **`jinkendo-foundation/design-principles/README.md`** | Querschnittsmuster; Foundation für Schwester-Apps |
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
---
@ -91,6 +95,16 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
## Technische Spezifikationen (`technical/`)
### Jinkendo Foundation (Produktfamilie)
| Dokument | Inhalt |
|----------|--------|
| **[jinkendo-foundation/README.md](jinkendo-foundation/README.md)** | Einstieg Foundation-Ordner |
| **[design-principles/README.md](jinkendo-foundation/design-principles/README.md)** | Index: 9 Designprinzipien, Lesereihenfolge, Übergabe-Checkliste |
| `design-principles/*_DESIGN_PRINCIPLES.md` | Einzeldokumente (#1–#9) |
### Referenz & Agent-Guides (`technical/`)
| Dokument | Thema |
|----------|--------|
| `AGGREGATION_METHODS.md` | Aggregation |
@ -113,6 +127,12 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
| `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` | Session-Metriken EAV, Attributprofile, Layer-1, Prod-Migration |
| `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` | Composite-Metriken in EAV (JSONB), Archetypen, CSV-Slots, Layer-1-Expand, Migration/Test-Checkliste |
| `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` | **Zielarchitektur** Aktivität (Spine/EAV/Composites/Import/Layer 1–2) + **Phasenplan A–F** Produktionsreife |
| `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` | Issue #53: Aktivitäts-Platzhalter Layer 1 ↔ 2a (Audit Schritt 1) |
| `ACTIVITY_SCALAR_KANON_TABLE.md` | **Skalar-Kanon** Aktivität (eine Semantik → eine Quelle); Phase A |
| *(Code)* `backend/data_layer/activity_data_canon.py` | **Kanon** activity CSV-Modul vs. EAV-primär; Legacy-Lesefallback |
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
---
@ -176,4 +196,4 @@ Siehe [`audit/README.md`](./audit/README.md).
---
**Letzte Aktualisierung:** 9. April 2026 (Universal CSV Agent-Guide, Abgleich-Tabelle)
**Letzte Aktualisierung:** 4. Juli 2026 (Jinkendo Foundation, Designprinzipien nach `jinkendo-foundation/`)

View File

@ -0,0 +1,59 @@
# BLS-Lebensmittelreferenz und FDDB-Mapping
**Stand:** 2026-09-14 · **Status:** Phase 1 (Umsetzung)
## WAS
Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class.
## Zuordnung (UX)
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. Zutatenlisten liegen im Tab **Listen** (sichtbar, durchsuchbar, bearbeitbar). Im Listendialog sieht man pro Zutat, ob ein Mapping fehlt, und kann dort bestätigen oder suchen — zusätzlich zum Tab Zuordnen. Die Zutatensuche zeigt zuerst Namen aus importierten/angelegten Listen, dann eigene Lebensmittel, dann den BLS. „Andere Liste wählen“ verknüpft eine Tagebuchzeile mit einer bestehenden Liste; der Dialog zeigt alle Listen, nicht nur Namens-Treffer. Nach dem Verknüpfen verschwindet die Zeile unter Zuordnen — Kennzeichnung „Liste: …“, solange sie noch offen ist.
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
**Mitai speichert nur Roh-Kombinationen** — einen Namen für eine Zutatenliste ohne Kochvorgang (Müsli, Bowl, Smoothie). Summe der Zutatengramm = gegessene Gramm. Tabelle bleibt `food_recipes` (kein Rename).
**Gekochte Familienrezepte** liegen in **Tandoor**. Kein zweites Rezeptbuch in Mitai. Kochschwund (Wasserverlust beim Köcheln) darf die Rohzutaten nicht 1:1 auf die gegessene Menge skalieren.
Spätere Ausbeute (nicht in Phase 1):
- `ingredients_g` = Summe der auf Gramm aufgelösten Zutaten
- `cooked_yield_g` = gewogenes Fertiggewicht nach dem Kochen
- `yield_factor = cooked_yield_g / ingredients_g` (oft 0,5–0,9 bei langem Köcheln)
- Nährwerte **pro 100 g fertig** = `Summe(Zutat_Nährwerte) / cooked_yield_g × 100`
- Gegessen: `portion_g / 100 × Werte_pro_100g`
Ohne Fertiggewicht: nicht raten — FDDB-Makros oder Marke „unvollständig“. Für Listen gilt implizit `cooked_yield_g = ingredients_g` (Faktor 1). Tandoor liefert Zutaten und Schritte; die Ausbeute und die **pro-100-g**-Rechnung führt Mitai. Importiertes Gericht wird ein Katalogeintrag (`catalog_kind` z. B. `recipe_cooked`), keine zweite Wertetabelle.
## FDDB-Listen
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Listenname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Liste verknüpft → **Zutaten** zuordnen, nicht die Liste als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein.
## Fachliche Regeln
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin.
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. Nutzer-Mapping vor globalem Mapping. Globales Mapping nur auf BLS oder zentrale Admin-Foods.
- Sichtbarkeit: persönlich (nur Owner) / zentral (alle) / BLS (alle). Vorschläge und Suche filtern danach; bei gleichem Namen gewinnt persönlich vor zentral vor BLS.
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
## Manuelle Lebensmittel / Supplemente
Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`.
**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar.
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.
## Später
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
**Tandoor:** Familienrezepte bleiben dort. Persönlicher Zugang (URL + API-Token) unter Einstellungen — nicht systemweit; Token nur auf dem Server, nie im Export. Verbindungstest: `GET /api/user/` (Fallback Rezeptliste). Zutaten werden später live gelesen und auf BLS gemappt, kein Zutaten-Sync. Noch kein Rezeptimport; später `cooked_yield_g` und Katalogeintrag Fertiggericht pro 100 g.

View File

@ -0,0 +1,46 @@
# Jinkendo Foundation
**Stand:** 2026-07-04
**Zweck:** Geteilte **Architektur-Foundation** für die Jinkendo-Produktfamilie (Mitai, Miken, Ikigai, Shinkan, …) — unabhängig von app-spezifischer Domänenlogik.
Dieser Ordner enthält **keine** Mitai-Fachspecs und **keine** operativen Agent-Guides zu einzelnen Features. Die Referenzimplementierung bleibt in **Mitai Jinkendo**; die Foundation beschreibt **übertragbare Muster**.
---
## Inhalt
| Pfad | Beschreibung |
|------|--------------|
| **[design-principles/README.md](./design-principles/README.md)** | **Einstieg:** Index aller 9 Designprinzipien-Dokumente, Lesereihenfolge, Übergabe-Checkliste |
| `design-principles/*_DESIGN_PRINCIPLES.md` | Einzeldokumente (#1 Prompt Engine … #9 Migration & Deploy) |
---
## Mitai-spezifische Implementierung
Agent-Guides, API-Referenz und Domänen-Specs liegen weiterhin unter:
- `.claude/docs/technical/` — WIE (Implementierung)
- `.claude/docs/functional/` — WAS (Fachlich)
- `docs/issues/` — Issue-Epics
Die Designprinzipien verlinken dorthin, wo Mitai als **Beispiel** dient.
---
## Apps der Familie
| Domain | App |
|--------|-----|
| mitai.jinkendo.de | Körper-Tracking (Referenz) |
| miken.jinkendo.de | Meditation |
| ikigai.jinkendo.de | Lebenssinn |
| shinkan.jinkendo.de | Kampfsport |
---
## Pflege
Neue Querschnittsmuster → neues Dokument unter `design-principles/` + Eintrag im [Index](./design-principles/README.md).
Regeln zur Ablage: [DOCUMENTATION.md](../../rules/DOCUMENTATION.md)

View File

@ -0,0 +1,326 @@
# Auth & Session – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Authentifizierung, Session-Management, rollenbasierte API-Zugriffe — kein Mandanten-/SSO-System, keine Zahlungs-Auth
**Serie:** Designprinzipien für Produktfamilie · Dokument 5 von n
**Vorgänger:** [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Auth-Kern | `backend/auth.py` |
| Auth-Endpoints | `backend/routers/auth.py` |
| Profile | `backend/routers/profiles.py` |
| Frontend | `frontend/src/context/AuthContext.jsx`, `ProfileContext.jsx`, `utils/api.js` |
| DB | `profiles`, `sessions` |
| Architektur-Regeln | `.claude/rules/ARCHITECTURE.md`, `CLAUDE.md` § Auth |
| Vision (nicht implementiert) | `CENTRAL_SUBSCRIPTION_SYSTEM.md` (SSO/JWT) |
---
## Modul
**Auth & Session**
Server-seitige, token-basierte Authentifizierung mit FastAPI-Dependencies — **Identität und Rolle**, getrennt von Feature-Entitlements und fachlicher Logik.
---
## Fachliche Verantwortung
Das Modul übernimmt:
1. **Identität** — Wer ist eingeloggt? (`profiles` + Passwort/bcrypt)
2. **Session** — Opaque Token in `sessions`, Ablaufzeit, Logout
3. **API-Gate** — `require_auth`, `require_admin`, `require_auth_flexible`
4. **Passwort-Lifecycle** — Hash, Verify, Reset, Registrierung, E-Mail-Verifizierung
5. **Rollen** — `profiles.role`: `user` \| `admin` (grobbinsenartig)
Es übernimmt **nicht**:
- Feature-Limits / Tier (→ Feature & Entitlement System, gleiche `auth.py`-Datei aber logisch getrennt)
- Mandanten-Isolation / Org-Workspaces
- OAuth/SSO/JWT (nur Vision)
- Authorization auf Datensatzebene (Row-Level Security)
### Was Mitai **ist** vs. **nicht ist**
| Mitai | Produktfamilien-Muster |
|-------|------------------------|
| 1 Login = 1 Profil (E-Mail) | ✅ Account-Modell |
| Historisch Multi-Profil auf einer Instanz | ⚠️ Legacy (`/profiles`, `X-Profile-Id`) |
| Self-hosted Einzelinstanz | ✅ Kein Multi-Tenant-SaaS |
| Session-Token in DB | ✅ Server-side Session Store |
| Zentrale Jinkendo-Auth (Vision) | ❌ nicht gebaut |
---
## Administrierte vs. code-definierte Konfiguration
| Konfiguration | Speicherort | Administrierbar? |
|---------------|-------------|------------------|
| Nutzer-Stammdaten, Rolle | `profiles` | Admin (User-Verwaltung) / Self-Service |
| Session-Laufzeit | `profiles.session_days` (Default 30) | Profil/Admin |
| Passwort-Hash | `profiles.pin_hash` | Nutzer (change pin) |
| E-Mail-Verifizierung | `email_verified`, Token-Felder | System |
| Trial-Ende | `trial_ends_at` | System bei Registrierung |
| SMTP | Env (`SMTP_*`, `APP_URL`) | Deploy |
| Rate Limits Login/Register | Code (`5/min`, `3/hour`) | Code |
**Hardcodiert:** bcrypt, Token-Länge (`secrets.token_urlsafe(32)`), Rollen-Enum (`user`/`admin`), Header-Name `X-Auth-Token`.
---
## Session- und Auth-Flow
```
Login (email + password)
→ verify_pin (bcrypt | legacy SHA256)
→ optional bcrypt upgrade
→ INSERT sessions (token, profile_id, expires_at)
→ Client: localStorage bodytrack_token
Request
→ Header X-Auth-Token (api.js / AuthContext)
→ get_session(token) JOIN profiles
→ require_auth → session dict (profile_id, role, …)
Logout
→ DELETE sessions WHERE token=…
→ Client: localStorage clear
```
**Sonderfall:** `require_auth_flexible` — Token via Header **oder** Query `ssetoken` (SSE, `<img>`, Downloads).
---
## Rollen
| Rolle | Mechanismus | Typische Rechte |
|-------|-------------|-----------------|
| **user** | `profiles.role = 'user'` | Eigene Daten, Features nach Tier |
| **admin** | `require_admin` | Admin-Shell, Prompts, User, System |
Kein feingranulares RBAC (keine Permission-Matrix). Admin ist Binär-Schalter.
---
## Designprinzipien
### 1. FastAPI-Dependencies als Auth-Gate
| | |
|---|---|
| **Prinzip** | Jeder geschützte Endpoint nutzt `session: dict = Depends(require_auth)` als **separaten** Parameter — nie in `Header()` eingebettet. |
| **Begründung** | Verhindert ungeschützte Endpoints durch falsche Parameter-Signatur. |
| **Quelle** | `CLAUDE.md` § Kritische Regeln; `auth.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht linter-erzwungen; Legacy-Endpoints existieren. |
### 2. Server-side opaque Sessions
| | |
|---|---|
| **Prinzip** | Token ist zufällig, in DB gespeichert; Validierung über `sessions` + Ablauf — kein JWT mit Client-Claims. |
| **Begründung** | Revocation (Logout), kein Trust in Client-Payload; einfaches Modell für Self-Hosted. |
| **Quelle** | `sessions` Tabelle; `make_token()`, `get_session()` |
| **Tragfähigkeit** | **hoch** (Single-App, Self-Hosted) |
| **Einschränkung** | Skalierung multi-node braucht shared session store; SSO-Familie braucht anderes Modell. |
### 3. profile_id aus Session, nicht aus Client
| | |
|---|---|
| **Prinzip** | Autoritative Identität für neue Endpoints: `session['profile_id']` — Client darf Profil nicht wählen. |
| **Begründung** | Verhindert IDOR (Zugriff auf fremde Profile). |
| **Quelle** | `routers/goals.py`, `routers/prompts.py`; Architektur-Intent in `CLAUDE.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy `get_pid(x_profile_id)` akzeptiert `X-Profile-Id` **ohne** Session-Abgleich — siehe Nicht übernehmen. |
### 4. bcrypt mit Legacy-Migration
| | |
|---|---|
| **Prinzip** | Passwörter mit bcrypt; SHA256-Legacy beim Login erkannt und transparent auf bcrypt upgraded. |
| **Begründung** | Kein Big-Bang-Migration; sichere Hashes ohne Nutzer-Zwangs-Reset. |
| **Quelle** | `verify_pin()`, Login in `routers/auth.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Upgrade nur bei erfolgreichem Login. |
### 5. Rate Limiting auf Auth-Endpoints
| | |
|---|---|
| **Prinzip** | Login, Register, Forgot-Password, Resend-Verification mit `slowapi`-Limits (IP-basiert). |
| **Begründung** | Brute-Force- und Abuse-Schutz. |
| **Quelle** | `routers/auth.py` (`5/minute`, `3/hour`); `main.py` Limiter |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | IP-only; kein account-based lockout. |
### 6. Keine E-Mail-Enumeration bei sensiblen Flows
| | |
|---|---|
| **Prinzip** | Forgot-Password und Resend-Verification liefern generische Erfolgsmeldung, auch wenn E-Mail unbekannt. |
| **Begründung** | Privacy; erschwert Account-Scraping. |
| **Quelle** | `password_reset_request`, `resend_verification` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Register sagt „E-Mail bereits registriert“ (Enumeration möglich). |
### 7. E-Mail-Verifizierung vor voller Nutzung
| | |
|---|---|
| **Prinzip** | Self-Register setzt `email_verified=FALSE`; Verify-Endpoint aktiviert + Auto-Login-Session. |
| **Begründung** | Valide Kontaktadresse; Spam-Reduktion. |
| **Quelle** | `register`, `verify_email` in `routers/auth.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht überall im Backend erzwungen (Login ohne verified check?). |
### 8. Flexible Auth für technische Clients
| | |
|---|---|
| **Prinzip** | `require_auth_flexible`: gleiche Session-Validierung via Header oder `?ssetoken=` für SSE/Bilder. |
| **Begründung** | Browser-APIs ohne Custom Headers. |
| **Quelle** | `auth.py`; Prompt SSE `/execute-stream` |
| **Tragfähigkeit** | **mittel–hoch** |
| **Einschränkung** | Token in URL kann in Logs/Referrer leaken — kurze Sessions / HTTPS Pflicht. |
### 9. Zentraler API-Client mit Token-Injektion
| | |
|---|---|
| **Prinzip** | Frontend: `api.js` injiziert `X-Auth-Token` automatisch — kein scattered `fetch` ohne Auth. |
| **Begründung** | Konsistenz; eine Stelle für Token-Handling. |
| **Quelle** | `utils/api.js` → `hdrs()`; `getToken()` aus AuthContext |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Einzelne Komponenten umgehen noch `api.js` (SettingsPage, EmailSettings). |
### 10. Auth getrennt von Authorization (Features)
| | |
|---|---|
| **Prinzip** | `require_auth` = identifiziert; `check_feature_access` = berechtigt für Aktion — nacheinander im Router. |
| **Begründung** | Klare Schichten; Auth-Modul nicht mit Tier-Logik vermischen (auch wenn Datei `auth.py` beides enthält). |
| **Quelle** | `FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md`; Router-Muster |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy Profil-Flags `ai_enabled`, `export_enabled` parallel zum Feature-System. |
### 11. Admin-Gate im Frontend und Backend
| | |
|---|---|
| **Prinzip** | Backend: `require_admin`; Frontend: `RequireAdmin` + `isAdmin` aus Session-Rolle. |
| **Begründung** | UX-Navigation + API-Sicherheit (Frontend allein reicht nicht). |
| **Quelle** | `RequireAdmin.jsx`; `require_admin()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Einzelne Routen (Workflow-Editor) ohne Frontend-Admin-Gate. |
### 12. Session-Kontext im Frontend
| | |
|---|---|
| **Prinzip** | `AuthProvider` hält `{ token, profile_id, role, profile }`; App setzt `setProfileId(session.profile_id)` für API. |
| **Begründung** | Single React-Tree für Login-State; Re-Validate via `/auth/me` beim Start. |
| **Quelle** | `AuthContext.jsx`; `App.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `ProfileContext` lädt alle Profile — Multi-Profil-UX Rest; Session-Profil ist Kanon. |
---
## Nicht übernehmen
1. **`get_pid(X-Profile-Id)` ohne Session-Bindung** — Client kann fremde `profile_id` senden; IDOR-Risiko. Kanon: immer `session['profile_id']` oder explizite Admin-Impersonation mit Audit.
2. **Profile-CRUD nur mit `require_auth`** — `/profiles` listet alle Nutzer für jeden Authentifizierten (Kommentar „admin“, kein `require_admin`). Für Familien-Architektur: strikte Admin-Gates.
3. **Dual-System Profil-Flags vs. Features** — `ai_enabled`, `export_enabled`, `ai_limit_day` in Session-Query neben v9c Feature-Registry.
4. **localStorage-Key-Inkonsistenz** — `bodytrack_token` vs. `mitai-jinkendo_active_profile` (historischer App-Name).
5. **Direktes `fetch` ohne `api.js`** — umgeht Token-/Error-Konvention.
6. **Reset-Token in `sessions`-Tabelle** — `reset_{token}` mischt Session-Typen in einer Tabelle; OK für MVP, für Familie: getrennte Token-Typen/Tabellen.
7. **Kein JWT/SSO trotz Produktfamilien-Vision** — `CENTRAL_SUBSCRIPTION_SYSTEM.md` beschreibt `auth.jinkendo.de` — Mitai-Implementierung ist **nicht** das Zielbild für Cross-App-SSO.
8. **Multi-Profil-Haushalt ohne klares Modell** — Legacy Multi-Profile auf einer Instanz vs. 1 Account = 1 Profil; für neue Apps Modell explizit wählen.
9. **Role als einziges RBAC** — reicht für Admin/User, nicht für feingranulare Permissions.
10. **Session-Query mit veralteten Profil-Spalten** — `get_session` SELECT enthält Legacy-Felder statt nur Identität + Rolle.
11. **Fehlende erzwungene E-Mail-Verified-Prüfung** — Registrierung setzt Flag, Login prüft es nicht offensichtlich.
12. **Debug-Print in Auth-Modul** — `print("[AUTH.PY] Module loaded…")` in Produktionscode.
---
## Abgrenzung zu anderen Serien-Dokumenten
| Thema | Dokument |
|-------|----------|
| Tier, Limits, Quotas | [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md) |
| Zentrale SSO/Abo-Vision | [CENTRAL_SUBSCRIPTION_SYSTEM.md](../../technical/CENTRAL_SUBSCRIPTION_SYSTEM.md) |
| API-First / Router | `ARCHITECTURE.md` §1 |
---
## Modul-Inventar (Ist-Stand)
```
backend/
├── auth.py # Session, require_*, Feature-Access (v9c)
└── routers/
├── auth.py # login, logout, register, verify, reset
└── profiles.py # CRUD, get_pid (Legacy)
frontend/src/
├── context/AuthContext.jsx
├── context/ProfileContext.jsx
├── layouts/RequireAdmin.jsx
└── utils/api.js # Token-Injektion
DB:
├── profiles # Identität, Rolle, Hash, Tier, Trial
└── sessions # token → profile_id, expires_at
```
**Endpoints (Auswahl):**
| Endpoint | Auth |
|----------|------|
| `POST /api/auth/login` | Public + Rate limit |
| `POST /api/auth/logout` | Token optional |
| `GET /api/auth/me` | require_auth |
| `POST /api/auth/register` | Public + Rate limit |
| `GET /api/auth/verify/{token}` | Public |
---
## Verwandte Dokumentation
- Feature-Entitlements: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
- Architektur-Regeln Auth: `CLAUDE.md`, `.claude/rules/ARCHITECTURE.md`
- GUI Admin-Guard: `docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`
- SSO-Vision: [CENTRAL_SUBSCRIPTION_SYSTEM.md](../../technical/CENTRAL_SUBSCRIPTION_SYSTEM.md)
---
## Geplante Folgedokumente (Serie)
| # | Modul | Status |
|---|-------|--------|
| 1 | Prompt Engine | ✅ |
| 2 | Data Layer | ✅ |
| 3 | Feature & Entitlement | ✅ |
| 4 | Registry-/Plugin-Muster | ✅ |
| 5 | Auth & Session | ✅ dieses Dokument |
| 6 | Universal Import | ✅ `UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md` |
| 7 | Dashboard Widgets | ✅ |
| 8 | Navigation / IA | ✅ `NAVIGATION_IA_DESIGN_PRINCIPLES.md` |
| 9 | Migration & Deploy | ✅ |

View File

@ -0,0 +1,363 @@
# Dashboard Widgets – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Konfigurierbare Übersicht (Widget-Katalog, Layout, Entitlements, Frontend-Registry) — keine Chart-/Metrik-Berechnung
**Serie:** Designprinzipien für Produktfamilie · Dokument 7 von n
**Vorgänger:** [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](./UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Katalog (SSoT) | `backend/widget_catalog.py` |
| Layout-Schema | `backend/dashboard_layout_schema.py` |
| Config-Validierung | `backend/dashboard_widget_config.py` |
| Entitlements | `backend/dashboard_widget_entitlements.py`, `widget_feature_requirements_db.py` |
| Produkt-Standard | `backend/system_dashboard_product_default.py` |
| HTTP | `backend/routers/app_dashboard.py` |
| Frontend-Registry | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
| Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
| Layout-Editor | `frontend/src/pages/DashboardConfigurePage.jsx` |
| Fehler-Isolation | `frontend/src/widgetSystem/WidgetErrorBoundary.jsx` |
| Leitfaden | `DASHBOARD_WIDGETS_AGENT_GUIDE.md` |
| Registry-Meta | [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) |
---
## Modul
**Dashboard Widgets**
Erweiterbares System für **konfigurierbare Startübersicht**: Backend-Katalog definiert erlaubte Widget-IDs; Nutzer speichern Reihenfolge, Ein/Aus und optionale `config` pro Profil; Frontend rendert über eine lokale Komponenten-Registry.
---
## Fachliche Verantwortung
Das Modul übernimmt:
1. **Widget-Katalog** — IDs, Titel, Beschreibung, optionale Feature-Anforderung (`requires_feature`).
2. **Layout-Persistenz** — `profiles.dashboard_layout` (JSON v1: `{ version, widgets[] }`).
3. **Validierung** — Erlaubte IDs, keine Duplikate, max. 32 Widgets, mindestens eines aktiv.
4. **Pro-Widget-Config** — Whitelist pro Widget-ID; Normalisierung beim Speichern.
5. **Standard-Layouts** — Code-Fallback (`DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS`), Admin-Override (`system_config`), Lab-Template (`DEFAULT_LAB_WIDGET_IDS`).
6. **Entitlements** — `allowed` im Katalog; Layout bereinigt bei fehlender Berechtigung.
7. **Frontend-Rendering** — Registry mappt Katalog-ID → React-Komponente + Props aus `layoutEntry.config`.
8. **Nutzer-Konfigurator** — „Übersicht anpassen“ (Sortieren, Toggle, Config-Editoren).
Es übernimmt **nicht**:
- Berechnung von KPIs, Charts, Scores (→ Data Layer + Chart-Endpoints, siehe [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md))
- Tier-/Subscription-Logik in Widgets (→ Feature System, siehe [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md))
- Prompt-/KI-Ausführung (Widget zeigt nur UI; Pipeline läuft über eigene API)
### Datenfluss (Happy Path)
```
WIDGET_CATALOG (Backend)
→ GET /api/app/widgets/catalog (+ allowed via check_feature_access)
→ GET /api/app/dashboard-layout
→ coalesce_effective_layout (Profil oder Standard)
→ merge_missing_catalog_widgets (neue IDs anhängen)
→ apply_entitlements_to_layout_dict
→ Frontend: ensureDashboardWidgetsRegistered()
→ WidgetRenderer: enabled widgets → mapProps(layoutEntry.config) → Component
→ PUT /api/app/dashboard-layout (Pydantic + Entitlements + speichern)
```
### Layout-Eintrag (Struktur)
| Feld | Bedeutung |
|------|-----------|
| `id` | Muss in `WIDGET_CATALOG` existieren |
| `enabled` | Sichtbar auf der Übersicht |
| `config` | Optional; nur für whitelisted Widgets mit Inhalt erlaubt |
---
## Administrierte vs. code-definierte Konfiguration
| Konfiguration | Speicherort | Wer pflegt? |
|---------------|-------------|-------------|
| Widget-IDs, Metadaten, Default-Aktivierung | `widget_catalog.py` | Entwickler |
| Produkt-Standard-Layout (live) | `system_config.dashboard_product_default` | Admin |
| Produkt-Standard (Fallback) | `DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS` | Entwickler |
| Lab-/Editor-Standard | `DEFAULT_LAB_WIDGET_IDS` | Entwickler |
| Nutzer-Layout | `profiles.dashboard_layout` | Nutzer |
| Feature-Gate (Katalog) | `requires_feature` pro Eintrag | Entwickler |
| Feature-Gate (Override) | `widget_feature_requirements` + Marker | Admin |
| Config-Schema pro Widget | `dashboard_widget_config.py` | Entwickler |
| React-Komponente | `registerDashboardWidgets.js` | Entwickler |
---
## Designprinzipien
### 1. Backend-Katalog als Single Source of Truth für IDs
| | |
|---|---|
| **Prinzip** | `WIDGET_CATALOG` ist die einzige autoritative Liste erlaubter Widget-IDs; `ALLOWED_WIDGET_IDS` wird daraus abgeleitet — nicht manuell duplizieren. |
| **Begründung** | Layout-Validator, API und Default-Layouts bleiben synchron; unbekannte IDs werden beim PUT abgewiesen. |
| **Quelle** | `widget_catalog.py`; Agent-Guide §4 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Frontend-Registry ist zweite manuelle Bindung (kein Build-Time-Gate). |
### 2. Dual Registry: Backend-Kanon + Frontend-Komponentenbindung
| | |
|---|---|
| **Prinzip** | Jede Katalog-ID braucht einen Eintrag in `registerDashboardWidget({ id, Component, mapProps })`; idempotent via `ensureDashboardWidgetsRegistered()`. |
| **Begründung** | React-Komponenten können nicht im Python-Katalog leben; explizite Zuordnung hält Bundle tree-shakeable. |
| **Quelle** | `registerDashboardWidgets.js`, `dashboardWidgetRegistry.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Fehlende Registrierung → Laufzeit „Unbekanntes Widget“, kein CI-Fail. |
### 3. Layout als versioniertes Profil-JSON
| | |
|---|---|
| **Prinzip** | Nutzer-Layout in `profiles.dashboard_layout`; Schema `version: 1`, Liste `{ id, enabled, config? }`. |
| **Begründung** | Pro Profil anpassbar; Reset auf NULL → System-Standard. |
| **Quelle** | `DashboardLayoutPayload`, `app_dashboard.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nur v1; Schema-Evolution braucht Migrationspfad. |
### 4. Validierung an der API-Grenze (Pydantic)
| | |
|---|---|
| **Prinzip** | Jeder GET/PUT-Pfad normalisiert über `DashboardLayoutPayload`: Duplikat-IDs, unbekannte IDs, leeres Layout (kein enabled) → Fehler. |
| **Begründung** | Keine korrupten Layouts in der DB; Frontend kann auf gültige Struktur vertrauen. |
| **Quelle** | `dashboard_layout_schema.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Ungültiges gespeichertes Layout → Fallback auf Standard (`coalesce_effective_layout`). |
### 5. Config nur für explizit whitelisted Widgets
| | |
|---|---|
| **Prinzip** | `WIDGETS_ALLOWING_CONFIG`: Widgets **ohne** Eintrag dürfen nur leere `config` haben; sonst Validierungsfehler. |
| **Begründung** | Verhindert unkontrollierte JSON-Blobs und stille Ignorierung unbekannter Keys. |
| **Quelle** | `dashboard_widget_config.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Pro Widget heterogene Schemas (chart_days vs. KPI-Tiles vs. show_*-Booleans). |
### 6. Strikte Config-Keys (Whitelist, Normalisierung)
| | |
|---|---|
| **Prinzip** | Unbekannte Keys in `config` werden abgelehnt; bekannte Keys typgeprüft und normalisiert (z. B. `chart_days` 7–90, KPI max. 9 Kacheln). |
| **Begründung** | Vorhersagbares Verhalten; Editor und Backend stimmen überein. |
| **Quelle** | `_validate_chart_days_only`, `_validate_kpi_board_config`, History-Viz-Defaults |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Frontend-Normalizer (`bodyChartDays.js`, `*VizConfig.js`) teils parallel — Abweichungsrisiko. |
### 7. Config-Größenlimit
| | |
|---|---|
| **Prinzip** | `MAX_WIDGET_CONFIG_JSON_BYTES` (3072) — keine großen Blobs in Layout-JSON. |
| **Begründung** | DB-Spalte und API-Payload bleiben schlank; Config = Präferenzen, nicht Datenspeicher. |
| **Quelle** | `dashboard_widget_config.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 8. Katalog-Erweiterung ohne Layout-Reset
| | |
|---|---|
| **Prinzip** | `merge_missing_catalog_widgets` hängt neue Katalog-IDs ans bestehende Layout an (`enabled: false`). |
| **Begründung** | Nutzer müssen nach Deploy nicht resetten; „Übersicht anpassen“ zeigt neue Optionen. |
| **Quelle** | `dashboard_layout_schema.py`; Agent-Guide |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Reihenfolge neuer Widgets immer am Ende. |
### 9. Mehrere Standard-Layouts (Produkt vs. Lab vs. Admin)
| | |
|---|---|
| **Prinzip** | **Produkt:** `get_product_default_base_dict` (DB-Override oder `DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS`). **Lab:** `lab_default_layout_dict` für Editor/Reset. **Nutzer:** eigenes JSON oder NULL. |
| **Begründung** | Onboarding-Default getrennt von Entwickler-/Lab-Template; Admin kann Produkt-Standard ohne Deploy ändern. |
| **Quelle** | `system_dashboard_product_default.py`, `widget_catalog.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Feldname `lab_default_layout` historisch irreführend (Servertemplate, nicht nur Lab). |
### 10. Entitlements zentral, Widgets konsumieren nur `allowed`
| | |
|---|---|
| **Prinzip** | Sichtbarkeit über `check_feature_access` in `widget_id_allowed`; Katalog liefert `allowed` pro Zeile. Widgets/React duplizieren **keine** Tier-Logik. |
| **Begründung** | Eine Wahrheit für „darf angezeigt werden“; spätere Feature-Cluster ohne Widget-Refactor. |
| **Quelle** | `dashboard_widget_entitlements.py`; Agent-Guide §0 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Inhalts-Endpoints (Charts, KI) brauchen **eigenes** Feature-Gate (Defense in Depth). |
### 11. Layout-Persistenz bereinigt nicht erlaubte Widgets
| | |
|---|---|
| **Prinzip** | `apply_entitlements_to_layout_dict`: bei fehlender Berechtigung `enabled: false`; mindestens `welcome` bleibt aktiv. GET und PUT wenden an. |
| **Begründung** | Keine „gespeichert aber nie sichtbar“-Zombies; Downgrade/Tier-Wechsel degradieren gracefully. |
| **Quelle** | `dashboard_widget_entitlements.py`, `app_dashboard.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Policy ist deaktivieren, nicht entfernen — IDs bleiben im JSON. |
### 12. DB-Override für Widget-Feature-Anforderungen
| | |
|---|---|
| **Prinzip** | Katalog-`requires_feature` ist Default; Admin kann per `dashboard_widget_requirement_custom` + `widget_feature_requirements` überschreiben (AND-Semantik). |
| **Begründung** | Runtime-Anpassung ohne Code-Deploy; Marker-Zeile trennt Custom von Fallback. |
| **Quelle** | `widget_feature_requirements_db.py`, Migration 041 |
| **Tragfähigkeit** | **mittel–hoch** |
| **Einschränkung** | Zwei Quellen (Code + DB) — Dokumentation und Admin-UI nötig. |
### 13. mapProps: Layout-Config → Komponenten-Props
| | |
|---|---|
| **Prinzip** | Registry-Eintrag mappt `ctx.layoutEntry.config` auf typisierte Props (`chartDays`, `kpiConfig`, `bodyHistoryVizConfig`, …). |
| **Begründung** | Widget-Komponenten bleiben layout-agnostisch; Normalisierung an einer Stelle pro ID. |
| **Quelle** | `registerDashboardWidgets.js` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Teilweise Normalisierung in Widget statt in `mapProps` (inkonsistent, aber dokumentiert). |
### 14. Refresh-Koordination über Context
| | |
|---|---|
| **Prinzip** | `refreshTick` + `requestRefresh()` im Render-Context; Widgets laden Daten bei Tick-Änderung neu; Aktionen (z. B. Schnelleingabe) rufen `requestRefresh`. |
| **Begründung** | Kein globales State-Monster; gezielte Invalidierung nach Capture. |
| **Quelle** | `dashboardWidgetRegistry.jsx`, Widget-Implementierungen |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein feingranulares Cache pro Widget. |
### 15. Fehler-Isolation pro Widget
| | |
|---|---|
| **Prinzip** | `WidgetErrorBoundary` um jede Instanz — Render-Fehler crashen nicht die ganze Übersicht. |
| **Begründung** | Robuste PWA; ein defektes Chart blockiert nicht Gewicht-Eingabe. |
| **Quelle** | `WidgetErrorBoundary.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein automatisches Retry/Reporting. |
### 16. Konfigurator filtert nach `allowed`
| | |
|---|---|
| **Prinzip** | `DashboardConfigurePage` blendet Widgets mit `allowed === false` aus der bearbeitbaren Liste aus. |
| **Begründung** | Nutzer sehen keine Optionen, die sie nicht nutzen dürfen (Agent-Guide A2). |
| **Quelle** | `DashboardConfigurePage.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Bereits gespeicherte disabled Einträge können im JSON verbleiben. |
### 17. Widgets konsumieren Data Layer, duplizieren keine Logik
| | |
|---|---|
| **Prinzip** | Chart-/KPI-Widgets rufen Chart-Endpoints bzw. API-Fassaden auf; Berechnungen leben in `data_layer/`, nicht in Widget-JS. |
| **Begründung** | Gleiche Zahlen wie Verlauf, KI-Platzhalter und Export. |
| **Quelle** | Layer-2b `*_history_viz`-Widgets; [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy-Widgets unter `dashboard-widgets-legacy/` teils ältere Fetch-Pfade. |
### 18. Dedizierte Config-Editoren für komplexe Widgets
| | |
|---|---|
| **Prinzip** | Einfache `chart_days`: Set `CHART_DAYS_WIDGET_IDS` im Layout-Editor; komplexe Config: eigene Editor-Komponenten (`KpiBoardConfigEditor`, `*VizConfigEditor`). |
| **Begründung** | UX skaliert mit Config-Komplexität; Backend-Schema und Editor bleiben parallel pflegbar. |
| **Quelle** | `widgetSystem/*ConfigEditor.jsx`, Agent-Guide §3.4 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Jedes neue komplexe Widget = Editor + Validator + Tests. |
---
## Nicht übernehmen
1. **Tier-Logik in React-Widgets** — nur `allowed` aus API; keine hardcodierten Plan-Namen.
2. **`ALLOWED_WIDGET_IDS` manuell pflegen** — immer aus Katalog ableiten.
3. **Config ohne Backend-Whitelist** — stille Ignorierung unbekannter Keys in Widgets.
4. **Nur UI-Gating ohne API-Absicherung** — Chart-/KI-/Export-Endpoints weiterhin `check_feature_access` (403).
5. **Frontend-Registry vergessen** — Katalog-Eintrag ohne `registerDashboardWidget` → Laufzeitfehler statt Build-Fail.
6. **Große Daten in `config`** — Layout ist Präferenzspeicher, kein Blob-Store (>3072 Bytes).
7. **Doppelte Widget-IDs im Layout** — Validator verbietet; Editor muss dasselbe erzwingen.
8. **Neue Katalog-IDs ohne `merge_missing_catalog_widgets`-Pfad** — Nutzer-Layouts veralten unsichtbar.
9. **Kompletter Katalog nur in DB** — schwer testbar; Code-Katalog + optionale Feature-Overrides ist das Muster.
10. **Evidence-Pflicht à la Placeholder-Registry** — 22 Metadatenfelder pro Widget wären Overkill; Tiefe an Risiko anpassen ([REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)).
11. **Ein Default für alles** — Produkt-Onboarding, Lab-Template und Admin-Override haben unterschiedliche Zwecke; nicht vermischen.
12. **Fehlender Cross-Check Backend ↔ Frontend IDs** — empfohener Test/Gate fehlt im Ist-Stand; nicht als „optional“ ignorieren.
13. **Berechnungslogik im Widget** — KPIs/Scores gehören in Data Layer, nicht in `useEffect`-Mathe.
14. **Entitlements beim Speichern ablehnen statt deaktivieren** — Mitai wählt deaktivieren; Policy bewusst festlegen und dokumentieren.
---
## Modul-Inventar (Ist-Stand)
```
backend/
├── widget_catalog.py # WIDGET_CATALOG, DEFAULT_*_IDS
├── dashboard_layout_schema.py # Pydantic, merge_missing, defaults
├── dashboard_widget_config.py # WIDGETS_ALLOWING_CONFIG, Validatoren
├── dashboard_widget_entitlements.py # allowed, layout cleanup
├── widget_feature_requirements_db.py # Admin-Override
├── system_dashboard_product_default.py
└── routers/app_dashboard.py
frontend/src/
├── widgetSystem/
│ ├── dashboardWidgetRegistry.jsx
│ ├── registerDashboardWidgets.js
│ ├── layoutEditor.js
│ ├── bodyChartDays.js, *VizConfig.js
│ └── *ConfigEditor.jsx
├── components/dashboard-widgets/ # Produkt-Widgets
├── components/dashboard-widgets-legacy/ # ältere Kern-Widgets
└── pages/DashboardConfigurePage.jsx
DB:
├── profiles.dashboard_layout
├── system_config.dashboard_product_default
├── dashboard_widget_requirement_custom
└── widget_feature_requirements
```
**Katalog-Umfang:** ~24 Widget-IDs (Stand `widget_catalog.py`); ~13 mit konfigurierbarer `config`.
---
## Verwandte Dokumentation
- Agent-Guide (normativ): [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md)
- Registry-Meta: [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)
- Feature-Gates: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
- Datenberechnung: [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md)
- Architektur §9: `.claude/rules/ARCHITECTURE.md`
---
## Geplante Folgedokumente (Serie)
| # | Modul | Status |
|---|-------|--------|
| 1–6 | … | ✅ |
| 7 | Dashboard Widgets | ✅ dieses Dokument |
| 8 | Navigation / IA | ✅ |
| 9 | Migration & Deploy | ✅ |

View File

@ -0,0 +1,359 @@
# Data Layer – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Multi-Layer Data Architecture (Phase 0c, Issue #53) — keine Mitai-Gesamtarchitektur, keine konkrete Gesundheits-/Ernährungsfachlogik als Produktinhalt
**Serie:** Designprinzipien für Produktfamilie · Dokument 2 von n
**Vorgänger:** [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Metriken (Layer 1) | `backend/data_layer/*_metrics.py`, `scores.py`, `correlations.py` |
| Utilities | `backend/data_layer/utils.py` |
| Visualisierung (Layer 2b) | `*_chart_payloads.py`, `*_viz.py` |
| KI-Formatierung (Layer 2a-Hilfe) | `prompt_output_compact.py` |
| Persistenz-Orchestrierung | `activity_persistence_orchestrator.py`, `activity_session_metrics.py` |
| Konsumenten | `routers/charts.py`, `placeholder_resolver.py`, `routers/exportdata.py` |
| Leitfäden | `DATA_LAYER_EXTENSION_GUIDE.md`, `docs/issues/issue-53-phase-0c-multi-layer-architecture.md` |
| Architektur-Regel Import-Grenze | `.claude/rules/ARCHITECTURE.md` §8 |
---
## Modul
**Data Layer** (Phase 0c Multi-Layer Architecture, Issue #53)
Zentrale Schicht für **Datenabruf, Berechnung und strukturierte Aufbereitung** — ohne UI-Formatierung, ohne Prompt-Texte, ohne Chart.js-spezifische Ausgabe in den Kern-Metrik-Modulen.
---
## Fachliche Verantwortung
Der Data Layer ist die **Single Source of Truth für alle abgeleiteten Messwerte und Metriken**. Er übernimmt:
1. **Datenabruf** — Lesen aus PostgreSQL (profile-scoped), optional mit Quality-Filter.
2. **Berechnung** — Trends, Scores, Korrelationen, Aggregationen, Projektionen.
3. **Strukturierte Rückgabe** — Dicts/Listen mit numerischen Werten, Datumsfeldern, Metadaten (`confidence`, `data_points`).
4. **Konsumenten-Bereitstellung** — Charts (Layer 2b), KI-Platzhalter (Layer 2a via Resolver), Export, Router-Anreicherung.
Er übernimmt **nicht**:
- CSV-Parsing und Feld-Mapping (Import-Schicht)
- Prompt-Template-Auflösung (Prompt Engine)
- React-Rendering oder Frontend-Berechnungen
- Autorisierung / Feature-Limits (Auth-Schicht)
### Schichtenmodell (Multi-Layer)
```
┌─────────────────────────────────────────────────────────┐
│ Layer 0: Persistenz (PostgreSQL) │
│ weight_log, nutrition_log, activity_log, sleep_log, … │
└──────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────┐
│ Layer 1: DATA LAYER (Metriken) │
│ Strukturierte Daten · confidence · data_points │
│ KEINE formatierten Strings · KEINE Chart.js-Objekte │
└──────────────┬───────────────────────┬──────────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌────────────────────────────┐
│ Layer 2a: KI / Prompts │ │ Layer 2b: Visualisierung │
│ placeholder_resolver │ │ *_chart_payloads, *_viz │
│ prompt_output_compact │ │ routers/charts.py │
└──────────────────────────┘ └────────────────────────────┘
```
### Administrierte vs. code-definierte Konfiguration
| Was | Wo | Administrierbar? |
|-----|-----|------------------|
| Berechnungslogik (Formeln, Fenster) | `data_layer/*.py` | ❌ Code + Review |
| Confidence-Schwellen | `data_layer/utils.py` | ❌ Code |
| Goal Mode / Focus Weights | DB (`profiles`, `user_focus_area_weights`) | ✅ Nutzer/Admin |
| Quality Filter (Profil) | DB (`profiles`) | ✅ Admin |
| Chart-Zeitfenster | Query-Parameter an API | ✅ Request |
| Referenzwerte (persönlich) | DB + `reference_values.py` | ✅ Nutzer |
| EAV Session Metrics | DB (`training_*_parameter`) | ✅ Admin |
**Bewusst nicht hardcodiert in Routern:** Metrik-Berechnungen — Router delegieren an Data Layer.
**Hardcodiert (Code):** Domänen-Module, Confidence-Regeln, Schwellen pro Metrik-Typ, TDEE-Fallback-Logik, Chart-Payload-Struktur.
### Trennung: Metriken · Chart-Payloads · KI-Formatierung · Persistenz
| Schicht | Module | Verantwortung |
|---------|--------|---------------|
| **Metriken** | `body_metrics.py`, `nutrition_metrics.py`, … | Reine Berechnung, strukturierte Dicts |
| **Chart-Payloads** | `nutrition_chart_payloads.py`, `correlation_chart_payloads.py`, … | Chart.js-kompatible `{ labels, datasets, metadata }` aus Layer-1-Daten |
| **Viz-Bundles** | `body_viz.py`, `fitness_viz.py`, … | Zusammengesetzte Dashboard-/History-Pakete für Frontend |
| **KI-Kompaktierung** | `prompt_output_compact.py` | Token-sparende Zahlen/JSON für Platzhalter |
| **Interpretation** | `*_interpretation.py`, `vital_signs_assessment.py` | Textliche Einordnung (WHO-Klassen etc.) — Grenze zu Layer 2a |
| **Persistenz-Orchestrator** | `activity_persistence_orchestrator.py` | Schreibpfade REST/CSV → DB + Nebenwirkungen (EAV, Eval) |
### Konsumenten (wer ruft den Data Layer auf?)
| Konsument | Muster |
|-----------|--------|
| `routers/charts.py` | Layer-1-Funktion + Chart-Payload-Builder |
| `placeholder_resolver.py` | Layer-1 → Formatierung/JSON für `{{placeholders}}` |
| `routers/exportdata.py` | `enrich_sessions_with_metrics`, `serialize_dates` |
| `routers/activity.py`, `csv_import.py` | `activity_persistence_orchestrator` (Schreiben) |
| `prompt_executor.execute_prompt_with_data` | ⚠️ teils Roh-SQL parallel zum Data Layer (Legacy) |
### Rollen
Der Data Layer hat **keine eigene Admin-UI**. Konfiguration erfolgt indirekt:
- **Admin:** Training-Parameter, Attributprofile, Referenzwert-Typen, Quality-Filter
- **Nutzer:** Profildaten, Referenzwerte, Focus-Area-Gewichte (beeinflussen Scores)
- **Entwickler:** Neue Funktionen in `data_layer/` nach Extension Guide
---
## Designprinzipien
### 1. Single Source of Truth für Berechnungen
| | |
|---|---|
| **Prinzip** | Jede Metrik wird **einmal** in `data_layer/` berechnet; Charts, KI und Export konsumieren dieselbe Funktion. |
| **Begründung** | Verhindert divergierende Zahlen zwischen Dashboard, Analyse und KI-Ausgabe. |
| **Quelle** | Issue #53 Executive Summary; `nutrition_chart_payloads.py` Kommentar „identisch zu GET /api/charts/energy-balance“ |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht alle Pfade migriert (`insights._prepare_template_vars`, `execute_prompt_with_data` Roh-SQL). |
### 2. Layer 1 liefert strukturierte Daten, keine formatierten Strings
| | |
|---|---|
| **Prinzip** | Kern-Metrik-Funktionen geben Dicts mit `float`/`int`/`date` zurück — **keine** Strings mit Einheiten („86,1 kg“). |
| **Begründung** | Formatierung ist konsumentenspezifisch (DE-Locale, Chart-Achsen, KI-Token). |
| **Quelle** | `data_layer/__init__.py` Docstring: „NO FORMATTING. NO STRINGS WITH UNITS.“ |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `placeholder_resolver` und `*_interpretation` Module formatieren teils direkt — Grenze Layer 1/2a nicht überall scharf. |
### 3. Pflicht-Metadaten: confidence + data_points
| | |
|---|---|
| **Prinzip** | Jede Metrik-Funktion liefert mindestens `confidence` (`high`\|`medium`\|`low`\|`insufficient`) und `data_points`. |
| **Begründung** | UI/KI können Datenqualität kommunizieren; Debugging und Monitoring vereinfacht. |
| **Quelle** | `DATA_LAYER_EXTENSION_GUIDE.md` § Pflicht-Felder; `calculate_confidence()` in `utils.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht runtime-validiert; Disziplin per Code-Review. |
### 4. Confidence nach Metrik-Typ und Zeitfenster
| | |
|---|---|
| **Prinzip** | Schwellen unterscheiden `general`, `correlation`, `trend` und Fensterlänge (7d / 28d / 90d). |
| **Begründung** | Korrelationen brauchen mehr Paare; Trends messen Abdeckung (% der Tage). |
| **Quelle** | `data_layer/utils.py` → `calculate_confidence()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Schwellen global hardcodiert, nicht pro Metrik konfigurierbar. |
### 5. Domänen-Module statt Monolith
| | |
|---|---|
| **Prinzip** | Ein Python-Modul pro fachlichem Bereich (`body_metrics`, `nutrition_metrics`, …), max. ~500 Zeilen, dann Split. |
| **Begründung** | Wartbarkeit, klare Ownership, parallele Entwicklung. |
| **Quelle** | `DATA_LAYER_EXTENSION_GUIDE.md` § Modul-Struktur |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Einige Module deutlich >500 Zeilen (Phase-0c-Wachstum). |
### 6. Layer 2b: Chart-Payloads als Adapter
| | |
|---|---|
| **Prinzip** | Chart.js-Strukturen leben in dedizierten `*_chart_payloads.py` / `*_viz.py`, nicht in Metrik-Modulen. |
| **Begründung** | Gleiche Metrik, verschiedene Visualisierungen; API-Endpoints bleiben dünn. |
| **Quelle** | `nutrition_chart_payloads.py`; `routers/charts.py` Imports |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Teilweise noch SQL-Duplikation in Payload-Buildern neben Layer-1-Aufruf. |
### 7. Layer 2a-Hilfe: KI-spezifische Kompaktierung getrennt
| | |
|---|---|
| **Prinzip** | Token-Reduktion für LLM-Kontext (`compact_float_for_prompt`, `compact_json_payload_for_prompts`) ist eigenes Modul, nicht in Metrik-Kern. |
| **Begründung** | KI hat andere Anforderungen als Charts (Präzision vs. Token-Kosten). |
| **Quelle** | `prompt_output_compact.py`; Tests in `tests/test_prompt_output_compact.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nur für KI-Pfad; Charts nutzen eigene Rundung. |
### 8. Import-Grenze: Ingest vs. Interpretation
| | |
|---|---|
| **Prinzip** | CSV-Import macht Mapping + Typkonvertierung + Duplikatlogik — **keine** fachliche Auswertung beim Insert. |
| **Begründung** | Semantik gehört in Layer 1+, sonst versteckte Business-Logik in Import-Adaptern. |
| **Quelle** | `ARCHITECTURE.md` §8; Issue #53 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy-Adapter (Apple-Schlaf-Aggregat, dedizierte Import-Endpoints) noch aktiv. |
### 9. Persistenz-Orchestrator für Schreibpfade
| | |
|---|---|
| **Prinzip** | Alle Schreibwege eines Domänenobjekts (REST, CSV, Legacy) laufen durch **einen** Orchestrator mit Nebenwirkungen (EAV, Evaluation). |
| **Begründung** | Konsistente Duplikat-Erkennung, Registry-Felder, keine divergierenden Insert-Logiken. |
| **Quelle** | `activity_persistence_orchestrator.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Bisher vor allem Aktivität; andere Domänen noch direkt in Routern. |
### 10. Registry als Feld-Kanon (Activity)
| | |
|---|---|
| **Prinzip** | Erlaubte persistierbare Felder für CSV/REST leiten sich aus `module_registry` ab, nicht aus Router-Hardcoding. |
| **Begründung** | Single Source of Truth für Import-Mappings und DB-Updates. |
| **Quelle** | `activity_data_canon.py`, `activity_persistence_orchestrator.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nur Activity vollständig; andere Module noch klassische Spalten-CRUD. |
### 11. EAV-Anreicherung als Read-Layer
| | |
|---|---|
| **Prinzip** | Session-Metriken (EAV) werden beim **Lesen** angereichert (`enrich_sessions_with_metrics`), nicht pro Consumer dupliziert. |
| **Begründung** | Ein Merge-Kanon für Liste, Detail, Export, Platzhalter. |
| **Quelle** | `activity_session_metrics.py`; `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Domänenspezifisch (Training); Muster übertragbar. |
### 12. Scores als composable Layer
| | |
|---|---|
| **Prinzip** | Composite Scores (`scores.py`) kombinieren Domänen-Metriken mit nutzer-spezifischen Focus Weights — keine Score-Logik in Routern. |
| **Begründung** | Goal-Mode-/Focus-abhängige Gewichtung zentral, für KI und Dashboard gleich. |
| **Quelle** | `data_layer/scores.py`; Phase-0b-Fokus-System |
| **Tragfähigkeit** | **mittel–hoch** |
| **Einschränkung** | Eng an Mitai-Zielsystem gekoppelt; Muster „gewichtete Composite Scores“ ist generisch. |
### 13. API-First: Router delegieren, rechnen nicht
| | |
|---|---|
| **Prinzip** | `routers/charts.py` und ähnliche Endpoints rufen Data-Layer-Funktionen auf und mappen auf HTTP — keine Trend-Berechnung im Router. |
| **Begründung** | Testbarkeit; Frontend ohne Business-Logik. |
| **Quelle** | `ARCHITECTURE.md` §1.2 API-First |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `charts.py` ist groß (2246+ Zeilen) — viel Adapter-Code, aber Berechnung delegiert. |
### 14. serialize_dates / safe_float als Querschnitt
| | |
|---|---|
| **Prinzip** | JSON/API-Serialisierung (Dates, Decimal) zentral in `utils.py`, nicht pro Modul neu erfunden. |
| **Begründung** | PostgreSQL-Typen (DATE, DECIMAL) konsistent für API und Export. |
| **Quelle** | `data_layer/utils.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 15. Extension Guide als verbindlicher Entwicklungsvertrag
| | |
|---|---|
| **Prinzip** | Neue Metriken folgen Template (Retrieve → Confidence → Early Return → Calculate → Return) und werden in `__init__.py` exportiert. |
| **Begründung** | Einheitliche Struktur für 97+ Funktionen und wachsende Codebase. |
| **Quelle** | `DATA_LAYER_EXTENSION_GUIDE.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Guide und Ist-Code divergieren teils (Modulgröße, `goals.py` noch nicht in `__init__`). |
---
## Nicht übernehmen
Muster, die sich nicht bewährt haben oder zu produktspezifisch sind:
1. **Berechnungslogik in `placeholder_resolver.py`** — Phase-0b-Legacy; Resolver soll nur formatieren/aggregieren, nicht rechnen.
2. **Paralleler Roh-SQL-Kontext in `prompt_executor.execute_prompt_with_data`** — lädt Modul-Rohdaten per SQL, obwohl Layer 1 existiert; zweite Wahrheit.
3. **Legacy Insights-Pfad (`insights._prepare_template_vars`)** — eigene Variablen-Vorbereitung ohne Data Layer.
4. **Import mit fachlicher Interpretation** — Apple-Schlaf-Aggregat und ähnliche Adapter verstecken Semantik im Ingest (Gitea #69).
5. **Monolithische Router mit Inline-Berechnung** — vor Phase 0c; gelegentlich noch Reste in nicht migrierten Pfaden.
6. **Interpretation vermischt mit Layer 1** — `*_interpretation.py` liefert teils fertige Texte; für Familien-Architektur klar als Layer 2a/2b markieren oder auslagern.
7. **SQL-Duplikation in Chart-Payloads** — manche Payload-Builder führen eigene Queries statt ausschließlich Layer-1-Ergebnisse zu visualisieren.
8. **Hardcodierte Confidence global** — funktioniert, aber nicht pro Metrik/Domäne konfigurierbar; Skalierung in Multi-Tenant-Produktfamilie prüfen.
9. **Domänen-Module als Produktinhalt** — `body_metrics`, TDEE, WHR etc. sind Mitai-spezifisch; **Schichtenmodell** übernehmen, **Formeln** nicht blind kopieren.
10. **Fehlende runtime-Validierung des Return-Schemas** — `confidence`/`data_points` per Konvention, nicht per TypedDict/Pydantic erzwungen.
11. **Uneinheitliche Schreib-Orchestrierung** — nur Activity hat `persistence_orchestrator`; andere Domänen noch fragmentiert.
12. **Riesige Einzeldateien** — einige Metrik-Module >>500 Zeilen widersprechen eigenem Extension Guide.
---
## Modul-Inventar (Ist-Stand)
```
backend/data_layer/
├── Kern-Metriken (Layer 1)
│ ├── body_metrics.py
│ ├── nutrition_metrics.py
│ ├── activity_metrics.py
│ ├── recovery_metrics.py
│ ├── health_metrics.py
│ ├── scores.py
│ └── correlations.py
├── Visualisierung (Layer 2b)
│ ├── *_chart_payloads.py (nutrition, recovery, correlation)
│ └── *_viz.py (body, nutrition, fitness, recovery, history_overview)
├── KI / Format (Layer 2a-Nähe)
│ ├── prompt_output_compact.py
│ └── *_interpretation.py
├── Persistenz / EAV
│ ├── activity_persistence_orchestrator.py
│ ├── activity_session_metrics.py
│ └── activity_data_canon.py
├── Querschnitt
│ ├── utils.py
│ ├── reference_values.py
│ └── nutrition_body_merge.py
└── __init__.py (Exports)
```
**Konsumenten-Endpoints (Auswahl):** 20+ Chart-Endpoints in `routers/charts.py` (E1–E5, A1–A8, R1–R5, C1–C4).
---
## Verwandte Dokumentation
- Issue #53 Abschluss: [issue-53-phase-0c-multi-layer-architecture.md](../../../../docs/issues/issue-53-phase-0c-multi-layer-architecture.md)
- Extension Guide: [DATA_LAYER_EXTENSION_GUIDE.md](../../technical/DATA_LAYER_EXTENSION_GUIDE.md)
- Fachliche Datenarchitektur: [DATA_ARCHITECTURE.md](../../functional/DATA_ARCHITECTURE.md)
- Import-Grenze: [ARCHITECTURE.md](../../../rules/ARCHITECTURE.md) §8
- Platzhalter-Anbindung: [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md)
- Prompt Engine (Konsument Layer 2a): [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
- Feature & Entitlement: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
---
## Geplante Folgedokumente (Serie)
| # | Modul | Datei (geplant) |
|---|-------|-----------------|
| 1 | Prompt Engine | ✅ `PROMPT_ENGINE_DESIGN_PRINCIPLES.md` |
| 2 | Data Layer | ✅ dieses Dokument |
| 3 | Feature & Entitlement System | ✅ `FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md` |
| 4 | Registry-/Plugin-Muster | ✅ `REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md` |
| 5 | Auth & Session | ✅ `AUTH_SESSION_DESIGN_PRINCIPLES.md` |
| 6 | Universal Import | ✅ `UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md` |
| 7 | Dashboard Widgets | ✅ `DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md` |
| 8 | Navigation / IA | ✅ `NAVIGATION_IA_DESIGN_PRINCIPLES.md` |
| 9 | Migration & Deploy | ✅ `MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md` |

View File

@ -0,0 +1,370 @@
# Feature & Entitlement System – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Membership-, Tier- und Feature-Limit-System (v9c) — keine Mitai-Domänenlogik, kein zentrales SSO/Stripe (Vision)
**Serie:** Designprinzipien für Produktfamilie · Dokument 3 von n
**Vorgänger:** [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Entitlement-Auflösung | `backend/auth.py` (`get_effective_tier`, `check_feature_access`, `increment_feature_usage`) |
| Monitoring | `backend/feature_logger.py` |
| Nutzer-API | `backend/routers/subscription.py`, `backend/routers/features.py` |
| Admin | `routers/tiers_mgmt.py`, `tier_limits.py`, `coupons.py`, `access_grants.py`, `user_restrictions.py` |
| Widget-Gating | `backend/dashboard_widget_entitlements.py`, `widget_feature_requirements_db.py` |
| Frontend | `UsageBadge.jsx`, Feature-Usage in Seiten (z. B. `Analysis.jsx`, `WeightPage`) |
| Doku | `MEMBERSHIP_SYSTEM.md`, `FEATURE_ENFORCEMENT.md`, `CENTRAL_SUBSCRIPTION_SYSTEM.md` (Vision) |
---
## Modul
**Feature & Entitlement System** (Membership v9c)
Zentrale Schicht für **„Darf dieser Nutzer diese Funktion wie oft nutzen?“** — unabhängig von Auth (Identität) und unabhängig von fachlicher Business-Logik in Routern.
---
## Fachliche Verantwortung
Das System übernimmt:
1. **Feature-Registry** — Deklarative Liste aller limitierbaren Produktfunktionen mit Metadaten.
2. **Tier-Auflösung** — Effektiver Tarif eines Profils (Basis-Tier + zeitliche Grants).
3. **Limit-Auflösung** — Pro Feature: Override → Tier-Limit → Feature-Default.
4. **Usage-Tracking** — Zähler für Count-Features mit optionalem Reset (daily/monthly/never).
5. **Enforcement** — HTTP 403 bei Überschreitung; Frontend-Vorschaum via Badges.
6. **Beobachtbarkeit** — Strukturiertes JSON-Logging aller Access-Checks.
7. **Promotionen** — Coupons → Access Grants (temporäre Tier-Elevation, Pause/Resume).
Es übernimmt **nicht**:
- Login, Session, Passwort (Auth-Modul)
- Zahlungsabwicklung / Stripe (geplant, `CENTRAL_SUBSCRIPTION_SYSTEM.md`)
- Mandanten-Isolation (Org/Workspace) — Entitlements sind **profile-scoped**
- Inhaltliche Berechtigung pro Datensatz (nur Feature-Gates)
### Zwei Entscheidungsebenen
| Ebene | Frage | Funktion |
|-------|--------|----------|
| **Tier** | Welcher Tarif gilt? | `get_effective_tier()` |
| **Feature** | Darf Feature X genutzt werden (wie oft)? | `check_feature_access()` |
Tier beeinflusst Feature-Limits über `tier_limits`; User-Overrides können Limits unabhängig vom Tier setzen.
### Administrierte Konfigurationen
| Konfiguration | Speicherort | Admin-UI |
|---------------|-------------|----------|
| Feature-Definitionen | `features` | Admin Features |
| Tier-Stufen | `tiers` | Admin Tiers |
| Tier × Feature Matrix | `tier_limits` | Admin Tier Limits |
| User-Overrides | `user_feature_restrictions` | Admin User Restrictions |
| Coupons | `coupons`, `coupon_redemptions` | Admin Coupons |
| Temporäre Tier-Grants | `access_grants` | (via Coupon/Admin) |
| Widget → Feature Mapping | `widget_feature_requirements`, Katalog | Admin Widget Features |
| Usage-Zähler | `user_feature_usage` | (automatisch) |
**Nicht hardcodiert:** Limits pro Tier, Feature-Metadaten, Coupon-Parameter, User-Overrides.
**Hardcodiert (Code):** Feature-IDs in Routern (`'ai_calls'`, `'weight_entries'`, …), Reset-Berechnung, 4-Phasen-Muster, 11 initial registrierte Features.
### Auflösungs-Hierarchien
**Effektiver Tier** (`get_effective_tier`):
1. Aktiver `access_grants`-Eintrag (`is_active`, `valid_from`/`valid_until`)
2. Fallback: `profiles.tier`
**Feature-Limit** (`check_feature_access` → `_check_impl`):
1. `user_feature_restrictions.limit_value` (höchste Priorität)
2. `tier_limits` für effektiven Tier
3. `features.default_limit`
**Limit-Semantik:**
| `limit_type` | Bedeutung |
|--------------|-----------|
| `count` | Zählbares Kontingent; `used < limit` |
| `boolean` | An/Aus; `limit == 1` erlaubt, `0` gesperrt |
| `limit_value` | Bedeutung |
|---------------|-----------|
| `NULL` | Unbegrenzt |
| `0` | Deaktiviert |
| `> 0` | Kontingent oder Boolean „an“ |
### Rollen
| Rolle | Darf |
|-------|------|
| **Admin** | Features/Tiers/Limits/Coupons/Restrictions CRUD; alle Nutzer-Overrides |
| **Nutzer** | Eigene Subscription/Usage lesen (`/subscription/me`, `/features/usage`); keine Limit-Änderung |
Enforcement gilt für alle authentifizierten Nutzer gleich — Admins haben keine automatische Bypass-Logik in `check_feature_access`.
### Versionierung, Freigabe, Test
| Mechanismus | Status |
|-------------|--------|
| 4-Phasen-Rollout (Monitor → UI → Enforce) | ✅ dokumentiert & angewendet |
| JSON-Log `feature-usage.log` | ✅ Phase 2 Monitoring |
| DB-Migration v9c für Schema | ✅ |
| Automatisierte Enforcement-Tests pro Router | ⚠️ teilweise (Widgets getestet) |
| Zentrale Policy „jeder Endpoint muss checken“ | ❌ nicht erzwungen |
---
## Designprinzipien
### 1. Feature-Registry statt hardcodierter Limits
| | |
|---|---|
| **Prinzip** | Jedes limitierbare Produkt-Feature ist Zeile in `features` — neue Features ohne Schema-Migration für Limits. |
| **Begründung** | Admin-UI, Usage-API und Backend-Checks teilen dieselbe ID und Metadaten. |
| **Quelle** | `MEMBERSHIP_SYSTEM.md` § Feature-Registry; `routers/features.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Feature-IDs müssen trotzdem in Router-Code referenziert werden. |
### 2. Eine Auflösungsfunktion für Entitlements
| | |
|---|---|
| **Prinzip** | Alle Backend- und Widget-Checks rufen `check_feature_access(profile_id, feature_id)` auf. |
| **Begründung** | Keine duplizierte Tier/Limit-Logik in Routern, Widgets oder Frontend. |
| **Quelle** | `auth.py`; `dashboard_widget_entitlements.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht alle Endpoints nutzen es (z. B. `/prompts/execute` fehlt). |
### 3. Getrennte Tier- und Feature-Auflösung
| | |
|---|---|
| **Prinzip** | `get_effective_tier()` für Tarif; `check_feature_access()` für konkretes Feature — Tier ist Input, nicht Output der Feature-Prüfung. |
| **Begründung** | Temporäre Grants heben Tier an; User-Override kann einzelnes Feature unabhängig anpassen. |
| **Quelle** | `auth.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `get_effective_tier` im Code einfacher als in `MEMBERSHIP_SYSTEM.md` (kein `tier_locked`, Trial nicht in Tier-Funktion). |
### 4. Prioritäts-Kette für Limits
| | |
|---|---|
| **Prinzip** | User-Override > Tier-Limit > Feature-Default — explizit und dokumentiert. |
| **Begründung** | Support/Beta-Fälle ohne Tier-Wechsel; vorhersehbares Verhalten. |
| **Quelle** | `_check_impl()` in `auth.py`; `MEMBERSHIP_SYSTEM.md` § Zugriffs-Hierarchie |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `user_feature_restrictions.enabled` im Schema, aber nicht in `_check_impl` ausgewertet. |
### 5. Count vs. Boolean als zwei Feature-Klassen
| | |
|---|---|
| **Prinzip** | Zählbare Aktionen (`count` + Usage) vs. Schalter-Features (`boolean`, kein Counter). |
| **Begründung** | Pipeline-An/Aus vs. monatliche KI-Calls — unterschiedliche UX und Backend-Logik. |
| **Quelle** | `features.limit_type`; `FEATURE_ENFORCEMENT.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Boolean-Features nutzen `limit_value` 0/1 — leicht mit Count zu verwechseln. |
### 6. Reset-Perioden für Count-Features
| | |
|---|---|
| **Prinzip** | `reset_period`: `never` \| `daily` \| `monthly` — Counter-Reset in `check_feature_access` bei abgelaufenem `reset_at`. |
| **Begründung** | Monats-Kontingente vs. Lifetime-Limits in einem Modell. |
| **Quelle** | `auth.py` → `_calculate_next_reset()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Reset beim Check, nicht per Cron — Edge Cases bei seltenem Zugriff. |
### 7. Usage nur bei neuen Entitäten incrementieren
| | |
|---|---|
| **Prinzip** | `increment_feature_usage()` nur nach **INSERT**, nicht nach UPDATE/Upsert-Deduplikat. |
| **Begründung** | Limits messen „neue Nutzung“, nicht Bearbeitung bestehender Daten. |
| **Quelle** | `FEATURE_ENFORCEMENT.md` § Wichtige Regeln |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Bulk-Import muss explizit zählen; Fehler anfällig. |
### 8. Vier-Phasen-Rollout (Observe before Enforce)
| | |
|---|---|
| **Prinzip** | Phase 1 Cleanup → 2 Logging → 3 Frontend-Badges → 4 HTTP 403. |
| **Begründung** | Limits einführen ohne blind Nutzer zu blockieren; Daten für Limit-Kalibrierung. |
| **Quelle** | `FEATURE_ENFORCEMENT.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Disziplin pro Feature; kein zentraler Feature-Flag pro Endpoint-Phase. |
### 9. Strukturiertes Feature-Logging
| | |
|---|---|
| **Prinzip** | Jeder Check: `log_feature_usage(profile_id, feature_id, access, action)` → JSON in `feature-usage.log`. |
| **Begründung** | Audit, Debugging, Kalibrierung — auch wenn noch nicht enforced. |
| **Quelle** | `feature_logger.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Log-Pfad `/app/logs` container-spezifisch. |
### 10. Defense in Depth: API 403 + Frontend-Gate
| | |
|---|---|
| **Prinzip** | Backend blockiert autoritativ; Frontend zeigt `UsageBadge`, deaktiviert Buttons, Tooltip bei Limit. |
| **Begründung** | UX (frühes Feedback) + Sicherheit (API nicht umgehbar via curl). |
| **Quelle** | `FEATURE_ENFORCEMENT.md`; `UsageBadge.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Frontend-Gate optional pro Seite; nicht generisch erzwungen. |
### 11. Nutzer-Usage-API ohne Code-Änderung bei neuen Features
| | |
|---|---|
| **Prinzip** | `GET /features/usage` iteriert alle aktiven `features` und ruft `check_feature_access` pro Zeile. |
| **Begründung** | Neues DB-Feature erscheint automatisch in Quota-Übersicht. |
| **Quelle** | `routers/features.py` → `get_feature_usage()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Frontend muss Feature-ID kennen, um Badge zu binden. |
### 12. Access Grants für temporäre Tier-Elevation
| | |
|---|---|
| **Prinzip** | Coupons/Admin erzeugen `access_grants`; effektiver Tier steigt zeitlich begrenzt. |
| **Begründung** | Promotions, Partner (Wellpass), Trials ohne permanente Tier-Änderung. |
| **Quelle** | `access_grants`; `routers/coupons.py` (Pause/Resume) |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Coupon-Stacking-Logik komplex; dokumentiert vs. Code prüfen bei Neuentwicklung. |
### 13. Entitlements als Querschnitt für UI-Module (Widgets)
| | |
|---|---|
| **Prinzip** | Dashboard-Widgets mappen auf `features.id`; Katalog liefert `allowed` pro Profil. |
| **Begründung** | Tier-Logik nicht in React-Widgets duplizieren (`DASHBOARD_WIDGETS_AGENT_GUIDE` §0). |
| **Quelle** | `dashboard_widget_entitlements.py`; `ARCHITECTURE.md` §9 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Widget-Sichtbarkeit ≠ API-Schutz — Chart-Endpoints brauchen eigenes Gating (A4). |
### 14. Admin-konfigurierbare Tier × Feature Matrix
| | |
|---|---|
| **Prinzip** | `tier_limits` trennt Tier-Definition von Limits; Tiers ohne hardcodierte Spalten pro Feature. |
| **Begründung** | Neue Tiers/Preise ohne Code-Deploy der Limit-Logik. |
| **Quelle** | `MEMBERSHIP_SYSTEM.md` § Tier-System; `tier_limits` Tabelle |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Tier-Namen in Seed-Daten (`free`, `premium`, …) — erweiterbar, aber Konvention. |
### 15. NULL = unlimited, 0 = disabled
| | |
|---|---|
| **Prinzip** | Einheitliche Semantik für Limit-Werte in allen Schichten. |
| **Begründung** | Vermeidet Sonderfälle „-1 means unlimited“; klare Admin-UI. |
| **Quelle** | `_check_impl()` in `auth.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | SQL NULL vs. Python None — konsistent, aber in UI erklärungsbedürftig. |
---
## Registrierte Features (Referenz)
| Feature ID | Typ | Reset | Typische Aktion |
|------------|-----|-------|-----------------|
| `weight_entries` | count | never | Gewicht anlegen |
| `circumference_entries` | count | never | Umfang anlegen |
| `caliper_entries` | count | never | Caliper anlegen |
| `activity_entries` | count | monthly | Training anlegen/import |
| `nutrition_entries` | count | monthly | Ernährung anlegen/import |
| `photos` | count | monthly | Foto hochladen |
| `ai_calls` | count | monthly | KI-Analyse |
| `ai_pipeline` | boolean | — | Pipeline-Analyse |
| `data_export` | count | monthly | Export/PDF |
| `data_import` | count | monthly | ZIP/Universal-Import |
**Enforcement-Lücken (Ist):** `routers/prompts.py` (`/execute`, `/execute-stream`) ohne `check_feature_access` — Legacy `insights.py` hat Enforcement für `ai_calls`/`ai_pipeline`.
---
## Nicht übernehmen
1. **Dokumentations-Drift** — `MEMBERSHIP_SYSTEM.md` („Enforcement deaktiviert“) vs. `FEATURE_ENFORCEMENT.md` (Phase 4 komplett) vs. Ist-Code; bei Neuentwicklung einen Kanon festlegen.
2. **Unvollständige Tier-Auflösung** — Doku beschreibt `tier_locked`, Trial-in-Tier; Code nutzt primär Grants + `profiles.tier`. Trial (`trial_ends_at`) eher UI-Banner als Tier-Engine.
3. **Feature-IDs in Routern verstreut** — kein zentraler Endpoint-Registry-Eintrag „welcher Router prüft welches Feature“.
4. **Check und Increment nicht atomar** — Race bei parallelen Requests möglich; kein DB-Level Locking.
5. **Legacy Profil-Spalten parallel** — `ai_enabled`, `ai_limit_day`, `export_enabled` in Sessions-Query neben Feature-System.
6. **Frontend ohne Backend-Gate** — reine UI-Deaktivierung ohne 403 ist unsicher (manche Seiten nur teilweise umgesetzt).
7. **Boolean via limit_value 0/1** — funktioniert, aber für Familien-Architektur explizites `enabled`-Flag oder Capability-Tokens erwägen.
8. **Unused Schema-Felder** — `user_feature_restrictions.enabled` nicht in Auflösung eingebunden.
9. **App-lokales Abo ohne Zahlungsanbindung** — Stripe/SSO nur Vision (`CENTRAL_SUBSCRIPTION_SYSTEM.md`); nicht als fertiges Familien-Muster übernehmen.
10. **Profile als Entitlement-Subject** — kein Org/Mandant; Multi-App-Familie braucht separates Identity/Subscription-Boundary.
11. **Self-hosted Tier als Sonderfall** — `selfhosted` ist Deploy-Modell, kein generisches SaaS-Tier-Muster.
12. **Increment-Schleifen bei Bulk** — `for _ in range(new_entries): increment_feature_usage()` — ineffizient; batch-Inkrement besser.
---
## Modul-Inventar (Ist-Stand)
```
backend/
├── auth.py # get_effective_tier, check_feature_access, increment_feature_usage
├── feature_logger.py # JSON-Logging
├── dashboard_widget_entitlements.py # Widget allowed + Layout-Sanitisierung
├── widget_feature_requirements_db.py
└── routers/
├── subscription.py # /me, /usage, /limits (Nutzer)
├── features.py # Admin CRUD + /usage, /check-access
├── tiers_mgmt.py, tier_limits.py
├── coupons.py, access_grants.py
└── user_restrictions.py
frontend/src/components/
└── UsageBadge.jsx # Quota-Anzeige (Phase 3)
```
**DB (v9c):** `features`, `tiers`, `tier_limits`, `user_feature_restrictions`, `user_feature_usage`, `coupons`, `coupon_redemptions`, `access_grants`, `user_activity_log`
---
## Verwandte Dokumentation
- Membership-Detail: [MEMBERSHIP_SYSTEM.md](../../technical/MEMBERSHIP_SYSTEM.md)
- Enforcement-Howto: [FEATURE_ENFORCEMENT.md](../../architecture/FEATURE_ENFORCEMENT.md)
- Vision Produktfamilie: [CENTRAL_SUBSCRIPTION_SYSTEM.md](../../technical/CENTRAL_SUBSCRIPTION_SYSTEM.md)
- Widget-Gating: [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md) §0
- Prompt Engine (Enforcement-Lücke): [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
---
## Geplante Folgedokumente (Serie)
| # | Modul | Status |
|---|-------|--------|
| 1 | Prompt Engine | ✅ |
| 2 | Data Layer | ✅ |
| 3 | Feature & Entitlement | ✅ dieses Dokument |
| 4 | Registry-/Plugin-Muster | ✅ |
| 5 | Auth & Session | ✅ |
| 6 | Universal Import | ✅ |
| 7 | Dashboard Widgets | ✅ |
| 8 | Navigation / IA | ✅ |
| 9 | Migration & Deploy | ✅ `MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md` |

View File

@ -0,0 +1,369 @@
# Migration & Deploy – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** DB-Migrationen, Container-Startup, CI/CD-Deploy — keine Anwendungsdomäne
**Serie:** Designprinzipien für Produktfamilie · Dokument 9 von n (Abschluss)
**Vorgänger:** [NAVIGATION_IA_DESIGN_PRINCIPLES.md](./NAVIGATION_IA_DESIGN_PRINCIPLES.md)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| DB-Init & Migrationen | `backend/db_init.py`, `backend/startup.sh` |
| Migrationen | `backend/migrations/XXX_*.sql` |
| Basis-Schema (Greenfield) | `backend/schema.sql` |
| Tracking | Tabelle `schema_migrations` |
| Compose Prod/Dev | `docker-compose.yml`, `docker-compose.dev-env.yml` |
| CI/CD | `.gitea/workflows/deploy-dev.yml`, `deploy-prod.yml`, `test.yml` |
| Versionierung | `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) |
| Doku (operativ) | `MIGRATIONS.md` |
| Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` §2, §7 |
---
## Modul
**Migration & Deploy**
Automatische **PostgreSQL-Schema-Evolution** beim Container-Start plus **Git-getriebene Deploy-Pipeline** (develop → Dev, main → Prod) auf selbst-gehosteter Infrastruktur (Docker auf Raspberry Pi).
---
## Fachliche Verantwortung
Das Modul übernimmt:
1. **Schema-Migrationen** — Nummerierte SQL-Dateien, idempotent wo möglich, getrackt in `schema_migrations`.
2. **Startup-Orchestrierung** — Postgres ready → Schema/Migrationen → optional SQLite-Import → Uvicorn.
3. **Umgebungstrennung** — Dev (`3099`/`8099`) vs. Prod (`3002`/`8002`), getrennte DBs/Volumes.
4. **Deploy-Automatisierung** — Push auf Branch → Runner → `git reset --hard` → `docker compose build --no-cache` → Health-Check.
5. **Post-Deploy-Tests** — Pytest/Lint/Frontend-Build gegen **deployed** Container auf dem Runner.
6. **Versions-Metadaten** — App-/Modul-Version und dokumentierte `DB_SCHEMA_VERSION`.
Es übernimmt **nicht**:
- Fachliche Datenberechnungen (→ Data Layer)
- Automatisches Downgrade/Rollback von Schema
- Blue-Green oder Multi-Region-Deploy
### Deploy-Pipeline (Happy Path)
```
Entwickler: commit → push develop
→ Gitea Runner: deploy-dev.yml
→ cd /home/lars/docker/bodytrack-dev
→ git fetch + reset --hard origin/develop
→ docker compose -f docker-compose.dev-env.yml build --no-cache && up -d
→ backend startup.sh → db_init.py (Migrationen)
→ curl localhost:8099/api/auth/status
→ test.yml (push + nach Deploy): pytest im Container, py_compile, npm run build
Prod: PR develop → main → deploy-prod.yml (Port 8002, bodytrack/)
```
---
## Administrierte vs. code-definierte Konfiguration
| Konfiguration | Speicherort | Wer pflegt? |
|---------------|-------------|-------------|
| Migration-SQL | `backend/migrations/` | Entwickler |
| Welche Migrationen angewendet | `schema_migrations` (DB) | Automatisch |
| Greenfield-Basis | `schema.sql` | Entwickler (selten) |
| Compose/Ports/Env | `docker-compose*.yml`, `.env` auf Server | Betrieb |
| Deploy-Workflow | `.gitea/workflows/*.yml` | Entwickler |
| App-Version / Changelog | `backend/version.py` | Entwickler (pro Release) |
| Prod-Geheimnisse | Server-`.env`, nicht im Repo | Betrieb |
---
## Designprinzipien
### 1. Migrationen beim Container-Start ( nicht manuell in Prod)
| | |
|---|---|
| **Prinzip** | `startup.sh` ruft `db_init.py` auf **bevor** Uvicorn startet; pending Migrationen werden automatisch angewendet. |
| **Begründung** | Kein vergessenes Schema-Update; Deploy und DB-Stand bleiben gekoppelt. |
| **Quelle** | `startup.sh`, `db_init.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Fehlgeschlagene Migration blockiert API-Start (`sys.exit(1)`). |
### 2. Nummeriertes Datei-Pattern als Gate
| | |
|---|---|
| **Prinzip** | Nur `\d{3}_*.sql` wird ausgeführt (z. B. `054_activity_session_metrics_eav.sql`); alles andere wird ignoriert. |
| **Begründung** | Sortierbare Reihenfolge; Ad-hoc-Skripte (`check_features.sql`, `v9c_*.sql`) verunreinigen nicht den Lauf. |
| **Quelle** | `run_migrations()` Regex |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy-Dateien ohne Nummer liegen noch im Ordner (historischer Ballast). |
### 3. Tracking-Tabelle als Single Source of „applied“
| | |
|---|---|
| **Prinzip** | `schema_migrations(filename)` — jede erfolgreiche Datei genau einmal eingetragen; Pending = Dateien minus Applied. |
| **Begründung** | Idempotenter Startup; wiederholter Container-Start wendet nichts doppelt an. |
| **Quelle** | `ensure_migration_table`, `apply_migration` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein checksum — geänderte Datei nach Apply wird nicht erneut ausgeführt. |
### 4. Alphabetische Reihenfolge = Migrations-Reihenfolge
| | |
|---|---|
| **Prinzip** | `sorted(glob)` — dreistellige Präfixe (`001`, `054`, `061`) definieren die Apply-Order. |
| **Begründung** | Einfach, git-freundlich, keine separate Migrations-Registry. |
| **Quelle** | `run_migrations()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nummern-Kollisionen oder nachträgliches Einfügen erfordern Disziplin (immer nächste freie Nummer). |
### 5. Fail-Fast bei Migrationsfehler
| | |
|---|---|
| **Prinzip** | Schlägt eine Migration fehl → kein Commit in Tracking (bei Exception vor INSERT), Prozess exit 1, Container unhealthy. |
| **Begründung** | API läuft nicht mit halb angewendetem Schema. |
| **Quelle** | `apply_migration`, `main` in `db_init.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Manueller Recovery-Prozess nötig (siehe MIGRATIONS.md Rollback). |
### 6. Greenfield: schema.sql, Bestand: nur Migrationen
| | |
|---|---|
| **Prinzip** | Existiert `profiles` nicht → einmalig `schema.sql` laden; danach nur noch nummerierte Migrationen. |
| **Begründung** | Frische Instanz schnell bootstrapped; langlebige DBs evolvieren incremental. |
| **Quelle** | `check_table_exists`, `load_schema` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `schema.sql` kann hinter Migrationen zurückfallen wenn nicht gepflegt. |
### 7. Idempotente DDL bevorzugen
| | |
|---|---|
| **Prinzip** | `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, defensive UPDATEs — Migration soll mehrfach ausführbar sein ohne Schaden. |
| **Begründung** | Recovery nach partiellem Apply; manuelles Re-Run sicherer. |
| **Quelle** | `MIGRATIONS.md` Best Practices |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht alle Änderungen sind idempotent (DROP, irreversible Datenmigration). |
### 8. Kein psql-Meta in Migrationsdateien
| | |
|---|---|
| **Prinzip** | Nur SQL — kein `\echo`, `\i`, `\connect`; Ausführung via psycopg2, nicht interaktiv. |
| **Begründung** | Parser/Runner versteht nur SQL-Statements. |
| **Quelle** | `MIGRATIONS.md`, `apply_migration` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 9. Schema-Änderung = nummerierte Migration (nie ad-hoc in Prod)
| | |
|---|---|
| **Prinzip** | Neue Tabellen/Spalten **nur** via `backend/migrations/XXX_*.sql`; nicht direkt in laufender Prod-DB editieren. |
| **Begründung** | Reproduzierbarkeit Dev→Prod; Review im Git-Diff. |
| **Quelle** | ARCHITECTURE.md, CLAUDE.md |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Agent-Regel — technisch nicht erzwungen. |
### 10. DB_SCHEMA_VERSION als dokumentierter Marker
| | |
|---|---|
| **Prinzip** | `backend/version.py` → `DB_SCHEMA_VERSION` bei Schema-Änderung manuell bumpen (Format z. B. `YYYYMMDD` + Suffix). |
| **Begründung** | API `/api/version` und Changelog zeigen Schema-Stand unabhängig von App-Minor. |
| **Quelle** | ARCHITECTURE.md §2.6 |
| **Tragfähigkeit** | **mittel–hoch** |
| **Einschränkung** | Nicht automatisch aus `schema_migrations` abgeleitet — Drift möglich. |
### 11. Branch → Umgebung (develop / main)
| | |
|---|---|
| **Prinzip** | `develop` → Dev-Deploy automatisch; `main` → Prod-Deploy automatisch; Prod nur nach expliziter Freigabe/Merge. |
| **Begründung** | Klare Promotion; Dev als Integrationsumgebung. |
| **Quelle** | Workflows, CLAUDE.md Deployment |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein Staging-Branch zwischen Dev und Prod. |
### 12. Deploy-Arbeitskopie = exakt Remote-Branch
| | |
|---|---|
| **Prinzip** | Runner: `git fetch` + `git reset --hard origin/<branch>` — keine `pull`-Merge-Konflikte, kein schmutziger `package-lock` auf dem Pi. |
| **Begründung** | Reproduzierbarer Deploy-Baum; Fix aus GUI-IA-Abnahme 2026-04-05. |
| **Quelle** | `deploy-prod.yml`, `deploy-dev.yml` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Lokale Hotfixes auf dem Server werden beim Deploy überschrieben. |
### 13. Immutabler Build pro Deploy (`--no-cache`)
| | |
|---|---|
| **Prinzip** | `docker compose build --no-cache` bei jedem Deploy — frisches Image aus Dockerfile + Repo-Stand. |
| **Begründung** | Keine veralteten Layer; Migrationen und Code garantiert im Image. |
| **Quelle** | Deploy-Workflows |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Langsamere Deploys; kein Registry-basiertes Image-Promotion. |
### 14. Health-Check nach Deploy
| | |
|---|---|
| **Prinzip** | Nach `up -d`: kurz warten, dann `curl -sf …/api/auth/status` (8099 Dev / 8002 Prod). |
| **Begründung** | Minimale Smoke-Verification dass API antwortet (inkl. DB-Init durchlaufen). |
| **Quelle** | Deploy-Workflows |
| **Tragfähigkeit** | **mittel–hoch** |
| **Einschränkung** | Prüft nicht fachliche Endpoints oder Migration-Inhalt. |
### 15. Persistente Volumes für Daten und Fotos
| | |
|---|---|
| **Prinzip** | Postgres-Daten, `/app/data`, `/app/photos` in benannten/external Volumes — überleben Container-Rebuild. |
| **Begründung** | Deploy = neues Image, nicht Datenverlust. |
| **Quelle** | `docker-compose*.yml` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Volume-Backup/Restore ist Betriebsaufgabe außerhalb Repo. |
### 16. Postgres Healthcheck vor Backend-Start
| | |
|---|---|
| **Prinzip** | `depends_on: condition: service_healthy` — Backend startet erst wenn DB `pg_isready`. |
| **Begründung** | `wait_for_postgres` in db_init ist zweite Absicherung; reduziert Race beim ersten Start. |
| **Quelle** | Compose-Files |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 17. Tests gegen deployed Stack (Self-Hosted Runner)
| | |
|---|---|
| **Prinzip** | `test.yml` führt pytest **im laufenden Backend-Container** auf dem Pi aus, nicht in isolierter GitHub-Cloud. |
| **Begründung** | Tests laufen gegen echte Dev/Prod-Compose-Umgebung des Projekts. |
| **Quelle** | `.gitea/workflows/test.yml` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Prod-Deploy triggert Tests auf Prod-Pfad — Risiko wenn Tests schreibend; `-m 'not slow'` begrenzt Laufzeit. |
### 18. Feste Ports pro Umgebung
| | |
|---|---|
| **Prinzip** | Dev `3099/8099`, Prod `3002/8002` — nicht ändern (Reverse Proxy/Fritz!Box hängen daran). |
| **Begründung** | Externe URLs (`dev.mitai.jinkendo.de`, `mitai.jinkendo.de`) stabil. |
| **Quelle** | CLAUDE.md, Compose |
| **Tragfähigkeit** | **hoch** (betriebsspezifisch) |
| **Einschränkung** | Andere Projekte brauchen eigene Port-Matrix. |
### 19. Prod-Schutz: Deploy nur über Git
| | |
|---|---|
| **Prinzip** | Keine direkten Prod-Container-/DB-Schreibzugriffe für Automation; Prod-Änderung = Merge `main` → Workflow. |
| **Begründung** | Audit-Trail, Review, keine Drift. |
| **Quelle** | ARCHITECTURE.md §7.1, `/deploy` Command |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Menschlicher SSH-Zugriff bleibt möglich — Prozess, nicht Technik. |
### 20. Versions-Bump als Release-Disziplin
| | |
|---|---|
| **Prinzip** | Jede lieferbare Änderung: `APP_VERSION`, betroffene `MODULE_VERSIONS`, `CHANGELOG` in `version.py`; bei Schema auch `DB_SCHEMA_VERSION`. |
| **Begründung** | `/api/version`, Support, Korrelation Deploy ↔ Code. |
| **Quelle** | ARCHITECTURE.md §2.5, `deploy.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Frontend-`version.js` in Spec erwähnt, im Repo teils nicht vorhanden — Dual-Bump unvollständig. |
---
## Nicht übernehmen
1. **Unnummerierte Migrationsdateien** — `v9c_*.sql`, `check_*.sql` werden nicht auto-applied; nicht als Vorbild.
2. **Migration-Datei nach Apply ändern** — Tracking verhindert Re-Run; neue Nummer statt Edit.
3. **Automatischer Downgrade** — nicht implementiert; Rollback manuell + Tracking-Eintrag löschen.
4. **Direktes Schema in Prod** — immer Git-Migration + Deploy.
5. **Breaking DROP ohne Koordination** — App-Code und Migration in einem Release.
6. **psql-Metacommands in `.sql`** — bricht Python-Runner.
7. **`git pull` auf Deploy-Server** — Merge-Schmutz; `reset --hard` ist das Muster.
8. **Prod-Deploy ohne Dev-Validierung** — develop-First ist implizite Policy.
9. **Schema-Drift ohne `DB_SCHEMA_VERSION`-Bump** — dokumentarische Lücke.
10. **Cached Docker-Build als Default** — Mitai wählt Reproduzierbarkeit über Geschwindigkeit.
11. **Migrationen außerhalb Container-Start vergessen** — manuelles psql in Prod als Normalfall.
12. **Hardcoded Seed-Daten in Migration** — produktive User/Secrets nicht in SQL.
13. **Port-Änderung „nebenbei“** — Infrastruktur-Kopplung.
14. **Tests nur lokal, nie auf Runner-Stack** — Mitai testet bewusst post-deploy im Pi-Container (Trade-off verstehen).
15. **Transaktionssteuerung in SQL-Datei** — Runner committet pro Datei; komplexe multi-step Rollbacks nicht eingebaut.
---
## Modul-Inventar (Ist-Stand)
```
backend/
├── db_init.py # wait, schema, run_migrations, sqlite import
├── startup.sh # db_init → uvicorn
├── schema.sql # Greenfield
├── migrations/ # 001–061+ nummeriert (+ Legacy ohne Nummer)
└── version.py # APP_VERSION, DB_SCHEMA_VERSION, MODULE_VERSIONS
docker-compose.yml # Prod: 3002/8002
docker-compose.dev-env.yml # Dev: 3099/8099
.gitea/workflows/
├── deploy-dev.yml # push develop
├── deploy-prod.yml # push main
└── test.yml # pytest, lint, npm build on Pi
Server (Pi):
/home/lars/docker/bodytrack-dev/ # develop
/home/lars/docker/bodytrack/ # main
```
**Migrationen (Stand):** 60+ nummerierte Dateien (`001` … `061`); höchste Nummer im Repo prüfen vor neuer Migration.
---
## Verwandte Dokumentation
- Operativ: [MIGRATIONS.md](../../technical/MIGRATIONS.md)
- Architektur: `.claude/rules/ARCHITECTURE.md` §2 (Versionierung), §7 (Prod-Schutz)
- Deploy-Command: `.claude/commands/deploy.md`, `merge-to-prod.md`
- Import/Migration-Grenze: [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](./UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md)
- Auth auf Prod: [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md)
---
## Serie – Übersicht (abgeschlossen)
| # | Modul | Status |
|---|-------|--------|
| 1 | Prompt Engine | ✅ `PROMPT_ENGINE_DESIGN_PRINCIPLES.md` |
| 2 | Data Layer | ✅ `DATA_LAYER_DESIGN_PRINCIPLES.md` |
| 3 | Feature & Entitlement | ✅ `FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md` |
| 4 | Registry / Plugin (Meta) | ✅ `REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md` |
| 5 | Auth & Session | ✅ `AUTH_SESSION_DESIGN_PRINCIPLES.md` |
| 6 | Universal Import | ✅ `UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md` |
| 7 | Dashboard Widgets | ✅ `DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md` |
| 8 | Navigation / IA | ✅ `NAVIGATION_IA_DESIGN_PRINCIPLES.md` |
| 9 | Migration & Deploy | ✅ dieses Dokument |

View File

@ -0,0 +1,346 @@
# Navigation & Informationsarchitektur – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** App-Navigation, Bereichs-Shells, Admin-IA, Responsive Shell — keine Seiteninhalte oder Domänenlogik
**Serie:** Designprinzipien für Produktfamilie · Dokument 8 von n
**Vorgänger:** [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Hauptnavigation | `frontend/src/config/appNav.js` |
| Erfassung | `frontend/src/config/captureNav.js`, `layouts/CaptureShell.jsx` |
| Einstellungen | `frontend/src/config/settingsNav.js`, `layouts/SettingsShell.jsx` |
| Admin | `frontend/src/config/adminNav.js`, `layouts/AdminShell.jsx`, `RequireAdmin.jsx` |
| KI-Analyse (Kategorien) | `frontend/src/config/analysisCategories.js`, `pages/Analysis.jsx` |
| Routing | `frontend/src/App.jsx` |
| Desktop-Sidebar | `frontend/src/components/DesktopSidebar.jsx` |
| Responsive CSS | `frontend/src/app.css` (`--nav-h`, `.bottom-nav`, `.analysis-split`, `.desktop-sidebar`) |
| Abnahme-Doku | `docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` |
| Responsive-Spec | `.claude/docs/functional/RESPONSIVE_UI.md` |
---
## Modul
**Navigation & Informationsarchitektur (IA)**
Schichtenmodell für die PWA: **eine primäre Hauptnavigation** (6–7 Bereiche), darunter **Bereichs-Shells** mit eigener Sub-Navigation, getrennte **Admin-Realm**, **Auth-Gates** und **ein Breakpoint** für Mobile vs. Desktop.
---
## Fachliche Verantwortung
Das Modul übernimmt:
1. **Hauptnav-SSoT** — Reihenfolge, Labels, Icons, Admin-Sichtbarkeit (`getMainNavItems`).
2. **Routing-Struktur** — Welche URL gehört zu welchem Bereich (Übersicht, Erfassen, Verlauf, Ziele, Analyse, Einstellungen, Admin).
3. **Sub-Navigation pro Bereich** — Capture-Hub, Settings-Tabs, Admin-Gruppen, Analyse-Kategorien.
4. **Layout-Muster** — Bottom-Nav (mobil), Sidebar (Desktop), `analysis-split` für tiefe Bereiche.
5. **Zugriffskontrolle (UI)** — `RequireAdmin`, Admin-Link nur bei `role === 'admin'`.
6. **Active-State** — Nested Routes (Erfassung unter `/capture`, Admin unter `/admin/*`).
7. **PWA-Tauglichkeit** — Safe Area, Scrollbare Bottom-Nav, Content-Padding.
Es übernimmt **nicht**:
- Backend-Autorisierung (→ [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md))
- Feature-Entitlements in der Nav (Tier-Gates an Endpoints/Widgets, nicht an jedem NavLink)
- Inhaltliche Tab-Logik innerhalb von Verlauf/Analyse (Seiten concern)
### IA-Modell (Nutzerperspektive)
| Ebene | Mental Model | Beispiel-Routen |
|-------|--------------|-----------------|
| **Primär** | Wo bin ich in der App? | `/`, `/capture`, `/history`, `/goals`, `/analysis`, `/settings` |
| **Sekundär (Shell)** | Was mache ich in diesem Bereich? | `/weight`, `/admin/g/features`, `/settings/dashboard-layout` |
| **Tertiär (Seite)** | Tabs/Filter innerhalb einer Maske | Verlauf-Tabs, Analyse-Kategorien |
### Strategisch vs. taktisch (Ziele)
| Ebene | Ort | Zweck |
|-------|-----|-------|
| **Strategisch** | `/goals` (Hauptnav) | Ziele definieren, Prioritäten, Focus Areas |
| **Taktisch** | `/custom-goals` (Erfassung) | Tägliche Ist-Werte für eigene Ziele |
| **Auswertung** | `/history` | Trends, Charts, Vergleiche |
---
## Administrierte vs. code-definierte Konfiguration
| Konfiguration | Speicherort | Wer pflegt? |
|---------------|-------------|-------------|
| Hauptnav-Reihenfolge & Labels | `appNav.js` | Entwickler |
| Erfassungs-Kacheln & Shell-Nav | `captureNav.js` | Entwickler |
| Admin-Gruppen & Hub-Karten | `adminNav.js` | Entwickler |
| Settings-Subnav | `settingsNav.js` | Entwickler |
| Analyse-Kategorie-Reihenfolge | `analysisCategories.js` | Entwickler |
| KI-Prompt-Kategorien (Runtime) | DB `ai_prompts.category` | Admin (Prompts) |
| React-Routes | `App.jsx` | Entwickler (muss zu Nav-Configs passen) |
---
## Designprinzipien
### 1. Eine Quelle für die Hauptnavigation
| | |
|---|---|
| **Prinzip** | `getMainNavItems(isAdmin)` liefert dieselbe Item-Liste für **Bottom-Nav** und **Desktop-Sidebar** — keine parallelen Hardcodings. |
| **Begründung** | Reihenfolge und Labels bleiben synchron; Admin-Conditional an einer Stelle. |
| **Quelle** | `appNav.js`, `App.jsx`, `DesktopSidebar.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Active-State-Logik ist in zwei Dateien dupliziert (`navItemActive` / `sidebarLinkActive`). |
### 2. Feste primäre IA-Reihenfolge (Produkt-Story)
| | |
|---|---|
| **Prinzip** | Übersicht → Erfassen → Verlauf → **Ziele** → Analyse → Einstellungen → [Admin] — spiegelt Nutzerfluss: sehen → eingeben → auswerten → steuern → interpretieren → konfigurieren. |
| **Begründung** | Ziele als eigener Hauptpunkt (nicht unter Analyse versteckt); klare Trennung Capture vs. History vs. Analysis. |
| **Quelle** | `GUI_IA_ADMIN_NAV_2026-04-05.md`; `appNav.js` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Produkt-spezifisch; andere Apps können andere Reihenfolge brauchen. |
### 3. Config-Dateien pro Bereich (Nav-as-Data)
| | |
|---|---|
| **Prinzip** | Sub-Navigation lebt in dedizierten Config-Modulen (`captureNav`, `adminNav`, `settingsNav`, `analysisCategories`) — Shell-Komponenten iterieren nur. |
| **Begründung** | Neue Erfassungsmaske = Eintrag in Config + Route; kein Nav-HTML in jeder Page. |
| **Quelle** | `captureNav.js` Kommentar „Pfade müssen mit Routes übereinstimmen“ |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein Build-Time-Check Config ↔ Routes. |
### 4. Bereichs-Shells für tiefe Navigation
| | |
|---|---|
| **Prinzip** | Capture, Settings und Admin nutzen **Shell-Layouts** mit `<Outlet />`; Nutzer wechselt Sub-Bereiche ohne Hauptnav zu verlassen. |
| **Begründung** | Erfassung hat 12+ Masken — wären als Hauptnav-Einträge unbrauchbar. |
| **Quelle** | `CaptureShell`, `SettingsShell`, `AdminShell` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Verlauf und Analyse haben eigene Tab-Muster (kein gemeinsames Shell-Config). |
### 5. Wiederverwendbares `analysis-split`-Layout
| | |
|---|---|
| **Prinzip** | Admin, Settings und KI-Analyse teilen CSS-Muster: mobil horizontale Chips, Desktop linke Spalte + `__main` für Inhalt. |
| **Begründung** | Ein visuelles Muster für „Kategorie links, Arbeit rechts“; weniger UI-Drift. |
| **Quelle** | `AdminShell.jsx`, `SettingsShell.jsx`, `Analysis.jsx`, `app.css` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Capture nutzt eigenes `capture-shell` (Emoji-Icons, Hub-Kacheln). |
### 6. Admin: Gruppen in der Shell, Seiten über Hub
| | |
|---|---|
| **Prinzip** | Shell-Nav zeigt nur **Admin-Gruppen** (+ Übersicht); konkrete Seiten als Karten auf `/admin/g/:groupId`. |
| **Begründung** | Skaliert bei wachsender Admin-Oberfläche; keine 20er-Sidebar. |
| **Quelle** | `adminNav.js` (`ADMIN_GROUPS`, `getAdminShellNavEntries`) |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Ein Klick mehr als flache Nav; bewusster Trade-off. |
### 7. Admin als eigener Realm
| | |
|---|---|
| **Prinzip** | `/admin/*` hinter `RequireAdmin`; kein Admin-Block mehr in Einstellungen; Profil-Anlage nur Admin → Benutzerverwaltung. |
| **Begründung** | Trennung Nutzer- vs. Betreiber-Kontext; weniger Verwechslung. |
| **Quelle** | `RequireAdmin.jsx`, `GUI_IA_ADMIN_NAV_2026-04-05.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | UI-Guard ersetzt nicht Backend-`require_admin` auf APIs. |
### 8. Route-Guard mit Nutzer-Feedback
| | |
|---|---|
| **Prinzip** | Nicht-Admin auf `/admin` → Redirect `/` mit `state.adminDenied`; Dashboard zeigt Hinweis. |
| **Begründung** | Stilles Scheitern vermeiden; klare Erwartung. |
| **Quelle** | `RequireAdmin.jsx`, `Dashboard.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 9. Nested Active-State für Section-Prefixes
| | |
|---|---|
| **Prinzip** | Custom Active-Logik: `/capture` aktiv bei allen Erfassungs-Pfaden; `/admin` bei gesamten Admin-Baum; `/goals` mit `end: true` (exakt). |
| **Begründung** | React-Router `end` allein reicht für Section-Gruppen nicht. |
| **Quelle** | `navItemActive`, `adminShellEntryIsActive` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Neue Section-Prefixes brauchen explizite Regel. |
### 10. Erfassungs-Hub + direkte Deep-Links
| | |
|---|---|
| **Prinzip** | `/capture` = Kachel-Hub; jede Maske auch direkt erreichbar (`/weight`, …); Shell-Nav immer sichtbar. |
| **Begründung** | Onboarding über Hub; Power-User/Dashboard-Links springen direkt. |
| **Quelle** | `CaptureHub`, `CAPTURE_HUB_TILES` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Hub und Shell-Nav listen dieselben Ziele (Doppelpflege). |
### 11. Einstellungen: nur aktives Profil
| | |
|---|---|
| **Prinzip** | Settings = Self-Service für **aktives** Profil (Name, E-Mail, Avatar, Quality-Filter); keine Profil-Liste für Endnutzer. |
| **Begründung** | Multi-Profil-Verwaltung ist Admin-Aufgabe; reduziert Komplexität. |
| **Quelle** | `SettingsPage.jsx`, IA-Doku |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Session-bound Profile-Id-Schwäche bleibt Backend-Thema. |
### 12. Settings-Subnav für Layout & Export
| | |
|---|---|
| **Prinzip** | Konfiguration schwerer Features (Dashboard-Layout, PDF-Berichte, Referenzwerte) als eigene Settings-Routen unter Shell — nicht in „Allgemein“ verstecken. |
| **Begründung** | Entspricht [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md) (Nutzer-Konfigurator). |
| **Quelle** | `settingsNav.js` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Admin-Dashboard-Default liegt unter `/admin/...` (getrennte Rolle). |
### 13. KI-Analyse: Ergebnis im Hauptspalt
| | |
|---|---|
| **Prinzip** | Neue Analyse-Ergebnisse rendern in `analysis-split__main`, nicht in der Kategorie-Nav — Nav bleibt wählbar. |
| **Begründung** | Lange Ergebnisse verdrängen sonst die Prompt-Auswahl (Mobile). |
| **Quelle** | `GUI_IA_ADMIN_NAV_2026-04-05.md`, `Analysis.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 14. Ein Breakpoint Mobile / Desktop (1024px)
| | |
|---|---|
| **Prinzip** | `< 1024px`: Bottom-Nav + Mobile-Header; `≥ 1024px`: Desktop-Sidebar, Bottom-Nav ausgeblendet, breiterer Content. **Kein** separates Tablet-Layout. |
| **Begründung** | Einfache Spec, PWA-first; iPad im Portrait = Mobile-Verhalten. |
| **Quelle** | `RESPONSIVE_UI.md`, `app.css` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Große Phones und kleine Tablets identisch behandelt. |
### 15. PWA Safe Area für Bottom-Navigation
| | |
|---|---|
| **Prinzip** | `--nav-h`, `--nav-pad-top`, `env(safe-area-inset-bottom)` auf `.bottom-nav`; Content-`padding-bottom` inkl. Nav-Höhe; horizontal scrollbare Nav bei vielen Items. |
| **Begründung** | iPhone Home-Indicator und Notch — kein Clipping, kein verdeckter Content. |
| **Quelle** | `app.css`, IA-Doku |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Safe Area nur auf Nav/Content-Padding, nicht global überall. |
### 16. Auth-Routen außerhalb der App-Shell
| | |
|---|---|
| **Prinzip** | Login, Register, Verify, Reset-Password rendern **ohne** Bottom-Nav/Sidebar — minimale Vollbild-Cards. |
| **Begründung** | Keine Navigation ohne Session; klarer Fokus. |
| **Quelle** | `App.jsx` (early returns vor `AppShell`) |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Public Routes nicht zentral in einer Route-Config. |
### 17. Rollen-sichtbare Nav-Einträge (UI only)
| | |
|---|---|
| **Prinzip** | Admin-Link erscheint nur wenn `isAdmin`; Backend schützt APIs separat. |
| **Begründung** | Progressive disclosure; normale Nutzer sehen keinen toten Link. |
| **Quelle** | `getMainNavItems(isAdmin)` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Security nicht durch Ausblenden ersetzt. |
### 18. Deep-Link-State für Verlauf
| | |
|---|---|
| **Prinzip** | Nav zu `/history` setzt optional `state: { tab: 'overview' }` — konsistenter Einstieg von Hauptnav. |
| **Begründung** | Verlauf merkt sich Tabs; Hauptnav soll nicht zufälligen alten Tab öffnen. |
| **Quelle** | `App.jsx`, `DesktopSidebar.jsx`, `History.jsx` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Nur für History implementiert, nicht app-weit. |
---
## Nicht übernehmen
1. **Hauptnav an mehreren Stellen hardcoden** — immer `appNav.js`.
2. **Admin-Funktionen in Einstellungen** — eigener `/admin`-Bereich.
3. **Alle Erfassungsmasken in die Bottom-Nav** — Shell + Hub skaliert.
4. **Alle Admin-Seiten in der Shell-Sidebar** — Hub-Gruppen-Muster beibehalten.
5. **Nav-Config ohne Route-Pflege** — jeder neue Pfad: Config + `App.jsx` + ggf. Active-State.
6. **UI-Admin-Guard ohne Backend-Guard** — `RequireAdmin` ist UX, APIs brauchen `require_admin`.
7. **Zwei Tablet-/Desktop-Breakpoints** — Mitai: ein Cut bei 1024px.
8. **Safe Area ignorieren** — PWA auf iOS bricht sonst an Bottom-Nav.
9. **Profil-Liste für Endnutzer in Settings** — Multi-Profil = Admin.
10. **Feature-Tier-Logik in Nav-Komponenten** — Entitlements an Widgets/APIs ([FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)).
11. **Inkonsistente Layout-Muster pro Bereich** — wo `analysis-split` passt, nicht neues Ad-hoc-Layout erfinden.
12. **Orphan-Routes ohne Nav-Ergänzung** — z. B. `/subscription`, `/workflow-editor/:id` existieren außerhalb Haupt-IA; bewusst dokumentieren, nicht unkontrolliert multiply.
13. **Active-State nur per Router-Default** — Section-Prefixes (`/capture/*`, `/admin/*`) brauchen explizite Regeln.
14. **Analyse-Ergebnisse in der Nav-Spalte** — verdrängt Prompt-Auswahl auf Mobile.
---
## Modul-Inventar (Ist-Stand)
```
frontend/src/config/
├── appNav.js # Hauptnav (6 + Admin)
├── captureNav.js # Erfassungs-Hub + Shell
├── settingsNav.js # Settings-Subnav
├── adminNav.js # ADMIN_GROUPS, Shell-Entries
└── analysisCategories.js # KI-Analyse-Gruppen
frontend/src/layouts/
├── CaptureShell.jsx
├── SettingsShell.jsx
├── AdminShell.jsx
└── RequireAdmin.jsx
frontend/src/components/
└── DesktopSidebar.jsx
frontend/src/App.jsx # Routes + Bottom-Nav + Auth-Gates
frontend/src/app.css # Shell, split, safe-area, 1024px breakpoint
```
**Hauptnav (7 Einträge mit Admin):** Übersicht · Erfassen · Verlauf · Ziele · Analyse · Einstellungen · Admin
**Admin-Gruppen (8):** users · features · subscription · training · goals · prompts · system
---
## Verwandte Dokumentation
- Abnahme-Stand: [GUI_IA_ADMIN_NAV_2026-04-05.md](../../../../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md)
- Responsive-Spec: [RESPONSIVE_UI.md](../../functional/RESPONSIVE_UI.md)
- Auth/Session: [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md)
- Dashboard-Konfigurator: [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md)
- Gitea #30 (Responsive UI, teilweise erledigt)
---
## Geplante Folgedokumente (Serie)
| # | Modul | Status |
|---|-------|--------|
| 1–7 | … | ✅ |
| 8 | Navigation / IA | ✅ dieses Dokument |
| 9 | Migration & Deploy | ✅ |

View File

@ -0,0 +1,305 @@
# Prompt Engine – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Prompt Engine“ (Unified Prompt System, Issue #28) — keine Mitai-Gesamtarchitektur, keine Domänenlogik (Gesundheit, Ernährung, Messwerte)
**Serie:** Designprinzipien für Produktfamilie · Dokument 1 von n
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Executor | `backend/prompt_executor.py`, `backend/workflow_executor.py` |
| Platzhalter | `backend/placeholder_resolver.py`, `backend/placeholder_registry.py`, `backend/placeholder_registrations/` |
| API | `backend/routers/prompts.py`, `backend/routers/workflows.py` |
| Admin-UI | `frontend/src/pages/AdminPromptsPage.jsx`, `UnifiedPromptModal.jsx`, `WorkflowEditorPage.jsx` |
| Fachliche Spec | `.claude/docs/functional/AI_PROMPTS.md` |
| Platzhalter-Governance | `.claude/docs/technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `docs/PLACEHOLDER_GOVERNANCE.md` |
---
## Modul
**Prompt Engine** (Unified Prompt System, Issue #28)
Backend-Kern: `prompt_executor.py`, `placeholder_resolver.py`, `placeholder_registry` / `placeholder_registrations/`, `workflow_executor.py`
API: `routers/prompts.py`
Admin-UI: `AdminPromptsPage`, `UnifiedPromptModal`, `WorkflowEditorPage`
---
## Fachliche Verantwortung
Die Prompt Engine ist die **zentrale Ausführungs- und Konfigurationsschicht für KI-Analysen**. Sie übernimmt:
1. **Prompt-Orchestrierung** — Laden aktiver Prompt-Definitionen aus der DB und Ausführung als `base` (Einzelprompt), `pipeline` (mehrstufig) oder `workflow` (Graph).
2. **Kontextaufbereitung** — Befüllen von Platzhaltern mit profilbezogenen Daten (Resolver → Data Layer).
3. **LLM-Aufruf** — Einheitlicher OpenRouter-Call über injizierte Callback-Funktion.
4. **Ergebnisbehandlung** — JSON-Validierung, strukturierte Container (Fragenergänzungen), Debug-Metadaten, optionales Speichern in `ai_insights`.
5. **Admin-Konfiguration** — CRUD für Prompts/Workflows, Import/Export, Vorschau und Test ohne Produktions-Ausführung.
### Administrierte Konfigurationen
| Konfiguration | Speicherort | Inhalt |
|---------------|-------------|--------|
| Prompt-Metadaten | `ai_prompts` | `name`, `slug`, `category`, `active`, `sort_order`, `display_name` |
| Templates | `ai_prompts` | `template` |
| Pipeline-Stages | `ai_prompts.stages` (JSONB) | Stages mit `inline` / `reference` |
| Workflow-Graphen | `ai_prompts.graph_data` | Knoten, Kanten, Metadaten |
| Output-Regeln | `ai_prompts` | `output_format`, `output_schema` |
| Fragenergänzungen | `ai_prompts.question_augmentations` | Optionale Standard-Fragen (Hybridmodell: Knoten > Prompt) |
| System-Reset | `ai_prompts` | `is_system_default`, `default_template` |
| Legacy-Pipeline-Configs | `pipeline_configs` | Module, Zeiträume, Stage-Slugs (parallel zum Unified System) |
| Workflow-Fragenkatalog | `workflow_question_catalog` | Fragetypen, Templates, Normalisierung |
### Bewusst nicht hardcodiert
- Prompt-Texte, Pipeline-Zusammensetzung, Workflow-Topologie
- Kategorie, Sichtbarkeit (`active`), Sortierung
- Output-Format und Schema pro Prompt
- Referenz vs. Inline in Pipeline-Stages
### Hardcodiert (Code / Env)
- Platzhalter-Definitionen und Resolver (`PLACEHOLDER_MAP`, Registry)
- LLM-Modell (`OPENROUTER_MODEL`)
- Default-Module und -Zeiträume in `/prompts/execute`
- Domänen-Kategorien im Frontend (`analysisCategories.js`)
- Meta-Prompts für Generate/Optimize (Admin-Tooling)
### Trennung: Template · Platzhalter · Kontext · Workflow
| Schicht | Ort | Rolle |
|---------|-----|-------|
| **Templates** | `ai_prompts.template`, `stages`, Knoten-Templates im Graph | Was an die KI geht |
| **Platzhalter** | `placeholder_resolver` + Registry | Semantische API-Keys `{{key}}`, Resolver-Funktionen |
| **Kontextdaten** | `execute_prompt_with_data` + Resolver → `data_layer/` | Werte für Platzhalter |
| **Workflows** | `graph_data` + `workflow_executor` | Ausführungsgraph, Verzweigung, Join, Aggregation |
### Durchsetzung: keine Sonderlogik außerhalb der Engine
**Konzeptionell:** Ein Executor (`execute_prompt` → `execute_prompt_with_data`) als Single Entry Point.
**Praktisch unvollständig:** Legacy-Pfade in `insights.py` (`/insights/run`, `/insights/pipeline`) mit eigener Template-Logik (`_prepare_template_vars`, `_render_template`) und direkten LLM-Calls; `History.jsx` nutzt noch `runInsight`. Kein technischer Guard (Lint/Policy), nur Konvention.
### Rollen und Berechtigungen
| Rolle | Darf |
|-------|------|
| **Admin** (`require_admin`) | Prompts/Workflows/Pipeline-Configs CRUD, Import/Export, Reset-to-default, Generate/Optimize, Platzhalter-Metadaten-ZIP |
| **Nutzer** (`require_auth`) | Aktive Prompts listen (ohne Pipeline-Slugs), ausführen (`/prompts/execute`), Preview, Platzhalter-Katalog, eigene Werte exportieren |
Workflow-Editor-Route (`/workflow-editor/:id`) ist nicht hinter `RequireAdmin`; Schreib-APIs sind admin-geschützt.
### Versionierung, Freigabe, Test
| Mechanismus | Status |
|-------------|--------|
| Prompt-Versionsverlauf in DB | ❌ Overwrite |
| Reset-to-default für System-Prompts | ✅ `is_system_default` + `default_template` |
| JSON Import/Export (Dev→Prod) | ✅ `/export-all`, `/import` |
| Admin-Test mit Debug | ✅ `debug=true`, UnifiedPromptModal |
| Preview ohne LLM | ✅ `POST /preview` |
| Platzhalter-Deprecation-Prozess | 📄 dokumentiert, nicht runtime-erzwungen |
| Formales Freigabe-Workflow | ❌ |
| Executor-E2E-Tests | ⚠️ punktuell (Modifier, Output-Compact) |
---
## Designprinzipien
### 1. Single Executor für Prompt-Ausführung
| | |
|---|---|
| **Prinzip** | Alle KI-Analysen laufen über `execute_prompt` / `execute_prompt_with_data`. |
| **Begründung** | Einheitliche Platzhalter-Auflösung, Debug, JSON-Validierung, Speicher-Metadaten. |
| **Quelle** | `backend/prompt_executor.py`; `POST /api/prompts/execute`; `Analysis.jsx` → `executeUnifiedPromptStream` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy `insights.py` und teils `History.jsx` umgehen den Executor noch. |
### 2. Konfigurierbare Prompt-Bibliothek statt fest verdrahteter Texte
| | |
|---|---|
| **Prinzip** | Prompt-Inhalte und Workflows liegen in `ai_prompts`, nicht im Anwendungscode. |
| **Begründung** | Admins können Analysen anpassen, duplizieren, deaktivieren, ohne Deploy. |
| **Quelle** | Migration 020; `UnifiedPromptCreate`/`Update` in `models.py`; `AdminPromptsPage.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Parallel existieren noch `pipeline_configs` und hardcodierte Default-Module/Zeiträume. |
### 3. Drei Prompt-Typen mit klarer Verantwortung
| | |
|---|---|
| **Prinzip** | `base` = wiederverwendbarer Baustein; `pipeline` = sequenzielle Stages; `workflow` = Graph mit Verzweigung. |
| **Begründung** | Komposition ohne Copy-Paste; Reference-Prompts in Pipelines (`source: 'reference'`). |
| **Quelle** | `execute_prompt()` Typ-Verzweigung; `StagePromptCreate` in `models.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Pipeline-Stages laufen sequentiell, obwohl konzeptionell „parallel“; `workflow_definitions` und `ai_prompts.graph_data` doppelt. |
### 4. Platzhalter als API-Verträge (Registry)
| | |
|---|---|
| **Prinzip** | Platzhalter sind registrierte, dokumentierte Verträge — keine freien Prompt-Hilfsvariablen. |
| **Begründung** | Konsistenz für Injektion, GUI-Picker, Export, Validierung. |
| **Quelle** | `PLACEHOLDER_REGISTRY_FRAMEWORK.md`; `docs/PLACEHOLDER_GOVERNANCE.md`; `import placeholder_registrations` in `main.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Duplikat `PLACEHOLDER_MAP` in `placeholder_resolver.py` neben Registry; Metadaten teils noch Legacy. |
### 5. Trennung Template (Was) vs. Resolver (Daten)
| | |
|---|---|
| **Prinzip** | Templates enthalten nur `{{keys}}`; Berechnung liegt in Resolver/Data Layer. |
| **Begründung** | Prompt-Autoren ändern Text, nicht Berechnungslogik. |
| **Quelle** | `resolve_placeholders()` in `prompt_executor.py`; Registry-Felder `resolver_function`, `data_layer_function` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `execute_prompt_with_data` lädt zusätzlich Roh-SQL pro Modul — zweite Kontext-Schicht. |
### 6. Layer-1-Daten vs. Layer-2a-Prompt-Injektion
| | |
|---|---|
| **Prinzip** | Berechnungen in `data_layer/`; Prompt Engine konsumiert nur formatierte Werte. |
| **Begründung** | Single Source of Truth für Charts, Platzhalter, KI. |
| **Quelle** | Phase-0c-Architektur; Registry-Felder `data_layer_module` / `layer_1_decision` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy `_prepare_template_vars` in `insights.py` umgeht Data Layer. |
### 7. Transparenz durch Debug- und Preview-Modus
| | |
|---|---|
| **Prinzip** | Aufgelöste/unaufgelöste Platzhalter, Final-Prompt und Stage-Outputs sind inspizierbar; Preview ohne LLM. |
| **Begründung** | Admin kann Prompts testen und Wertetabelle/Expertenmodus speisen. |
| **Quelle** | `debug`-Parameter; `/preview`; `UnifiedPromptModal` Test-Button; `ai_insights.metadata` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Debug-Daten in Responses können groß/sensibel sein; kein separates Staging. |
### 8. Wiederverwendbare Base-Prompts via Reference
| | |
|---|---|
| **Prinzip** | Pipeline-Stages referenzieren Slugs statt Templates zu duplizieren. |
| **Begründung** | Ein Baustein, mehrere Workflows; zentral wartbar. |
| **Quelle** | `source == 'reference'` in `execute_pipeline_prompt()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Keine Referenz-Versionierung; Änderung am Base-Prompt wirkt sofort auf alle Referenzen. |
### 9. Strukturierte LLM-Ausgaben per Output-Format
| | |
|---|---|
| **Prinzip** | Pro Prompt/Prompt-Def: `output_format: text\|json`, optional `output_schema`; Pipeline-Outputs als Stage-Keys im Kontext. |
| **Begründung** | Maschinenlesbare Zwischenergebnisse für Multi-Stage und Wertetabelle. |
| **Quelle** | `validate_json_output()`; Stage `output_key` in Pipeline |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | JSON-Schema-Validierung ist TODO (`jsonschema`); Markdown-Unwrap als Heuristik. |
### 10. Admin-only Konfiguration, User-only Ausführung
| | |
|---|---|
| **Prinzip** | Schreibende Prompt-/Workflow-Operationen nur mit `require_admin`. |
| **Begründung** | Produktions-Prompts sind Systemkonfiguration, nicht Nutzerdaten. |
| **Quelle** | `require_admin` in `routers/prompts.py`; `RequireAdmin` für `/admin/prompts` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `/workflow-editor/:id` ohne Frontend-Admin-Gate; `/prompts/execute` ohne `check_feature_access` (Legacy-Pfad in `insights.py` hat Enforcement). |
### 11. Import/Export als Umgebungs-Sync
| | |
|---|---|
| **Prinzip** | Prompt-Sätze als JSON exportierbar/importierbar (Dev→Prod). |
| **Begründung** | Konfiguration versionierbar in Git, nicht in der App-DB. |
| **Quelle** | `GET /export-all`, `POST /import` in `routers/prompts.py`; Admin-UI Buttons |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Kein Diff, keine Merge-Strategie, kein Rollback; Overwrite-Flag manuell. |
### 12. Workflow-Erweiterung: Graph + Fragenergänzungen + Signale
| | |
|---|---|
| **Prinzip** | Workflows als Knoten/Kanten-Graph; optionale Fragen am Knoten; Normalisierung/Logic/Join als Engine-Schicht. |
| **Begründung** | Bedingte, verzweigte Analysen jenseits linearer Pipelines. |
| **Quelle** | `workflow_executor.py`; Migration 034; `question_augmenter.py` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Hohe Komplexität; zwei Speicherorte (`graph_data` vs. `workflow_definitions`); Jinja2 im Workflow-Pfad zusätzlich zu `{{}}`-Resolver. |
### 13. Platzhalter-Modifier für KI-Kontext
| | |
|---|---|
| **Prinzip** | `{{key\|d}}` (Wert + Beschreibung), `{{key\|x}}` (Erklärung ohne Zahl) über Katalog-Metadaten. |
| **Begründung** | Prompts können Kontext für das Modell reichhaltiger machen ohne Template-Duplikate. |
| **Quelle** | `resolve_placeholders()` Modifier-Logik; `get_placeholder_catalog()` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Modifier-Syntax ad hoc; Katalog-Pflicht für sinnvolle `\|x`-Nutzung. |
### 14. System-Prompt-Reset statt DB-Versionierung
| | |
|---|---|
| **Prinzip** | Shipped Prompts mit `is_system_default` + `default_template`; Admin-Reset auf Original. |
| **Begründung** | Schutz vor irreversiblen Fehlkonfigurationen ohne vollständiges Versionsmodell. |
| **Quelle** | Migration 019; `POST /{prompt_id}/reset-to-default` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Nur ein Default-Snapshot; keine Historie benutzerdefinierter Änderungen. |
### 15. Governance für Platzhalter-Änderungen
| | |
|---|---|
| **Prinzip** | Breaking Changes nur über Deprecation + Replacement; semantische Verträge dokumentiert. |
| **Begründung** | Prompts in Produktion brechen nicht still. |
| **Quelle** | `docs/PLACEHOLDER_GOVERNANCE.md` §4 |
| **Tragfähigkeit** | **mittel** (prozessual) |
| **Einschränkung** | Prozess in Doku, nicht im Runtime erzwungen; Checkliste verweist noch auf Legacy-Dateien. |
---
## Nicht übernehmen
Muster, die sich nicht bewährt haben oder zu produktspezifisch sind — bei Neuentwicklung vermeiden:
1. **Parallele Ausführungspfade** — Legacy `insights.py` (`/insights/run`, `/insights/pipeline`) mit eigener Template-Engine und LLM-Calls neben `prompt_executor`; Frontend-Split (`Analysis` vs. `History`).
2. **Doppelte Metadaten für Platzhalter** — `PLACEHOLDER_MAP`, Registry, `placeholder_metadata_complete.py` und Katalog-Fallbacks parallel; erzeugt Sync-Risiko (114 Keys müssen deckungsgleich bleiben).
3. **Zwei Pipeline-Modelle gleichzeitig** — `pipeline_configs` (3 fixe Stages) und Unified `type=pipeline` in `ai_prompts`; Migration 020 migriert, Tabelle bleibt aktiv.
4. **Zwei Workflow-Speicher** — `workflow_definitions.graph` und `ai_prompts.graph_data`; unklare Single Source of Truth.
5. **Roh-SQL-Kontextladung im Executor** — `execute_prompt_with_data` lädt Modul-Rohdaten per SQL, obwohl Resolver/Data Layer existieren; Domänenwissen in der Engine.
6. **Hardcodierte Execute-Defaults** — Module/Zeiträume in `/execute` fest verdrahtet statt aus Prompt-/Pipeline-Konfiguration.
7. **Fehlende Feature-Enforcement-Konsistenz** — `check_feature_access` auf Legacy-Insights, nicht auf `/prompts/execute`.
8. **„Parallel“ als sequentiell implementiert** — Pipeline-Stages kommentiert als parallel, Code sequentiell; irreführendes Modell.
9. **Unvollständige Output-Validierung** — JSON-Parse + Markdown-Unwrap, Schema-Check auskommentiert/TODO.
10. **Workflow-Editor ohne klares Admin-Gate in Routing** — Schreib-API geschützt, UI-Route für alle Authentifizierten erreichbar.
11. **Domänen-spezifische Hardcodings in der Engine** — Kategorien (`körper`, `ernährung`, …), deutsche Meta-Prompts für Generate/Optimize, Fitness-Kontext in Prompt-Generator.
12. **Kein integriertes Prompt-Versions- und Freigabemodell** — Overwrite + JSON-Export ersetzt keine Revision/Review/Publish-Pipeline.
13. **Issue #51 (Seitenzuordnung) nicht umgesetzt** — Prompt-Verfügbarkeit kontextuell nicht konfigurierbar; alles über zentrale Analyse-Seite.
14. **Globales LLM-Modell per Env** — `workflow_executor` übergibt Modell pro Call, `call_openrouter` ignoriert es; keine prompt-spezifische Modellwahl trotz API-Shape.
---
## Verwandte Dokumentation
- Fachliche Spec: [AI_PROMPTS.md](../../functional/AI_PROMPTS.md)
- Platzhalter-Registry: [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md)
- Platzhalter-Governance: [PLACEHOLDER_GOVERNANCE.md](../../../../docs/PLACEHOLDER_GOVERNANCE.md)
- Issue #28 (Unified Prompt System): abgeschlossen, siehe `CLAUDE.md`
- Issue #51 (Prompt-Seitenzuordnung): [issue-51-prompt-page-assignment.md](../../../../docs/issues/issue-51-prompt-page-assignment.md)
- **Serie (abgeschlossen):** [Index](./README.md) · [Jinkendo Foundation](../README.md) · Dokumente #1–#9

View File

@ -0,0 +1,136 @@
# Designprinzipien – Index (Jinkendo Foundation)
**Teil von:** [Jinkendo Foundation](../README.md)
**Status:** Arbeitspapier / Übergabe
**Stand:** 2026-07-04
**Zweck:** Zentraler Einstieg für die aus Mitai extrahierte Serie **tragfähiger Designprinzipien** — für neue Apps der Jinkendo-Produktfamilie oder vergleichbare Self-Hosted-PWAs.
**Nicht enthalten:** Mitai-Gesamtarchitektur, Domänenlogik (Gesundheit/Ernährung), Kairo-/Framework-Empfehlungen.
**Ablage:** `.claude/docs/jinkendo-foundation/design-principles/` · Regeln: [DOCUMENTATION.md](../../../rules/DOCUMENTATION.md)
---
## Wofür diese Serie?
Mitai Jinkendo implementiert wiederkehrende **Querschnittsmuster** (Prompt-Ausführung, Data Layer, Entitlements, Registries, Auth, Import, Dashboard, Navigation, Deploy). Die neun Dokumente destillieren daraus:
- **Was** übertragbar ist (Prinzip + Begründung + Tragfähigkeit)
- **Was** bewusst nicht kopiert werden soll („Nicht übernehmen“)
- **Wo** im Code nachgeschaut werden kann (Pfade, Agent-Guides)
Jedes Dokument ist **eigenständig lesbar**; dieser Index ordnet Abhängigkeiten und Lese-Reihenfolge.
---
## Dokumente (9/9)
| # | Modul | Datei | Kernidee (1 Satz) |
|---|-------|-------|-------------------|
| 1 | Prompt Engine | [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md) | Ein Executor, Pipeline/Workflow-Typen, Platzhalter über Registry — keine Raw-Template-Ausführung. |
| 2 | Data Layer | [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) | Layer 0→1→2: Single Source of Truth für Berechnungen; Charts und KI nutzen dieselbe Schicht. |
| 3 | Feature & Entitlement | [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md) | Zentrale `check_feature_access`, DB-Registry, 4-Phasen-Rollout — Enforcement an der API. |
| 4 | Registry / Plugin (Meta) | [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) | Drei Registry-Muster (Platzhalter, Widgets, CSV): SSoT, Validierung an der Grenze, Runtime-Registrierung. |
| 5 | Auth & Session | [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md) | Server-Sessions, `require_auth` als Depends — bekannte Schwäche: Profile-Header ohne Session-Bindung. |
| 6 | Universal Import | [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](./UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md) | Modul-Registry + Executor + Vorlagen; Ingest ≠ Interpretation; SAVEPOINT pro Zeile. |
| 7 | Dashboard Widgets | [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md) | Backend-Katalog, Profil-Layout, Config-Whitelist, Entitlements — Dual Registry mit Frontend. |
| 8 | Navigation / IA | [NAVIGATION_IA_DESIGN_PRINCIPLES.md](./NAVIGATION_IA_DESIGN_PRINCIPLES.md) | `appNav` als SSoT, Shells für tiefe Bereiche, Admin-Hub, ein Breakpoint 1024px. |
| 9 | Migration & Deploy | [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) | `XXX_*.sql` beim Container-Start; develop/main → Dev/Prod; fail-fast, kein Auto-Rollback. |
---
## Empfohlene Lesereihenfolge
### Schnellüberblick (30 Min)
1. [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) — Meta-Muster für viele Module
2. [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) — Daten vs. Darstellung
3. [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) — Betrieb & Schema-Evolution
### Vollständige Implementierung (neues Produkt)
```
Foundation: (9) Migration & Deploy → (5) Auth → (3) Feature & Entitlement
Daten: (2) Data Layer → (6) Universal Import
Erweiterung: (4) Registry Meta → (1) Prompt Engine → (7) Dashboard Widgets
Oberfläche: (8) Navigation / IA
```
### Nur ein Modul nachbauen
| Ziel | Lese zuerst | Dann |
|------|-------------|------|
| KI-Analysen | #1 Prompt Engine | #2 Data Layer, #4 Registry |
| Charts & KPIs | #2 Data Layer | #7 Dashboard Widgets |
| Freemium / Limits | #3 Feature & Entitlement | #7 Widgets (Gating) |
| CSV-Import | #6 Universal Import | #2 Data Layer, #4 Registry |
| Admin-PWA | #8 Navigation / IA | #3, #5 |
---
## Querschnittsthemen (über alle Docs)
| Thema | Primär | Ergänzend |
|-------|--------|-----------|
| Single Source of Truth | #2 Data Layer | #4 Registry, #7 Widget-Katalog, #6 Modul-Registry |
| Validierung an der Grenze | #4 Registry | #7 Layout-Pydantic, #6 Template-Validator |
| Feature-Gating | #3 Entitlement | #7 Widget `allowed`, #6 Import-Limits |
| Dual Registry (Backend + Frontend) | #4 Registry | #7 `registerDashboardWidgets`, Platzhalter-UI |
| Idempotenz / Recovery | #9 Migration | #6 SAVEPOINT, Migration `IF NOT EXISTS` |
| Bekannte Lücken dokumentieren | alle „Nicht übernehmen“ | z. B. IDOR Profile-Header (#5), fehlender Cross-Check Widget-IDs (#7) |
---
## Verwandte normative Docs (Mitai-spezifisch)
Diese Agent-Guides sind **Implementierungsdetail**; die Designprinzipien-Serie ist **extrahiertes Muster**:
| Thema | Agent-Guide / Spec |
|-------|-------------------|
| Platzhalter | [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md) |
| Data Layer erweitern | [DATA_LAYER_EXTENSION_GUIDE.md](../../technical/DATA_LAYER_EXTENSION_GUIDE.md) |
| CSV Import | [UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md](../../technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md) |
| Dashboard Widgets | [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md) |
| GUI / Admin / Nav | [GUI_IA_ADMIN_NAV_2026-04-05.md](../../../../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md) |
| Migrationen (operativ) | [MIGRATIONS.md](../../technical/MIGRATIONS.md) |
| Membership | [MEMBERSHIP_SYSTEM.md](../../technical/MEMBERSHIP_SYSTEM.md), [FEATURE_ENFORCEMENT.md](../../architecture/FEATURE_ENFORCEMENT.md) |
| Architektur-Regeln | [ARCHITECTURE.md](../../../rules/ARCHITECTURE.md) |
---
## Übergabe-Checkliste (neues Produkt in der Familie)
Nutze diese Liste beim Start eines Schwester-Projekts — **Prinzipien ja, Mitai-Pfade nein**:
```
[ ] Deploy: nummerierte SQL-Migrationen + Tracking-Tabelle + Startup vor App
[ ] Auth: Session server-side; Depends(require_auth); Admin-Route-Guard
[ ] Entitlements: eine check_feature_access-Funktion; keine Tier-Logik in UI-Widgets
[ ] Data Layer: Berechnungen nicht in Router/React duplizieren
[ ] Registry: neue erweiterbare IDs nur über zentralen Katalog + Validierung
[ ] Import (falls CSV): Modul-Registry + Vorlagen + Import-Grenze (keine Scores beim Insert)
[ ] Dashboard (falls konfigurierbar): Backend-Katalog + Layout-JSON + allowed-Flag
[ ] Navigation: eine appNav-SSoT; Shells für >6 Top-Level-Bereiche
[ ] Prompt/KI (falls): ein Executor; Platzhalter-Registry; kein Raw-Template an LLM
[ ] Dokumentieren: pro Modul „Nicht übernehmen“ aus Mitai-Lücken mitnehmen
```
---
## Pflege
| Aktion | Wo |
|--------|-----|
| Neues Querschnittsmodul extrahiert | Neues `*_DESIGN_PRINCIPLES.md` + Zeile in Tabelle oben |
| Mitai-Implementierung ändert Muster | Betroffenes Einzeldokument + ggf. Querschnittstabelle hier |
| Nur Mitai-Bugfix ohne Muster-Änderung | Agent-Guides / Code; Designprinzipien optional |
---
## Changelog Index
| Datum | Änderung |
|-------|----------|
| 2026-07-04 | Index angelegt; Serie 1–9 abgeschlossen |
| 2026-07-04 | Nach `jinkendo-foundation/design-principles/` verschoben |

View File

@ -0,0 +1,315 @@
# Registry- & Plugin-Muster – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Querschnittsmuster für erweiterbare Registries — drei Implementierungen in Mitai (Platzhalter, Dashboard-Widgets, CSV-Import)
**Serie:** Designprinzipien für Produktfamilie · Dokument 4 von n
**Vorgänger:** [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
**Die drei Registries:**
| Registry | Backend-Kanon | Frontend-/Runtime-Registry | Leitfaden |
|----------|---------------|----------------------------|-----------|
| **Platzhalter** | `placeholder_registry.py` + `placeholder_registrations/` | `PLACEHOLDER_MAP` in `placeholder_resolver.py` | `PLACEHOLDER_REGISTRY_FRAMEWORK.md` |
| **Dashboard-Widgets** | `widget_catalog.py` | `registerDashboardWidgets.js` → `dashboardWidgetRegistry.jsx` | `DASHBOARD_WIDGETS_AGENT_GUIDE.md` |
| **CSV-Import-Module** | `csv_parser/module_registry.py` | — (Executor + Admin-UI konsumieren API) | `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` |
---
## Modul
**Registry- & Plugin-Muster** (Meta-Schicht)
Wiederkehrendes Architekturmuster: **Zentral registrierte, ID-basierte Erweiterungspunkte** mit Metadaten, Validierung und getrennten Konsumenten (GUI, API, Batch). Kein einzelnes Runtime-Modul — ein **Familien-Designpattern**, das in Mitai dreimal konkret umgesetzt ist.
---
## Fachliche Verantwortung
Registries übernehmen:
1. **Autoritative ID-Liste** — Was existiert, was ist erlaubt, in welcher Reihenfolge (optional).
2. **Metadaten für Mensch & Maschine** — Titel, Beschreibung, Typen, Abhängigkeiten, semantische Verträge.
3. **Validierung** — Unbekannte IDs werden abgelehnt; Konfigurationen gegen Kanon geprüft.
4. **Entkopplung** — Implementierung (Resolver, React-Komponente, Import-Executor) registriert sich an den Kanon, nicht umgekehrt.
5. **Erweiterbarkeit ohne Schema-Explosion** — Neue Einträge über Code-Registrierung (+ ggf. DB-Overrides), nicht über neue DB-Spalten pro Feature.
Registries übernehmen **nicht**:
- Fachliche Berechnung (→ Data Layer)
- Entitlement-Auflösung (→ Feature System; Widgets *referenzieren* Features)
- Auth / Mandanten
### Gemeinsames Strukturschema
```
┌─────────────────────────────────────────────────────────┐
│ REGISTRY (Kanon) │
│ ID + Metadaten + optionale Policies/Abhängigkeiten │
└────────────────────┬────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Implementierung Validierung Konsumenten
(Resolver/ (Schema/ (GUI-Picker,
Component/ Tests) API, Executor)
Executor)
```
### Vergleich der drei Implementierungen
| Aspekt | Platzhalter | Dashboard-Widgets | CSV-Import |
|--------|-------------|-------------------|------------|
| **Primär-ID** | `key` (snake_case) | `id` (snake_case) | Modul-Name (`nutrition`, `activity`, …) |
| **Kanon-Speicher** | Python Singleton + Cluster-Module | Python-Liste `WIDGET_CATALOG` | Python-Dict `MODULE_DEFINITIONS` |
| **Metadaten-Tiefe** | Sehr hoch (22+ Felder, Evidence) | Mittel (title, description, requires_feature) | Hoch (fields, types, duplicate_key, aggregates) |
| **Runtime-Registry** | `register_placeholder()` beim Import | `registerDashboardWidget()` idempotent | Keine — Executor liest Dict |
| **Frontend-Spiegel** | PlaceholderPicker, Admin-Prompt-Modal | `ensureDashboardWidgetsRegistered()` | Admin CSV Template Editor |
| **Validierung** | `metadata.validate()`, Governance-Docs | Pydantic Layout + `validate_widget_entry_config` | `validate_field_mappings`, `validate_csv_template` |
| **Tests** | `test_placeholder_metadata.py`, … | `test_widget_catalog.py` | `test_template_validator.py`, … |
| **DB-Override** | Nein (nur Code) | Ja (`widget_feature_requirements`, Layout pro Profil) | Ja (Vorlagen, Nutzer-Mappings) |
| **Entitlements** | Indirekt (Data/Features) | `requires_feature` → `check_feature_access` | Feature-Limits beim Import |
---
## Designprinzipien (übergreifend)
### 1. Single Source of Truth für erlaubte IDs
| | |
|---|---|
| **Prinzip** | Jede erweiterbare Einheit hat **eine** autoritative ID-Liste; Router und UI duplizieren keine Feld-/Widget-/Platzhalter-Listen. |
| **Begründung** | Verhindert „funktioniert in der UI, scheitert in der API“ und umgekehrt. |
| **Quelle** | `module_registry.py` Kommentar; `widget_catalog.py`; `PLACEHOLDER_REGISTRY_FRAMEWORK.md` §2.3 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Platzhalter: paralleles `PLACEHOLDER_MAP` neben Registry. |
### 2. ID-Stabilität als Vertrag
| | |
|---|---|
| **Prinzip** | IDs/Keys sind stabile API-Verträge; Umbenennung = neuer Key + Deprecation, nicht stilles Rename. |
| **Begründung** | Prompts, Layouts und CSV-Vorlagen referenzieren IDs persistent. |
| **Quelle** | `docs/PLACEHOLDER_GOVERNANCE.md` §4.2–4.3; Widget-Layout in `profiles.dashboard_layout` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht überall runtime-erzwungen (Platzhalter-Governance prozessual). |
### 3. Metadaten getrennt von Implementierung
| | |
|---|---|
| **Prinzip** | Registry speichert **Was** (ID, Beschreibung, Typ, Policies); Implementierung lebt in separaten Modulen. |
| **Begründung** | GUI, Export, Validierung und Docs können Metadaten nutzen ohne Resolver/Component zu laden. |
| **Quelle** | `PlaceholderMetadata` Dataclass; `WidgetCatalogEntry`; `MODULE_DEFINITIONS.fields` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Platzhalter bindet `_resolver_func` optional an Metadata-Objekt. |
### 4. Zwei-Phasen-Registrierung (Backend-Kanon + Runtime-Binding)
| | |
|---|---|
| **Prinzip** | Phase A: Kanon definiert IDs und Metadaten. Phase B: Implementierung registriert sich (Platzhalter-Cluster-Import, `registerDashboardWidget`, Executor nutzt Modul-Def). |
| **Begründung** | Backend bleibt autoritativ; Frontend/plugins können nachziehen, Tests können Lücken finden. |
| **Quelle** | `import placeholder_registrations` in `main.py`; `ensureDashboardWidgetsRegistered()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Fehlende Frontend-Registrierung zeigt „Unbekanntes Widget“ — kein Build-Time-Fail. |
### 5. Auto-Registration via Package-Import
| | |
|---|---|
| **Prinzip** | Side-Effect-Import eines Packages triggert vollständige Registrierung (`placeholder_registrations/__init__.py`). |
| **Begründung** | Keine vergessene manuelle Registrierungsliste in `main.py` pro Eintrag. |
| **Quelle** | `placeholder_registrations/__init__.py`; `main.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Import-Reihenfolge und zirkuläre Imports beachten. |
### 6. Validierung am Registry-Rand
| | |
|---|---|
| **Prinzip** | Unbekannte Keys/Widget-IDs/Feld-Mappings werden an Registry-Grenzen abgewiesen, nicht erst in der Business-Logik. |
| **Begründung** | Frühes, klares Fehlerbild für Admins und Entwickler. |
| **Quelle** | `DashboardWidgetEntry` + `ALLOWED_WIDGET_IDS`; `validate_field_mappings()`; `get_unknown_placeholders()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Prompt-Templates können unbekannte Platzhalter erst zur Laufzeit offenbaren. |
### 7. Konsumenten-Agnostik
| | |
|---|---|
| **Prinzip** | Dieselbe Registry bedient mehrere Konsumenten (KI, Charts, Export, Admin-Browser) ohne duplizierte Metadaten. |
| **Begründung** | DRY für Beschreibungen, Kategorien, Beispielwerte. |
| **Quelle** | `PLACEHOLDER_REGISTRY_FRAMEWORK.md` §2.2; `get_placeholder_catalog()` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy-Export-Pfade mergen noch „Registry + Legacy“. |
### 8. Erweiterungs-Checkliste statt Ad-hoc
| | |
|---|---|
| **Prinzip** | Jedes Registry hat dokumentierte Schritte A→G (Katalog-Eintrag, Validierung, Tests, Version-Bump). |
| **Begründung** | Agenten und Menschen erweitern konsistent; Review an Checkliste. |
| **Quelle** | `DASHBOARD_WIDGETS_AGENT_GUIDE.md` §2; `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §2; `PLACEHOLDER_DEVELOPMENT_GUIDE.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Drei separate Guides — kein unified „Registry Agent Guide“. |
### 9. Tests auf Katalog-Konsistenz
| | |
|---|---|
| **Prinzip** | Automatisierte Tests prüfen Eindeutigkeit, Reihenfolge, Payload-Shape, ID-Abgleich Kanon ↔ abgeleitete Sets. |
| **Begründung** | Regression wenn Katalog wächst but Registry/Layout nicht mitzieht. |
| **Quelle** | `test_widget_catalog.py`; Placeholder-Metadata-Tests |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein Cross-Registry-Test „Frontend widget IDs == backend catalog“. |
### 10. Optionale Entitlement-Referenz im Kanon
| | |
|---|---|
| **Prinzip** | Registry-Einträge **referenzieren** Feature-IDs (`requires_feature`), lösen Entitlements aber nicht selbst auf. |
| **Begründung** | Tier-Logik bleibt in `check_feature_access`; Katalog bleibt deklarativ. |
| **Quelle** | `WidgetCatalogEntry.requires_feature`; `dashboard_widget_entitlements.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Platzhalter haben kein direktes `requires_feature` — Gating nur indirekt. |
---
## Designprinzipien (spezifisch pro Registry)
### Platzhalter-Registry
| # | Prinzip | Tragfähigkeit | Schwäche |
|---|---------|---------------|----------|
| P1 | **Semantischer Vertrag** (`semantic_contract`) pro Key — Platzhalter sind API, nicht Prompt-Hilfe | hoch | Viele Legacy-Keys mit schwachem Vertrag |
| P2 | **Evidence-Tagging** — Herkunft jedes Metadatenfelds nachvollziehbar | mittel | Pflegeaufwand |
| P3 | **Cluster-Module** — Registrierung nach Domäne (`nutrition_part_a`, `body_metrics`, …) | hoch | 114 Keys Sync mit `PLACEHOLDER_MAP` |
| P4 | **Data-Layer-Referenz** in Metadata (`data_layer_function`) — Bindung an Layer 1 | hoch | Nicht alle Keys vollständig verknüpft |
| P5 | **Singleton** `get_registry()` — globaler Kanon | hoch | Test-Isolation braucht Disziplin |
### Dashboard-Widget-Registry
| # | Prinzip | Tragfähigkeit | Schwäche |
|---|---------|---------------|----------|
| W1 | **Backend-Katalog = ALLOWED_WIDGET_IDS** — Layout-Schema leitet ab | hoch | Frontend-Registry manuell parallel |
| W2 | **`merge_missing_catalog_widgets`** — neue Katalog-IDs erscheinen im Layout ohne User-Reset | hoch | — |
| W3 | **Strikte `config`-Validierung** nur für whitelisted Widgets (`WIDGETS_ALLOWING_CONFIG`) | hoch | Config-Schemas pro Widget heterogen |
| W4 | **Graceful Degradation** — unregistrierte ID → Fehler-Karte, nicht Crash | mittel | Maskiert Deploy-Fehler |
| W5 | **WidgetErrorBoundary** pro Instanz | hoch | — |
### CSV-Modul-Registry
| # | Prinzip | Tragfähigkeit | Schwäche |
|---|---------|---------------|----------|
| C1 | **`MODULE_DEFINITIONS` = einzige Feldliste** — Router duplizieren nicht | hoch | Activity erweitert dynamisch um `training_parameters` |
| C2 | **Deklarative Duplikat-Strategie** (`duplicate_key`, `update`/`skip`) | hoch | Modul-spezifische Executor-Sonderfälle |
| C3 | **`import_row_processing`** — Aggregation in Registry, nicht im Router | hoch | Legacy-Defaults in Modul-Def |
| C4 | **`validate_field_mappings`** vor Persistenz | hoch | Nutzer-Mappings teils ohne volle Validator-Parität (#71) |
| C5 | **Persistenz-Orchestrator** liest Registry-Felder (`activity_persistence_orchestrator`) | hoch | Nur Activity voll ausgebaut |
---
## Anti-Pattern: Doppel-Registry
Mitai zeigt an **Platzhaltern** das Risiko explizit:
```
placeholder_registrations/ ──register──► PlaceholderRegistry (Metadata)
│ ▲
└── resolver in code ──► PLACEHOLDER_MAP (Runtime, 114 Keys)
```
**Regel für Produktfamilie:** Runtime-Auflösung soll Metadata-Registry **lesen**, nicht spiegeln.
Widgets sind näher am Ideal: Backend `WIDGET_CATALOG` ist Kanon; Frontend muss IDs manuell in `registerDashboardWidgets.js` binden — akzeptabel, aber testbar machen (Cross-Check).
---
## Nicht übernehmen
1. **Parallele Runtime-Maps** — `PLACEHOLDER_MAP` + Registry; eine Quelle für Keys und Resolver-Pfad.
2. **Frontend-Registry ohne Build-/Test-Gate** — fehlende `registerDashboardWidget`-Einträge erst zur Laufzeit sichtbar.
3. **Metadaten-Duplikation in Export-Code** — hardcodierte Beschreibungen außerhalb der Registry.
4. **Registry-Einträge ohne Validierungs-Tests** — besonders bei 100+ Platzhaltern.
5. **Import-Feldlisten in Routern** — alles über `module_registry` (Mitai-Zielbild, noch nicht überall).
6. **Evidence-/Metadata-Pflicht für einfache Plugins** — 22 Felder für Widgets wären Overkill; **Metadaten-Tiefe an Risiko anpassen**.
7. **Dynamische Registry aus DB ohne Versionierung** — Widget-Feature-Overrides OK; kompletter Kanon nur in DB wäre schwer testbar.
8. **Registry ohne Deprecation-Pfad** — Breaking Key-Changes still (Platzhalter-Governance existiert, durchsetzen).
9. **Entitlements in Registry auflösen** — Widgets richtig: referenzieren; nicht Tier-Logik im Katalog.
10. **Schlaf-Modul leeres `fields: {}`** — Sondermodus (`import_mode`) statt sauberem Registry-Eintrag; technische Schuld.
---
## Entscheidungsmatrix: Welche Registry-Tiefe?
| Wenn … | dann Metadaten-Tiefe … | Beispiel |
|--------|------------------------|----------|
| Externe Verträge / KI / Compliance | Hoch (Vertrag, Evidence, Missing-Policy) | Platzhalter |
| UI-Plugin mit optionaler Config | Mittel (ID, title, config schema, feature ref) | Widgets |
| Daten-Ingest / Schema-Mapping | Hoch (Typen, keys, constraints) | CSV-Module |
| Internes Hilfsmodul | Minimal (ID + Handler-Ref) | — |
---
## Modul-Inventar (Querschnitt)
```
backend/
├── placeholder_registry.py
├── placeholder_registrations/ # Auto-import Cluster
├── placeholder_resolver.py # PLACEHOLDER_MAP (Legacy-Spiegel)
├── placeholder_registry_export.py
├── widget_catalog.py
├── dashboard_layout_schema.py
├── dashboard_widget_config.py
├── dashboard_widget_entitlements.py
├── widget_feature_requirements_db.py
└── csv_parser/
└── module_registry.py
frontend/src/
├── widgetSystem/
│ ├── registerDashboardWidgets.js
│ └── dashboardWidgetRegistry.jsx
└── components/workflow/panels/PlaceholderPicker.jsx
```
---
## Verwandte Dokumentation
- Platzhalter: [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md), [PLACEHOLDER_GOVERNANCE.md](../../../../docs/PLACEHOLDER_GOVERNANCE.md)
- Widgets: [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md)
- Import: [UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md](../../technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md)
- Entitlements (Widget-Gating): [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
- Data Layer (Platzhalter-Berechnung): [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md)
- Prompt Engine (Platzhalter-Konsument): [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
---
## Geplante Folgedokumente (Serie)
| # | Modul | Status |
|---|-------|--------|
| 1 | Prompt Engine | ✅ |
| 2 | Data Layer | ✅ |
| 3 | Feature & Entitlement | ✅ |
| 4 | Registry-/Plugin-Muster | ✅ dieses Dokument |
| 5 | Auth & Session | ✅ `AUTH_SESSION_DESIGN_PRINCIPLES.md` |
| 6 | Universal Import | ✅ |
| 7 | Dashboard Widgets | ✅ |
| 8 | Navigation / IA | ✅ |
| 9 | Migration & Deploy | ✅ |
*Hinweis:* Dokumente 6 und 7 vertiefen Einzel-Registries; dieses Meta-Dokument ist die übergreifende Extraktion.

View File

@ -0,0 +1,325 @@
# Universal CSV Import – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Universal CSV Import (Issue #21) — Ingest/Mapping/Persistenz, keine Auswertungslogik
**Serie:** Designprinzipien für Produktfamilie · Dokument 6 von n
**Vorgänger:** [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Modul-Kanon | `backend/csv_parser/module_registry.py` |
| Ausführung | `backend/csv_parser/executor.py` |
| Parsing/Typen | `core.py`, `type_converter.py`, `field_units.py` |
| Aggregation | `import_row_processing.py` |
| Validierung | `template_validator.py` |
| Fehler-Hints | `import_errors.py` |
| Mapping-Vorschläge | `mapping_suggest.py` |
| Nutzer-API | `backend/routers/csv_import.py` |
| Admin-Vorlagen | `backend/routers/admin_csv_templates.py` |
| Persistenz-Orchestrator | `data_layer/activity_persistence_orchestrator.py` |
| Leitfaden | `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` |
| Import-Grenze | `.claude/rules/ARCHITECTURE.md` §8 |
---
## Modul
**Universal CSV Import**
Konfigurierbare Pipeline: **CSV-Datei → Feld-Mapping → Typkonvertierung → (optional Aggregation) → DB-Upsert** — mit Vorlagen, Audit-Log und row-level Fehlertoleranz.
---
## Fachliche Verantwortung
Das Modul übernimmt:
1. **Modul-Registry** — Welche Zieltabellen/Felder importierbar sind (Typen, Duplikat-Keys, Strategien).
2. **Vorlagen (Mappings)** — System-Templates (Admin) + Nutzer-Kopien (`csv_field_mappings`).
3. **Analyse** — Delimiter-Erkennung, Spalten-Signatur, Mapping-Vorschläge, Diagnose einzelner Zeilen.
4. **Ausführung** — Upsert pro Modul, `source=csv`, Statistik, `affected_ids`.
5. **Fehlertransparenz** — Row-level Errors mit `code`/`hint`; kein Silent-Fail der ganzen Transaktion.
6. **Audit** — `csv_import_log` mit Status, Counts, betroffenen IDs.
7. **Limits** — Dateigröße/Zeilen aus `system_config`; Feature-Entitlements pro Modul.
Es übernimmt **nicht**:
- Fachliche Metriken / Scores (→ Data Layer, siehe [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md))
- Prompt-/KI-Logik
- Vollständiger Ersatz aller Legacy-Import-Endpoints (noch parallel)
### Pipeline (Happy Path)
```
Upload CSV
→ decode_raw_bytes + resolve_effective_csv_delimiter
→ Vorlage laden (csv_field_mappings)
→ validate (optional Admin) / feature check
→ run_universal_csv_import(cur, …) // eine Transaktion
→ build_row_after_mapping (type_converter)
→ aggregate_mapped_rows (import_row_processing)
→ UPSERT / activity_persistence_orchestrator
→ csv_import_log UPDATE + increment_feature_usage
```
### Unterstützte Module (Registry)
| Modul | Zieltabelle | Besonderheit |
|-------|-------------|--------------|
| `nutrition` | `nutrition_log` | Tages-Aggregation |
| `weight` | `weight_log` | Duplikat: profile + date |
| `activity` | `activity_log` | SAVEPOINT pro Zeile; EAV via Orchestrator |
| `vitals_baseline` | `vitals_baseline` | Tages-Aggregation |
| `blood_pressure` | `blood_pressure_log` | Composite `measured_at` |
| `sleep` | `sleep_log` | Legacy-Adapter `import_mode: apple_sleep_aggregate` |
---
## Administrierte vs. code-definierte Konfiguration
| Konfiguration | Speicherort | Wer pflegt? |
|---------------|-------------|-------------|
| Zielfelder, Typen, Duplikat-Keys | `MODULE_DEFINITIONS` | Entwickler (Code) |
| System-Vorlagen | `csv_field_mappings` (`is_system=true`) | Admin (+ Migration Seeds) |
| Nutzer-Mappings | `csv_field_mappings` (`profile_id`) | Nutzer (Kopie/Anpassung) |
| `field_mappings`, `type_conversions`, `import_row_processing` | JSONB in Vorlage | Admin/Nutzer |
| Import-Limits | `system_config.csv_import` | Admin |
| Delimiter-Sniffing-Heuristik | `core.py` | Code |
| Header-Aliases (Vorschläge) | `mapping_suggest.py` | Code |
**Bewusst nicht in Routern hardcodiert:** Feldlisten, Duplikat-Logik — nur Registry + Executor.
---
## Designprinzipien
### 1. Module Registry als Single Source of Truth
| | |
|---|---|
| **Prinzip** | Alle erlaubten Zielfelder, Typen und Duplikat-Keys leben in `MODULE_DEFINITIONS` — Router duplizieren nicht. |
| **Begründung** | Admin-UI, Validator, Executor und `/api/csv/modules` bleiben synchron. |
| **Quelle** | `module_registry.py`; Agent-Guide §1 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Activity erweitert Felder dynamisch aus `training_parameters` (DB). |
### 2. Ingest vs. Interpretation (Import-Grenze)
| | |
|---|---|
| **Prinzip** | Import: Mapping + Typ/Einheit + Duplikat/Upsert. **Keine** fachliche Auswertung beim Insert. |
| **Begründung** | Semantik gehört in Data Layer; Import bleibt austauschbar und testbar. |
| **Quelle** | `ARCHITECTURE.md` §8; [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `sleep_apple_import.py` ist Legacy-Adapter mit quellenspezifischer Logik. |
### 3. Vorlagen trennen Struktur von Datei
| | |
|---|---|
| **Prinzip** | `csv_field_mappings` speichert Modul, Delimiter, Header-Flag, Mappings, Conversions, Row-Processing — unabhängig vom Upload. |
| **Begründung** | Wiederverwendung (Apple Health, Omron, …); Nutzer wählt Vorlage statt jedes Mal neu zu mappen. |
| **Quelle** | Migration 042; Admin + User APIs |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nutzer-Kopien nicht immer durch `validate_csv_template` (#71). |
### 4. Effektives Trennzeichen aus Datei, nicht blind aus Vorlage
| | |
|---|---|
| **Prinzip** | `resolve_effective_csv_delimiter` — DE-Export (`;`) vs. EN-Vorlage (`,`) wird aus Header-Feldanzahl erkannt. |
| **Begründung** | Regionale CSV-Exporte brechen sonst das gesamte Mapping (eine Spalte). |
| **Quelle** | `core.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Heuristik, kein 100%-Garant für exotische Formate. |
### 5. Typ- und Einheiten-Konvertierung deklarativ
| | |
|---|---|
| **Prinzip** | `type_conversions` + `source_unit` in Vorlage; Logik in `type_converter` / `field_units`. |
| **Begründung** | kJ→kcal, Datumsformate, Dezimal-Komma ohne Code pro Quelle. |
| **Quelle** | `type_converter.py`, `field_units.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Falsche `source_unit` → DB-Overflow; `enrich_row_error` hilft nachträglich. |
### 6. Zeilen-Aggregation vor Upsert
| | |
|---|---|
| **Prinzip** | `import_row_processing` (group_by + aggregates) fasst mehrere CSV-Zeilen pro logischem Tag/Datensatz zusammen. |
| **Begründung** | Ernährung/Vitals: viele Rohzeilen → ein Tageseintrag. |
| **Quelle** | `import_row_processing.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Modul-Default als Legacy-Fallback wenn Vorlage leer; Admin „Format prüfen“ kann Processing auslassen. |
### 7. Ein Cursor, eine Transaktion, SAVEPOINT pro Zeile
| | |
|---|---|
| **Prinzip** | `run_universal_csv_import(cur, …)` nutzt **bestehenden** Cursor; bei Row-Fehlern SAVEPOINT + ROLLBACK TO, nicht ganze Xact abbrechen. |
| **Begründung** | PostgreSQL „transaction aborted“; partielle Imports mit Fehlerliste. |
| **Quelle** | `executor.py` (activity, vitals); `csv_import.py` SAVEPOINT `csv_import_exec` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht alle Module gleich implementiert; Disziplin pro Modul. |
### 8. Kein verschachteltes get_db im Importpfad
| | |
|---|---|
| **Prinzip** | FK-Auflösung (z. B. Trainingstyp) und Activity-Persistenz mit **demselben** `cur` wie der Import. |
| **Begründung** | Pool-Deadlocks, konsistente Transaktion. |
| **Quelle** | `_resolve_training_type_for_activity`; Agent-Guide §2 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Lazy-Import aus Router in Executor (Kopplung). |
### 9. Strukturierte Fehler mit Hints
| | |
|---|---|
| **Prinzip** | `enrich_row_error()` mappt DB-/Parse-Fehler auf `code` + menschenlesbaren `hint`. |
| **Begründung** | Nutzer/Admin können Vorlagen korrigieren ohne PostgreSQL-Kenntnis. |
| **Quelle** | `import_errors.py`; Import-Response `error_details` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Heuristische String-Matches, nicht vollständig. |
### 10. Vorlagen-Validierung vor Persistenz (Admin)
| | |
|---|---|
| **Prinzip** | `validate_csv_template` → `{ valid, errors[], warnings[] }`; Admin Create/Update → HTTP 422 bei Fehlern. |
| **Begründung** | Fehler früh, nicht erst beim Nutzer-Import. |
| **Quelle** | `template_validator.py`; `admin_csv_templates.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Dry-Run / User-Mappings Lücken (#71). |
### 11. System- vs. User-Mappings (Permissions)
| | |
|---|---|
| **Prinzip** | `is_system=true`: nur Admin editierbar; Nutzer kopiert und passt eigene Zeile an. |
| **Begründung** | Shipped Templates schützen; Individualisierung erlauben. |
| **Quelle** | `permissions.py`; DB CHECK + Unique Indexes |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 12. Import-Audit und Rollback-Vorbereitung
| | |
|---|---|
| **Prinzip** | Jeder Lauf schreibt `csv_import_log` mit Counts, `error_details`, `affected_ids` (PKs pro Tabelle). |
| **Begründung** | Nachvollziehbarkeit, spätere Bereinigung/Rollback, Erfolgsrate pro Vorlage. |
| **Quelle** | Migration 042; `csv_import_execute` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Automatischer Rollback-Button nicht überall umgesetzt. |
### 13. Feature-Entitlements an Import gebunden
| | |
|---|---|
| **Prinzip** | `data_import` global + modulspezifisch (`nutrition_entries`, …); Increment nur für **neue** Zeilen. |
| **Begründung** | Konsistent mit Membership-System. |
| **Quelle** | `csv_import.py` `_check_module_feature_access`, `increment_feature_usage` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Bulk-Increment-Schleife ineffizient (wie Feature-Doc). |
### 14. Mapping-Vorschläge (Heuristik, nicht Autorität)
| | |
|---|---|
| **Prinzip** | `mapping_suggest.py` schlägt Spalten-Zuordnung aus Header-Aliases vor — Admin bestätigt. |
| **Begründung** | Schneller Editor-Start; Kanon bleibt menschlich/administrativ freigegeben. |
| **Quelle** | `_MODULE_HEADER_ALIASES` |
| **Tragfähigkeit** | **mittel–hoch** |
| **Einschränkung** | Domänenspezifische Aliases hardcodiert (DE/EN). |
### 15. Persistenz-Orchestrator für komplexe Domänen
| | |
|---|---|
| **Prinzip** | Activity: nach Registry-Mapping → `activity_persistence_orchestrator` (Upsert + EAV + Eval-Hook). |
| **Begründung** | Gleiche Schreiblogik wie REST-API; kein divergierender CSV-Pfad. |
| **Quelle** | `activity_persistence_orchestrator.py`; [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) §9 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nur Activity vollständig; andere Module direkt im Executor. |
---
## Nicht übernehmen
1. **Parallele Legacy-Import-Endpoints** — `/api/nutrition/import-csv`, `/api/activity/import-csv` neben Universal-Pfad; neue Quellen nur über Universal + Vorlage (ARCHITECTURE §8.2).
2. **Quellenspezifische Aggregat-Logik im Import** — `sleep_apple_import` als Dauerlösung; Ziel: mapping-nah + Layer 1 (Gitea #69).
3. **Feldlisten in Routern** — jede neue Spalte nur via `module_registry` + Migration.
4. **Verschachtelte DB-Connections im Executor** — Pool-Risiko; immer Caller-`cur` durchreichen.
5. **Transaktion ohne SAVEPOINT bei Multi-Row-Import** — ein Fehler killt gesamten Import + opaque „transaction aborted“.
6. **Blindes Vorlagen-Delimiter** — regionaler Export bricht Mapping.
7. **Nutzer-Mappings ohne Validierung** — #71; Copy-from-System muss durch Validator.
8. **Dry-Run ohne `import_row_processing`** — Admin „Format prüfen“ unvollständig vs. echter Import.
9. **`source`-CHECK in DB vergessen** — Import setzt `csv`, Constraint muss Migration sein.
10. **NUMERIC-Overflow durch falsche Einheit** — Schema + `source_unit` + Migrationbreite gemeinsam planen.
11. **Interpretation/Auswertung beim Import** — Scores, TDEE, Training-Quality nicht in `executor.py`.
12. **Executor-Monolith ohne Modul-Split** — `executor.py` wächst pro Modul; langfristig Executor-Strategie pro Registry-Key.
---
## Modul-Inventar (Ist-Stand)
```
backend/csv_parser/
├── module_registry.py # MODULE_DEFINITIONS
├── executor.py # run_universal_csv_import
├── core.py # decode, delimiter, limits
├── type_converter.py
├── field_units.py
├── import_row_processing.py
├── template_validator.py
├── import_errors.py
├── mapping_suggest.py
├── permissions.py
└── sleep_apple_import.py # Legacy-Adapter
backend/routers/
├── csv_import.py # Nutzer: modules, analyze, import, mappings
└── admin_csv_templates.py # Admin: CRUD + validate
DB:
├── csv_field_mappings
└── csv_import_log
```
---
## Verwandte Dokumentation
- Agent-Guide (normativ): [UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md](../../technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md)
- Registry-Meta: [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)
- Import-Grenze: [ARCHITECTURE.md](../../../rules/ARCHITECTURE.md) §8
- Feature-Limits: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
- Gitea #71: Dry-Run, User-Mapping-Validierung
---
## Geplante Folgedokumente (Serie)
| # | Modul | Status |
|---|-------|--------|
| 1–5 | … | ✅ |
| 6 | Universal Import | ✅ dieses Dokument |
| 7 | Dashboard Widgets | ✅ `DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md` |
| 8 | Navigation / IA | ✅ |
| 9 | Migration & Deploy | ✅ |

View File

@ -0,0 +1,317 @@
# Activity Session Metrics: Composite-Daten (EAV) – Umsetzungskonzept
**Stand:** 2026-04-16
**Status:** Normatives Konzept zur nahtlosen Weiterarbeit durch Code-Agenten
**Bezieht sich auf:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (§2.3–2.4, Phasen D–E), `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`, Issue #53 (Layer-1-Prinzip: Auswertungen nur über `data_layer`)
---
## 1. Ziel und Abgrenzung
### 1.1 Ziel
- **Composite-Messgrößen** (strukturierte Werte mit mehreren benannten Slots) werden wie **normale Trainingsparameter** im Katalog geführt, **Kategorie-/Typ-Profilen** zugeordnet und pro Session in der **EAV-Tabelle** persistiert.
- **Persistenz:** ein JSON-Dokument pro Session und `training_parameter_id` (kanonisch **JSONB**), kompatibel mit der bestehenden „eine Zeile pro Parameter“-Semantik.
- **Import:** CSV liefert typischerweise **eine Spalte pro atomarem Slot**; das Mapping verweist auf **`(Parameter-Key, Slot-Key)`** (stabile Strings, nicht Spaltenreihenfolge).
- **Layer 1:** liefert für Consumer weiterhin **eine konsistente API**: Rohdokument **und** optional **aufgelöste Einzelwerte** (flach oder namenspaced), ohne dass Charts/Platzhalter direkt JSON parsen müssen.
### 1.2 Nicht-Ziele (explizit)
- Kein „freies“ JSON-Schema im Admin ohne Archetyp-Bindung (verhindert Datenmüll und nicht validierbare Dokumente).
- Keine Abschwächung bestehender **Skalar-Parameter** (`integer`, `float`, `string`, `boolean`): alle bisherigen Pfade bleiben gültig.
- Kein Ersatz für `activity_log`-**Spine** oder Session-Qualitätsblobs (`evaluation`, …).
### 1.3 Kompatibilitätsgarantie („keine Regression“)
| Bereich | Maßnahme |
|---------|----------|
| DB | Nur **additive** Migrationen; bestehende `CHECK`-Regeln für Skalare bleiben für Zeilen **ohne** Composite erhalten bzw. werden zu einer **Oder-Verknüpfung** erweitert (siehe §4). |
| `training_parameters` | Neuer `data_type`-Wert **`composite`** zusätzlich zu den vier bestehenden; bestehende CHECK-Constraint muss erweitert werden (Migration). |
| `activity_session_metrics` | Skalare Zeilen unverändert; Composite-Zeilen nutzen **`value_json`** (neu), alle `value_*` NULL. |
| Layer 1 | `resolve_activity_attribute_schema`, Merge, Replace: Composite erscheint als **ein** Schema-Eintrag; Lese-/Schreibpfade erweitern, nicht ersetzen. |
| CSV | Bestehende Map-Ziele auf Skalare/Registry unverändert; neue Zielnotation nur für Composites. |
| Admin | tcp/ttp-UI: gleiche Zuordnung wie heute; Zusatzfelder nur bei `data_type === composite`. |
### 1.4 Abgleich mit `functional_concept_composite_data.md` (fachliches Konzept)
Das **fachliche Konzeptpapier** (Composite Scalar/Layer-Trennung) und dieses **Umsetzungskonzept** sind **vereinbar**, wenn die Rollen klar getrennt bleiben:
| Thema | Fachliches Konzept (`functional_concept_composite_data.md`) | Dieses Umsetzungskonzept (technisch) |
|--------|-------------------------------------------------------------|--------------------------------------|
| **Speicher in der DB** | Einheitlicher Store; Composite = `jsonb` mit **kleinem Basisschema** (`v`, `kind`, `domain`, `items`, optional `basis`, `meta`) | `activity_session_metrics.value_json`; CHECK Skalar vs. Composite |
| **Technische Container** | Genau **vier** `kind`-Werte: `group_set`, `distribution_set`, `sequence_set`, `model_set` | Layer-1-Validierung **muss** diese Hülle durchsetzen; kein freies JSON ohne `kind`/`v`/`items` |
| **„Archetypen“** | **Fachliche** Ausprägungen werden in **Layer 2a** aus L1-Objekten abgeleitet | Benannte **Preset-/Validierungsprofile** im Code (z. B. Zonenverteilung HF) sind **kein** zweites Persistenz-Schema: sie legen fest, *welches* der vier `kind`-Muster, *welches* `domain`, *welche* Item-Keys/Typen erlaubt sind — inkl. CSV-Slot-Mapping |
| **Layer 1** | Validiert, minimal normalisiert, **keine** Scores/Bewertungen/KI-Texte | Validator + Merge + optional `expand_*` (**technische** Flachstellung für Consumer, z. B. `param.slot` → Skalar) |
| **Layer 2** | Diagramme, Kennzahlen, KI-Platzhalter-**Formulierung** | unverändert; konsumiert L1 (und ggf. L2a) |
**Konsequenz für die Registry:** Statt „8 freie JSON-Archetypen“ implementiert die Code-Registry **Validierungs-Presets**, die alle auf die **vier technischen `kind`-Formen** abbilden. Die Tabelle in §3 beschreibt weiterhin **fachlich benannte MVP-Anker** — technisch übersetzen sie sich in `(kind, domain, Item-Regeln, v)`.
**Konsequenz für Platzhalter:** Roh-JSON aus der DB **nicht** ungefiltert in Prompts; L2b nutzt L1/L2a-Aufbereitung (wie im fachlichen Konzept).
---
## 2. Begriffe
| Begriff | Bedeutung |
|---------|-----------|
| **Archetyp** | Im **Repo versionierte** Strukturvorlage (erlaubte Slots, Typen, Pflichtfelder, Validator, Version). **7–8** Stück geplant; Erweiterung nur per Code-Release. |
| **Slot** | Benanntes Teilfeld innerhalb des Composite-Dokuments, z. B. `z1_sec`, `z2_sec`, `avg_cadence`. |
| **Parameter-Instanz** | Eine Zeile in `training_parameters` mit `data_type = composite` und Metadaten, **welcher** Archetyp gilt (siehe §5). |
| **Dokument** | Ein JSON-Objekt, das alle Slots abbildet; gespeichert in `activity_session_metrics.value_json`. |
---
## 3. Archetypen-Katalog (Planungsstand) — fachliche Namen → technische `kind`-Presets
Die **konkrete** Slot-Liste und Validierung wird im Code als **Registry** geführt (z. B. `backend/data_layer/activity_composite_archetypes.py`). Jedes Preset **mappt** auf genau eines von **`group_set` | `distribution_set` | `sequence_set` | `model_set`** und erfüllt das **Basisschema** aus `functional_concept_composite_data.md` §7.
Inhaltlich orientiert an `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.4.
**Beispielhafte fachliche MVP-Anker** (8 Kandidaten; im Code als Preset-Key + `kind`/`domain` abbilden):
| `archetype_key` (stabil) | Kurzbeschreibung | Typische Slots (Beispiel) |
|--------------------------|------------------|---------------------------|
| `hr_zone_distribution` | Zeit-/Anteil je HF-Zone | `z1_sec`…`z5_sec` oder `zones[]` |
| `power_zone_distribution` | Leistungszonen | analog |
| `pace_band_profile` | Pace-Bänder / Histogramm | bucket-Struktur |
| `interval_block_summary` | Intervallblöcke aggregiert | `blocks[]` mit Dauer, Ziel, Ist |
| `event_marker_sequence` | Ereignisse mit Zeitstempel | `events[]` |
| `coupling_efficiency_profile` | Kopplungs-/Effizienzmetriken | sportabhängig |
| `model_parameter_profile` | Modell-/Schwellenparameter | key-value-ähnlich, validiert |
| `readiness_recovery_snapshot` | optional: kurzes Multi-Signal-Bundle | nur wenn fachlich gewünscht |
**Regel:** Jeder Archetyp hat `version` (Integer). Validator lehnt Dokumente mit falscher/fehlender Version ab oder migriert definiert (nur wenn spezifiziert).
---
## 4. Datenmodell-Erweiterungen
### 4.1 `training_parameters`
**Migration (additiv):**
1. `CHECK (data_type IN (...))` erweitern um **`composite`**.
2. Optional eigene Spalte **`composite_archetype_key` `VARCHAR(64)`** (NOT NULL wenn `data_type = composite`, sonst NULL) — **oder** ausschließlich in `validation_rules` speichern (siehe unten).
**Empfehlung:** Spalte `composite_archetype_key` + `composite_archetype_version INT` für einfache Admin-Queries und klare Semantik; `validation_rules` für archetyp-spezifische Feinheiten (z. B. erlaubte Zonenanzahl).
**Konsistenz-Constraint (DB oder App):**
- Wenn `data_type = composite`: `composite_archetype_key` gesetzt, `source_field` typischerweise **NULL** (kein `activity_log`-Skalar-Shadowing).
- `unit` am Parameter: optional für „Anzeige-Einheit“ des Gesamtwerts oder leer; Slots haben Einheiten im Archetyp oder in Slot-Metadaten.
### 4.2 `activity_session_metrics`
**Migration (additiv):**
```text
value_json JSONB NULL
```
**CHECK-Constraint ersetzen/erweitern** (Konzept):
- **Modus Skalar:** genau eine der Spalten `value_num`, `value_int`, `value_text`, `value_bool` ist NOT NULL; `value_json` IS NULL.
- **Modus Composite:** `value_json` IS NOT NULL; alle vier Skalar-Spalten IS NULL.
Damit bleibt die bestehende Semantik „eine Zeile = ein Parameter“ erhalten.
**Kommentar:** Tabelle trägt weiterhin „EAV“; Composites sind **keine** zusätzlichen Zeilen pro Slot.
### 4.3 Profil-Zuordnung (tcp / ttp)
**Keine** Tabellenänderung: `training_category_parameter` und `training_type_parameter` verweisen weiter nur auf `training_parameter_id`. Composite-Parameter verhalten sich wie Skalare in Bezug auf **Zuordnung**, **sort_order**, **required**, **ui_group**.
**`required`:** bedeutet „Dokument muss nach Validator vollständig sein“, nicht „jede CSV-Spalte muss in jeder Zeile vorkommen“.
---
## 5. Metadaten pro Composite-Parameter
Minimal in der DB (Beispiel):
| Feld | Zweck |
|------|--------|
| `data_type` | `composite` |
| `composite_archetype_key` | Verweis auf Code-Registry |
| `composite_archetype_version` | Schema-Version |
| `validation_rules` | optional: Overrides (z. B. `max_zones`, sport-spezifisch) — nur was der Validator explizit auswertet |
**Admin-API:** bestehende Endpoints erweitern (Payload-Validierung): bei `composite` müssen Archetyp + Version gesetzt sein und in der **Registry** existieren.
---
## 6. Layer 1 – Kontrakt (`activity_session_metrics.py` + Helfer)
### 6.1 Schema-Auflösung
`resolve_activity_attribute_schema` liefert pro Composite **einen** Eintrag wie bei Skalaren, mit:
- `data_type: "composite"`
- `composite_archetype_key`, `composite_archetype_version` (aus DB oder Join)
- ggf. `composite_slot_catalog`: **nur wenn** für Admin/UI gewünscht — alternativ separater Endpoint `GET .../composite-archetypes` (read-only) aus Registry, um Bundle-Größe klein zu halten.
### 6.2 Lesen / Merge
- `fetch_activity_session_metrics`: SELECT inkl. `value_json`.
- `merge_column_backed_and_eav_metrics`: Composites **nur** aus EAV (`value_json`), kein `activity_log`-Shadowing (außer später explizit im Kanon — Standard: nein).
- Ausgabe in `metrics`-Liste: ein Eintrag pro Parameter mit z. B.
`value: { "_composite": true, "document": { ... } }` **oder** kanonisch getrennt: `value_document` + `value` null — **festlegen beim Implementieren** und in API-Doku halten; Empfehlung: **`value` = deserialisiertes Objekt (dict)** für Composites, damit Frontend dieselbe Struktur wie Speicher hat.
### 6.3 „Einzelwerte für Layer 1 / Issue 53“
Neue **pure** Funktion (kein SQL im Router), z. B.:
```text
expand_composite_metrics_for_session(
schema: list[dict],
metrics: list[dict],
) -> dict[str, Any]
```
- Input: effektives Schema + gemergte Metriken.
- Output: flaches Dict **`slot_path → typisierter Wert`**, z. B.
`hr_zones.z1_sec → 1200`, oder namespaced Keys `training_param_key.slot_key` zur Kollisionssicherheit.
- Nutzung: `activity_metrics`, Chart-Builder, später Platzhalter-Registry (`data_layer_function`), **ohne** JSON-Parsing in Layer 2.
**Wichtig:** Skalare Parameter erscheinen im expandierten Dict mit ihrem `parameter_key` wie bisher (kein Breaking Change für Consumer, die nur Skalare erwarten).
### 6.4 Validierung / Schreiben
- **`replace_activity_session_metrics`:** Payload-Item für Composite: `value` ist **Objekt** (dict) oder JSON-String — Server normalisiert zu dict, validiert mit Archetyp-Validator, speichert als `value_json`.
- **`upsert_session_metrics_from_csv_mapped`:** siehe §7 (Zusammenbau aus Partial-Updates pro Zeile).
**Pflicht:** Keine Teil-Updates in DB, die ein halbes Dokument hinterlassen, ohne Validierung — außer explizit als „Draft“-Modus spezifiziert (nicht Teil dieses Konzepts).
---
## 7. CSV / Universal Import
### 7.1 Map-Ziel-Notation
Stabiles Muster (Vorschlag, im Import-Modul zentral parsen):
```text
"<parameter_key>.<slot_key>"
```
Beispiel: `my_hr_zones.z1_sec` → nach Import-Zusammenfügung in den Parameter `my_hr_zones` unter Slot `z1_sec`.
**Alternative:** explizites Präfix `composite:` in der Vorlage — nur nötig, wenn Kollisionen mit normalen Keys befürchtet werden; sonst Punkt-Notation reicht.
### 7.2 Executor-Flow (Konzept)
1. `build_row_after_mapping` liefert flache Keys inkl. `param.slot`.
2. Nach Schreiben von `activity_log` / Skalar-EAV: **Composite-Accumulator** pro `activity_log_id` und `parameter_key`:
- Sammelt alle Slot-Werte aus der Zeile.
3. Vor Commit der Zeile (oder am Ende der Datei — **pro Zeile empfohlen**, damit SAVEPOINT pro Row funktioniert):
- Dokument aus Slots bauen → Validator → Upsert `activity_session_metrics` mit `value_json`.
**Teilbefüllung:** Validator entscheidet (Archetyp: optional vs. required Slots). CSV darf nur Teilmengen liefern, wenn Archetyp erlaubt.
### 7.3 Typkonvertierung
Pro **Slot** im Archetyp: definierter skalarer Typ (`float`, `int`, …). Converter wie bei Skalaren (Executor / zentrale Converter), **keine** Parallel-Logik in Routern.
---
## 8. Admin-UI / Mapping-UX
### 8.1 Parameter anlegen
- Auswahl **Datentyp „Composite“** → Dropdown **Archetyp** (aus Registry-API), Version readonly oder wählbar gemäß Policy.
- Rest wie Skalar: Name, Kategorie (`training_parameters.category`), Aktiv-Flag.
### 8.2 Profil zuordnen
Unverändert: Kategorie-/Typ-Matrix wie heute.
### 8.3 Universal-CSV-Vorlage
- Mapping-Ziele: neben bisherigen Keys **Slot-Ziele** `parameter_key.slot_key`.
- UI-Gruppierung: optisch **Composite-Block** (wie in `ACTIVITY_PRODUCTION_ARCHITECTURE` §2.5 angedeutet), um Verwechslung mit Spine-Spalten zu vermeiden.
---
## 9. API-Oberflächen (Erweiterungen)
| Bereich | Änderung |
|---------|-----------|
| `GET /api/activity/{id}` | `metrics` enthält Composite-Werte als Objekt; `schema` kennzeichnet `data_type: composite`. |
| `PUT /api/activity/{id}/metrics` | Eintrag `{ parameter_key, value: { ... } }` für Composites. |
| Admin `training-parameters` | Create/Update mit Composite-Feldern. |
| Optional | `GET /api/admin/composite-archetypes` | Registry export für UI (Keys, Slot-Liste, Version). |
**Rückwärtskompatibilität:** Clients, die nur Skalare senden, unverändert.
---
## 10. Frontend (Kurz)
- `ActivityPage` / Session-Metrik-Editor: für `data_type === composite` **strukturierte Teilfelder** aus Slot-Katalog rendern (oder JSON-Editor nur als Entwickler-Fallback — Produkt: strukturierte Felder).
- Sortierung/Gruppierung: bestehende `param_category` / `ui_group` / `sort_order` gelten unverändert.
---
## 11. Tests (pytest)
| Test | Beschreibung |
|------|----------------|
| Archetyp-Validator | gültige / ungültige Dokumente je Version |
| DB-Constraint | Skalar vs. Composite Ausschluss |
| `expand_composite_metrics_for_session` | flache Keys, Kollisionen |
| CSV-Zusammenbau | mehrere Spalten → ein `value_json` |
| Regression | bestehende `test_activity_session_metrics.py` unverändert grün halten |
---
## 12. Rollout-Phasen (operativ)
Stimmt mit `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` überein:
1. **Phase D – MVP:** ein Preset (z. B. HF-Zonen → `distribution_set`, `domain: heart_rate`), Migration `value_json` + `composite` data_type, Validator gegen Basisschema §7, Import 3–5 Spalten → `items`, GET/PUT, minimale Admin-Anbindung.
2. **Phase E:** weitere Presets / `kind`-Varianten, Mapping-UX, `expand_*` für ausgewählte Layer-1-Consumer.
3. **Phase F:** Observability, Performance, Doku, Gitea-Issues schließen.
### 12.1 Empfohlene Reihenfolge: Skalar-Pipeline vs. Composite-Speicherung
**Frage:** Zuerst Skalar-EAV vollständig bis Platzhalter/Orchestrator abschließen, oder zuerst Composite-Speicherung?
| Option | Vorteil | Risiko |
|--------|---------|--------|
| **A: Nur Skalar zuerst** (Kanon, L1-Härtung, Platzhalter aus EAV/L1) | Eine klare, end-to-end **Referenzpipeline**; weniger gleichzeitige Variablen | Composite-Datenstrome verzögern sich |
| **B: Composite-Speicher zuerst** | JSON landet früh in der DB | Platzhalter/Charts nutzen noch **alte** Pfade → **zwei Wahrheiten** (Detail-API vs. KI) bis L1 vereinheitlicht ist |
| **C (Empfehlung): Skalar L1 + Platzhalter-Orchestrierung *vor* Composite-MVP**, oder **eng parallel** mit gemeinsamem L1-Einstieg | `get_activity_session_logical_unit` / `activity_metrics` werden **kanonisch**; Platzhalter lesen **dieselbe** Schicht; Composite wird **additiv** (`value_json` + Validator + später `expand_*`) | Erfordert kurze Planungsdisziplin: Composite-MVP **ohne** sofort alle KI-Platzhalter |
**Konkrete Empfehlung**
1. **`ACTIVITY_PRODUCTION` Phase A–B** nicht überspringen: Kanon „eine Semantik / eine Quelle“ + alle relevanten Consumer über **Layer 1** (mind. Session-Detail, Listen-Anreicherung, erste Platzhalter-Pfade für **Skalare**).
2. **Dann Phase D (Composite-MVP):** Migration + Speichern/Lesen mit **Basisschema** (`kind`/`items`/…); L1 liefert dasselbe API-Objekt wie Skalare, nur `value` als strukturiertes Dokument.
3. **Platzhalter für Composite:** erst **nach** L1 liefert stabil `value_json` **und** optional `expand_composite_metrics_*` — ein Orchestrator-Endpoint bzw. Resolver-Aufruf, der **eine** L1-Funktion nutzt, vermeidet doppelte Logik für Skalar vs. Composite.
**Kurz:** Composite **persistieren** kann kurz nach stabiler **Skalar-Lese-/Merge-API** folgen; **KI/Platzhalter für Composite** sinnvoll **gemeinsam** mit der erweiterten L1-Ausgabe bauen, nicht gegen eine noch nicht vereinheitlichte Skalar-Pipeline.
---
## 13. Checkliste für den nächsten Agenten
- [ ] Migration: `value_json`, erweiterte CHECKs, `training_parameters.data_type` + ggf. `composite_archetype_*` Spalten.
- [ ] Registry-Modul: Archetypen + Versionen + Slot-Metadaten + Validator-Einstieg.
- [ ] `activity_session_metrics.py`: Fetch/Merge/Replace/Upsert-Integration; keine Regression für Skalare.
- [ ] Optional: `expand_composite_metrics_for_session` + erste Nutzung in einem Layer-1-Consumer (Tests).
- [ ] CSV: Parser für `parameter_key.slot_key`, Row-Accumulator, Fehler melden wie bestehender Import.
- [ ] Admin-API + UI: Composite anlegen, tcp/ttp unverändert nutzbar.
- [ ] Doku: dieses Dokument mit **festgelegter** JSON-Beispielstruktur pro MVP-Archetyp ergänzen.
---
## 14. Referenzen
- `functional_concept_composite_data.md` – **fachliches** Schichtenmodell, vier technische `kind`-Container, Basisschema JSON
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` – Zielbild, Phasen A–F
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` – Ist-Layer-1, APIs
- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` – Executor, Vorlagen
- Migration `054_activity_session_metrics_eav.sql` – Ist-Constraint Skalar
- Migration `013_training_parameters.sql` – Ist-`data_type`-Enum
---
**Version:** 1.1 · Abgleich mit fachlichem Konzept (§1.4, §3, §12.1); MVP auf `distribution_set` o. ä. konkretisieren.

View File

@ -0,0 +1,70 @@
# Aktivität: Layer-2a-Platzhalter — Audit Schritt 1 (Issue #53)
**Stand:** 2026-04-16
**Bezug:** [Issue #53 — Multi-Layer Architecture](../../../docs/issues/issue-53-phase-0c-multi-layer-architecture.md): Layer 1 = strukturierte Daten, Layer 2a = KI-Formatierung (keine parallele Domänen-Logik im Resolver).
**Ziel dieses Dokuments:** Jeder Aktivitäts-Platzhalter hat genau eine **Layer‑1‑Quelle** (`data_layer/activity_metrics.py`); `placeholder_resolver.py` formatiert oder serialisiert nur noch.
---
## 1. Ergebnisübersicht
| Kategorie | Anzahl | Resolver-SQL für Aktivität? |
|-----------|--------|------------------------------|
| Gebündelt in `PLACEHOLDER_MAP` (Training/Aktivität) | 20 | **Nein** |
| Abweichungen / offene Punkte | 0 | — |
**Hinweis:** `{{rest_days_count}}` steht in der Karte unter „Schlaf & Erholung“ und nutzt `recovery_metrics.get_rest_days_data` — nicht in dieser Tabelle.
---
## 2. Platzhalter → Layer 1 → Layer 2a
| Key | Layer 1 (`activity_metrics`) | Layer 2a (`placeholder_resolver`) | Bemerkung |
|-----|------------------------------|-------------------------------------|-----------|
| `activity_summary` | `get_activity_summary_data` | `get_activity_summary` | String-Zusammenfassung |
| `activity_detail` | `get_activity_detail_data` (+ `enrich_sessions_with_metrics`) | `get_activity_detail` | Dynamische `session_metrics[]` pro Zeile (Profil/EAV) |
| `trainingstyp_verteilung` | `get_training_type_distribution_data` | `get_trainingstyp_verteilung` | Ausgabe: Top-3-Text (kein JSON); Registry 2026-04 an Ist angeglichen |
| `training_minutes_week` | `calculate_training_minutes_week` | `_safe_int` | |
| `training_frequency_7d` | `calculate_training_frequency_7d` | `_safe_int` | |
| `quality_sessions_pct` | `calculate_quality_sessions_pct` | `_safe_int` | |
| `proxy_internal_load_7d` | `calculate_proxy_internal_load_7d` | `_safe_int` | |
| `monotony_score` | `calculate_monotony_score` | `_safe_float` | |
| `strain_score` | `calculate_strain_score` | `_safe_int` | |
| `rest_day_compliance` | `calculate_rest_day_compliance` | `_safe_int` | |
| `ability_balance_strength` | `calculate_ability_balance_strength` | `_safe_int` | abilities in `activity_log` |
| `ability_balance_endurance` | `calculate_ability_balance_endurance` | `_safe_int` | |
| `ability_balance_mental` | `calculate_ability_balance_mental` | `_safe_int` | |
| `ability_balance_coordination` | `calculate_ability_balance_coordination` | `_safe_int` | |
| `ability_balance_mobility` | `calculate_ability_balance_mobility` | `_safe_int` | |
| `vo2max_trend_28d` | `calculate_vo2max_trend_28d` | `_safe_float` | |
| `activity_score` | `calculate_activity_score` | `_safe_int` | |
| `training_frequency_by_type_md` | `get_training_frequency_by_type_data` | `get_training_frequency_by_type_md` | Markdown-Tabelle |
| `training_inter_session_gap_md` | `get_training_inter_session_gap_data` | `get_training_inter_session_gap_md` | Markdown-Text |
| `training_sessions_recent_json` | `get_training_sessions_recent_weeks_data` (+ `enrich_sessions_with_metrics`) | `_safe_json('training_sessions_recent_json')` | JSON inkl. `session_metrics[]` pro Session |
---
## 3. Schichten-Disziplin (Checkliste)
- [x] Kein `SELECT` auf `activity_log` / `activity_session_metrics` in den **Layer‑2a**-Funktionen oben — nur Aufrufe in Layer 1 bzw. `_safe_*`-Wrapper.
- [x] `get_activity_detail` / `get_training_sessions_recent_json` liefern EAV nur über **bereits gemergte** `session_metrics` (Merge-Kanon: `activity_log` vor EAV).
- [x] Registry-Metadaten: `data_layer_module` / `data_layer_function` pro Key in `placeholder_registrations/activity_metrics.py` und `activity_session_insights.py`.
- [x] Korrektur Registry: `activity_summary.resolver_function` = `get_activity_summary` (war veraltet: `_format_activity_summary`).
---
## 4. Nächste Schritte (Roadmap)
2. ~~**Registry-Texte:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` (tcp/ttp) und Merge-Kanon — **erledigt** (`activity_detail`, `training_sessions_recent_json`); dazu **`trainingstyp_verteilung`**-Metadaten von veraltetem „JSON/Resolver-SQL“ auf Ist (**Layer 1 + Top-3-Text**) korrigiert.~~
3. **History / Layer 2b:** EAV-Zeitreihen nicht über Platzhalter, sondern dedizierte Layer‑1-/Chart-Pfade.
4. **Optional:** Gitea-Issue „Activity Layer 2a“ bei Änderungen an `activity_metrics` pflegen.
---
## 5. Referenzen
- `backend/placeholder_resolver.py` — `PLACEHOLDER_MAP` (Training/Aktivität)
- `backend/placeholder_registrations/activity_metrics.py`
- `backend/placeholder_registrations/activity_session_insights.py`
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.1a (Navigation Read vs. Berechnen)

View File

@ -0,0 +1,215 @@
# Aktivität: Zielarchitektur & Phasenplan (Produktionsreife)
**Stand:** 2026-04-16
**Status:** Normative Zielrichtung für `activity_log`, EAV, Composites, Import, Layer 1/2.
**Ergänzt:** `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Ist-Modell, APIs, Tests).
**Phase A:** abgeschlossen — Kanon-Tabelle [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
**Phase B:** in Arbeit — Consumer-Audit und Lesepfad-Härtung (siehe §4 Phase B).
---
## 1. Leitprinzipien
| Prinzip | Bedeutung |
|---------|-----------|
| **Layer 1 = Single Source of Truth** | Alle Auswertungen (Charts, Scores, strukturierte Platzhalter) lesen **nur** über `data_layer` (kanonische Funktionen). Keine parallele SQL-Logik in Routern oder im Placeholder-Resolver für Aktivität. |
| **Eine semantische Größe, eine kanonische Quelle** | Kein Dauer-Sync derselben Bedeutung in `activity_log`-Spalte **und** EAV. Übergang: dokumentierte Abschaltung, nicht implizites Driften. |
| **Spine vs. Parameter** | `activity_log` trägt Identität, Zeit, Typ, Notizen, Audit + **heiße** universelle Skalare (siehe §2.2). Alles Typ-/Admin-Dynamische über EAV. |
| **Composites = Archetyp im Code, Konfiguration in der DB** | Struktur (7+2 Archetypen) und Validierung **versioniert im Repo**; Admin **wählt** Archetyp, **benennt** Slots, **bindet** Sportarten, **mappt** CSV → `(parameter_id, slot_key)`. Kein freies JSON-Schema im Admin. |
| **Import explizit** | Jede CSV-Spalte hat ein klares Ziel: Spine-Spalte, skalarer Parameter oder **Slot** eines Composite-Parameters. Typkonvertierung zentral (Executor / Converter), nicht verteilt. |
---
## 2. Zielarchitektur (Gesamtbild)
### 2.1 Schichtenmodell
```
[CSV / UI / API Write]
↓
Orchestrator & Router (Auth, Transaktionen, Feature-Checks)
↓
Persistenz: activity_log (Spine + heiße Skalare) + activity_session_metrics (EAV)
↓
Layer 1: data_layer (activity_session_metrics.py, activity_metrics.py, …)
↓
Layer 2a/2b: Platzhalter-Resolver (Formatierung), Chart-Endpoints (Chart.js-Shapes)
↓
KI / UI / Export
```
- **Orchestrator:** Schreibpfad, Konsistenz nach Write (kein zweites „Lesen der Wahrheit“ neben Layer 1; optional nur Post-Write-Hooks).
- **Resolver:** für Aktivität **kein** direkter DB-Zugriff; nur Aufruf von Layer 1.
### 2.1a Navigationsregel: wo nachsehen (ohne Datei-Zwang)
Die **physische** Aufteilung ist dreigeteilt: **`activity_log`** (Spine + heiße Spalten), **EAV-Skalare** (`activity_session_metrics` + numerische/textuelle `value_*`), **EAV-Composites** (ein Parameter, Nutzlast z. B. JSON/JSONB im EAV-Datensatz). **Fachlich** soll nach außen **eine homogene Session-Sicht** entstehen — Consumer sollen nicht selbst entscheiden, aus welcher Tabelle/Welche Form ein Wert kommt.
| Thema | Wo nachsehen (Ist; Ziel: Schnittstelle stabil, Datei optional splittbar) |
|--------|--------------------------------------------------------------------------|
| **Homogene Session lesen** (Merge Spalte + EAV-Skalare + später Composite-Payload) | `data_layer/activity_session_metrics.py` — u. a. `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics` |
| **Schreiben / Import / API-Persistenz** | `data_layer/activity_persistence_orchestrator.py` (+ Router) |
| **Berechnungen, Aggregationen, Scores** über viele Sessions oder Zeitfenster | `data_layer/activity_metrics.py` — arbeitet auf der **vereinheitlichten** Session-Datenlage (über die Read-Funktionen oben), nicht durch paralleles Mergen der drei Quellen im Caller |
**Hinweis:** Orchestrator und Read-Merge **müssen nicht** in derselben Datei stehen. Entscheidend ist, dass es **genau eine dokumentierte Read-Fassade** für „Session inkl. aller effektiven Metriken“ gibt und Layer‑1‑Berechnungen **nur** diese Fassade (oder deren Ergebnisstrukturen) nutzen. Eine spätere Umbenennung oder Auslagerung in z. B. `activity_read_gateway.py` ändert die Rolle nicht — nur der **eine Einstieg** muss in dieser Doku und im Code auffindbar bleiben.
### 2.2 `activity_log` (Spine + heiße Skalare)
**Maschinenlesbarer Kanon:** `backend/data_layer/activity_data_canon.py` (`ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`, `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS`, Legacy-Lesefallback für EAV-primäre Parameter).
**Immer (fachlich minimal + listenfähig):** `id`, `profile_id`, Kalender-/Zeitfenster (`date`, `started_at`/`ended_at`, ggf. `start_time`/`end_time` bis Konsolidierung), `duration_min`, `training_type_id` (+ ggf. denormalisierte Kategorie), Legacy `activity_type`, `notes`, `source`, `created`.
**Heiße Skalare (CSV-Modul + `source_field` nach Migration 057):** u. a. `kcal_active`, `kcal_resting`, `distance_km`, `hr_avg`/`hr_max` (Parameter `avg_hr`/`max_hr`), `duration_min`, `rpe` – für Listen und Standard-Aggregate ohne EAV-Join.
**EAV-primär (erweiterte Metriken):** z. B. Kadenz, Pace, Leistung, Höhe, Umgebung — `training_parameters.source_field` = NULL; Import schreibt EAV; bei leerem EAV optional Lesefallback auf bestehende `activity_log`-Spalte (Migration 057 + Merge-Logik).
**Session-Qualität / Auswertungsblob:** z. B. `evaluation`, `quality_label`, `overall_score` – **kein** EAV-Parameter-Raster; semantisch „Ergebnis der Einheit“.
**Nicht dauerhaft doppelt:** dieselbe Semantik nicht parallel pflegen; siehe entfallener Spalte→EAV-Schreib-Sync, Lesepfad `merge_column_backed_and_eav_metrics`.
### 2.3 EAV (`activity_session_metrics`)
- **Skalare:** ein `training_parameter`, genau eine `value_*`-Spalte (wie heute).
- **Composites:** ein `training_parameter` pro Composite-Instanz, **ein** gespeichertes Dokument pro Session (serialisiert z. B. in `value_text` als JSON **oder** künftig dedizierte JSONB-Spalte – technische Entscheidung in eigener Migration, Vertrag im Archetyp).
- **Merge-/Schema-Logik:** weiterhin zentral in `activity_session_metrics.py` (effektives Schema aus Kategorie + Typ-Overrides).
### 2.4 Composite-Metamodell (Ziel)
**Archetypen (Code, begrenzte Menge):** u. a. Band-/Zonenverteilung, Sequenz-/Übergangsprofil, Intervallblock-, Ereignis-/Aktions-, Kopplungs-/Effizienz-, Modellparameter-Profil; optional Technik-/Zyklus-, Readiness-/Recovery-Profil.
**Pro Archetyp:** feste strukturelle Regeln (erlaubte Slots, Typen, Pflicht/Optional), Validator + Version.
**In der DB (Admin):** Zuordnung „Parameter X hat Archetyp A“, Slot-Labels (DE/EN), Einheiten, Aktivierung pro Sportart/Kategorie, Sortierung.
**Import:** CSV-Spalten → `(training_parameter_id, slot_key)` mit stabilen Keys (`z1_sec`, …), nie nur „Spaltenreihenfolge“.
### 2.5 Universal CSV & Admin
- Vorlagen: Mapping inkl. **Composite-Slots** und Typkonvertierung (vollständige Matrix Ziel).
- UI: Trennung **Kern activity_log** vs. **Parameter/EAV** vs. **Composite-Blöcke** (optisch/UX), um Doppel-Tabellen-Chaos zu vermeiden.
### 2.6 Layer 2 (Platzhalter & Diagramme)
- Datenbezug **nur** Layer 1.
- Registry-Einträge: `data_layer_module` / `data_layer_function` pflegen; Composite-Auswertung ggf. über Hilfsfunktionen, die JSON → normierte Struktur für Prompts/Charts liefern.
---
## 3. Ist → Soll (Kurz)
| Bereich | Ist (typisch) | Soll |
|---------|----------------|------|
| Schreibpfad | Teilweise Doppelhaltung Spalte ↔ EAV, Sync-Hooks | Kanon + gezielte Abschaltung; eine Quelle pro Semantik |
| Lesepfad | Layer 1 wächst; Legacy-Spalten noch relevant | `get_activity_session_logical_unit` / `activity_metrics` als alleinige Wahrheit für Consumer |
| Composites | Noch nicht im Einklang mit EAV-Metamodell | Archetypen + Slot-Admin + ein Dokument pro Parameter/Session |
| Import | Mapping teilweise; Typkonvertierung lückenhaft | Vollständige Konvertierung + Composite-Zusammenbau |
| Resolver | Aktivität sauber über Layer 1 | Profil/Focus ggf. später ebenfalls aus Layer 1 |
---
## 4. Vorgehensmodell (Phasen)
Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel (z. B. UI-Polish) laufen, wenn der Kanon steht.
### Phase A – Kanon & Abschaltplan (Grundlage) ✅
**Inhalt:** Schriftliche **Kanon-Tabelle**: pro Messgröße genau eine Quelle (`activity_log` | `eav_scalar` | `eav_composite` | `session_quality`). Liste der Keys, für die **Sync/Spiegelung** endet.
**Definition of Done:** Review im Team; Referenz in diesem Dokument oder Verweis auf Gitea-Kommentar; keine Code-Änderung zwingend.
**Erledigt (2026-04-16):** [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md) — eine Semantik pro Zeile, verlinkt mit `activity_data_canon.py` und Merge-Logik.
---
### Phase B – Lesepfad härten (Layer 1) 🔄
**Inhalt:** Sicherstellen, dass **alle** relevanten Consumer (mind. `activity_metrics` für Platzhalter/Charts, Activity-Detail-API) dieselbe Merge-/Fallback-Logik nutzen; Legacy-Spalten nur noch als dokumentierter Fallback bis Enddatum.
**Definition of Done:** Kurze Audit-Liste „Router/Resolver greifen nicht an Aktivität vorbei“; Tests oder manuelle Stichprobe für Detail + ein Chart + 2 Platzhalter.
**Abhängigkeit:** Phase A für „welche Spalten noch Fallback sind“.
**Audit-Stand (2026-04-16, ergänzt Export):**
| Consumer | Nutzt Layer-1-Merge (`enrich_sessions_with_metrics` / `get_activity_session_logical_unit`) | Anmerkung |
|----------|---------------------------------------------------------------------------------------------|-----------|
| `GET /api/activity/{eid}` | ✅ `get_activity_session_logical_unit` | Referenz-Detail |
| `GET /api/activity` (Liste) | ✅ seit 2026-04-16 `enrich_sessions_with_metrics` auf jeder Listen-Antwort | vorher nur Roh-Spalten |
| `activity_metrics.get_activity_detail_data` | ✅ | Platzhalter `{{activity_detail}}` |
| `activity_metrics.get_training_sessions_recent_weeks_data` | ✅ | KI-Kontext |
| `placeholder_resolver` (Aktivität) | ✅ nur `activity_metrics` | kein paralleles SQL |
| `GET /api/export/json` (`activity`) | ✅ `enrich_sessions_with_metrics` + `serialize_dates` | `session_metrics` pro Zeile |
| `GET /api/export/csv` (Training-Zeilen) | ✅ `enrich_sessions_with_metrics` | gemergte EAV in Spalte „Details“ |
| `GET /api/export/zip` (`data/activity.csv`) | ✅ `enrich_sessions_with_metrics` | Zusatzspalte `session_metrics_json` (Import ignoriert sie) |
| `get_activity_summary_data` | n. a. | rein aggregiert (`SUM`/`COUNT`), keine Session-EAV |
| `routers/charts.py` (A1–A8) | Spalten-Aggregate | bewusst: Dauer/RPE/HF aus **`activity_log`**-Kanon; kein EAV-Join nötig für definierte Charts |
| `activity_stats` (`GET /api/activity/stats`) | nur Spalten | Kacheln: `kcal`/`duration` aus Kernspalten |
---
### Phase C – Schreibpfad entschlacken
**Inhalt:** Orchestrierung/CSV: kein Schreiben derselben Semantik an zwei Orten; `sync_column_backed_session_metrics` (o. ä.) **stufig abschalten** oder auf Notfall-Flag; Import schreibt gemäß Kanon.
**Definition of Done:** Deploy auf Prod mit Monitoring; Stichprobe Import + manuelle Bearbeitung; keine Regression in Listenansicht.
**Abhängigkeit:** Phase A + B (sonst Lücken beim Lesen).
**Analyse (2026-04-16, nur Ist-Review):** Es gibt **keinen aktiven** Schreibpfad mehr, der `activity_log`-Spalten für `source_field`-Parameter **dauerhaft nach EAV spiegelt**.
| Prüfpunkt | Ergebnis |
|-----------|----------|
| `sync_column_backed_session_metrics` | Nur noch **Definition** in `activity_session_metrics.py`, als veraltet markiert; **keine Aufrufer** im Repo (grep). Laufzeit-Sync: **abgestellt**. |
| `run_activity_post_write_hooks` / `…_import` | Nur **Auto-Eval** (optional); Kommentar: **kein** Spalte→EAV-Sync. |
| Universal-CSV (`executor.py`) | Kernfelder → `activity_log` (`activity_csv_registry_updates_from_mapped` + `update_activity_columns` / Insert); EAV → `upsert_session_metrics_from_csv_mapped`. Registry-Keys werden **nicht** nach EAV geschrieben; bei `source_field` wird EAV **übersprungen**, wenn die Spalte **bereits befüllt** ist — vermeidet bewusst doppelte Speicherung. |
| REST `PUT /metrics` | Kommentar in Code: **kein** `sync_column_backed` nach EAV-Ersatz. |
| Migrationen 055 / 057 | **Einmaliger** Backfill/Schwenk, kein fortlaufender Sync. |
**Lesepfad (2026-04-16):** `merge_column_backed_and_eav_metrics` bevorzugt **immer** `activity_log`, wenn ein kanonischer Spaltenwert existiert: zuerst `source_field`, dann Registry-Spalte gleichen Keys, dann Legacy-Spalten für EAV-primäre Parameter, zuletzt EAV. Doppelte physische Schreiborte sind damit in der effektiven Sicht **ohne EAV-Vorrang** behoben.
---
### Phase D – Composite MVP
**Inhalt:** Ein Archetyp end-to-end (z. B. **Band-/Zonenverteilung**): Code-Validator, DB-Binding (Parameter + Slots), Admin-UI minimal, Import **5 Spalten → ein JSON-Dokument** mit festen Keys, Layer-1-Read (Roh + optional `expand_*`).
**Definition of Done:** Eine Sportart/Kategorie befüllbar; Dokumentation des JSON-Vertrags im Repo; pytest für Validator/Zusammenbau wo möglich.
**Abhängigkeit:** Phase A (Kanon „Composites nur als Dokument, nicht doppelt in Spalten“).
---
### Phase E – Composite-Ausbau & Typkonvertierung Import
**Inhalt:** Weitere Archetypen nach Priorität; Universal-CSV **vollständige** Typkonvertierung für alle gemappten Ziele; Dialog-/Mapping-Konzept (Kern vs. Parameter vs. Composite).
**Definition of Done:** Matrix „Zieltyp × Converter“ gepflegt; Admin-Flow reviewt.
---
### Phase F – Produktionshärtung
**Inhalt:** Performance-Indizes bei Bedarf; Observability (Import-Fehler, Validierungs-Fails); Resolver/Profil optional komplett ohne `get_db` für domänische Daten; Doku + Gitea-Issues geschlossen/aktualisiert.
---
## 5. Was zuerst?
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
**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).
---
## 6. Referenzen
- `ACTIVITY_SCALAR_KANON_TABLE.md` – **Skalar-Kanon** (Phase A)
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` – Tabellen, APIs, Tests, Backfill-Hinweise
- `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` – Composite-EAV (JSONB), Archetypen, Import-Slots, Layer-1-Expand, Migrations- und Testplan
- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` – Executor, Vorlagen, Typen
- `PLACEHOLDER_REGISTRY_FRAMEWORK.md` – Layer-2-Registrierung
- `functional/DATA_ARCHITECTURE.md` – fachliche Datenarchitektur (Querschnitt)
---
**Version:** 1.5 · Merge: activity_log (Registry + Legacy-Spalten) vor EAV bei Lesen.

View File

@ -0,0 +1,95 @@
# Aktivität: Skalar-Kanon (eine Semantik → eine Quelle)
**Stand:** 2026-04-16
**Normativer Code:** `backend/data_layer/activity_data_canon.py`
**Kontext:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (Phase A abgeschlossen)
---
## 1. Spine & Identität (`activity_log`, nicht EAV)
Diese Felder sind **keine** `training_parameters`-Skalare. Sie gehören zur Session-Zeile.
| Semantik | DB / API | Kanonische Quelle | Lesefallback | Sync Spalte↔EAV |
|----------|----------|-------------------|--------------|-----------------|
| Primärschlüssel | `activity_log.id` | `activity_log` | — | — |
| Profil | `profile_id` | `activity_log` | — | — |
| Kalendertag | `date` | `activity_log` | — | — |
| Start / Ende (Zeit) | `start_time`, `end_time`, `started_at`, `ended_at` | `activity_log` | — | — |
| Trainingsart (Freitext/Legacy) | `activity_type` | `activity_log` | — | — |
| Referenz Trainingstyp | `training_type_id`, `training_category`, … | `activity_log` (+ `training_types`) | — | — |
| Notiz | `notes` | `activity_log` | — | — |
| Quelle / Import | `source`, `created`, … | `activity_log` | — | — |
| Session-Auswertung | `evaluation`, `quality_label`, `overall_score`, … | `activity_log` (Blob/Ergebnis) | — | Kein EAV-Raster |
---
## 2. Kernfelder CSV-Modul `activity` (= „heiße“ Skalare)
Abgeleitet aus `csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields` — maschinenlesbar über `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` in `activity_data_canon.py`.
| Semantik | Key (Registry/API) | Kanonische Quelle | Lesefallback | Bemerkung |
|----------|-------------------|-------------------|--------------|-----------|
| Dauer | `duration_min` | **`activity_log`** | — | Aggregates, Listen |
| Aktive Energie | `kcal_active` | **`activity_log`** | — | |
| Ruhe-Energie | `kcal_resting` | **`activity_log`** | — | |
| Distanz | `distance_km` | **`activity_log`** | — | |
| Ø HF | `hr_avg` (Parameter oft `avg_hr` in EAV-Schema) | **`activity_log`** | EAV nur wenn `source_field` / Profil-Schema | `merge_column_backed_and_eav_metrics`: Spalte schlägt EAV |
| Max-HF | `hr_max` | **`activity_log`** | analog | |
| RPE | `rpe` | **`activity_log`** | analog | |
Schreibpfad: Universal-CSV und API sollen diese Keys auf **`activity_log`** mappen, sofern nicht ausdrücklich ein EAV-primärer Parameter (§3) gewählt ist.
---
## 3. EAV-primäre Parameter (erweiterte Skalare)
`ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` in `activity_data_canon.py`. **`training_parameters.source_field`** = NULL (nach Kanon / Migration 057): kanonischer Speicher ist **`activity_session_metrics`**.
| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` | Schreib-Kanon (Ziel) |
|-------------------------------------------|------------------------------|------------------------|
| `min_hr` | `hr_min` | **EAV** |
| `pace_min_per_km` | `pace_min_per_km` | **EAV** |
| `cadence` | `cadence` | **EAV** |
| `avg_power` | `avg_power` | **EAV** |
| `elevation_gain` | `elevation_gain` | **EAV** |
| `temperature_celsius` | `temperature_celsius` | **EAV** |
| `humidity_percent` | `humidity_percent` | **EAV** |
| `avg_hr_percent` | `avg_hr_percent` | **EAV** |
| `kcal_per_km` | `kcal_per_km` | **EAV** |
**Lesen:** `merge_column_backed_and_eav_metrics` — wenn Legacy-Spalte **und** EAV einen Wert haben, **gewinnt die Spalte** (kanonische `activity_log`-Sicht). EAV nur, wenn die Spalte leer/nicht koerzierbar ist.
---
## 4. Profil-/Typ-dynamische Skalare (EAV, nicht in Registry-Kernliste)
| Semantik | Kanonische Quelle | Lesefallback |
|----------|-------------------|--------------|
| Admin-definierte Parameter (Attributprofil Kategorie/Typ) | **`activity_session_metrics`** + `training_parameters` | — |
| Parameter mit `source_field` → Spalte | **`activity_log`** (Spalte) | EAV ergänzend; Leseregel: Spalte bevorzugt (kein veraltetes EAV) |
---
## 5. Composites (Zielbild, noch nicht Kanon-Zeile pro Slot)
| Semantik | Kanonische Quelle (Ziel) |
|----------|---------------------------|
| Strukturierte Composite-Dokumente (z. B. Zonen/Bänder) | **EAV** ein Dokument pro Parameter/Session (siehe `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`) |
Kein dauerhaftes Spiegeln derselben Semantik in `activity_log`-Spalten.
---
## 6. Sync & Übergang
- **Kein** automatischer Dauer-Sync „Spalte → EAV“ für dieselbe Semantik; Lesepfad vereinheitlicht die Sicht (`merge_column_backed_and_eav_metrics`).
- Optionale **Backfill**-Migration/Skript (idempotent) nur nach fachlicher Freigabe — siehe EAV-Agent-Guide §6.
---
## 7. Referenzen
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` — Phasen A–F
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` — APIs, Tests
- `activity_data_canon.py` — `ACTIVITY_LOG_PATCHABLE_COLUMNS`, Legacy-Map

View File

@ -0,0 +1,146 @@
# Activity Session Metrics (EAV) – Umsetzungs- & Agent-Guide
**Stand:** 2026-04-14
**Status:** Kern-Backend (Migration 054, Layer 1, Admin- & Nutzer-API) umgesetzt; Admin-UI & CSV-Mapping folgen.
**Ziel:** Sportspezifische **Attributprofile** (Kategorie + optional Trainingstyp-Override) administrierbar; Messwerte pro Session in **EAV**; **alle Auswertungen** sollen künftig über **Layer 1** (`data_layer`) laufen.
**Zielarchitektur, Phasenplan (Produktionsreife):** [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md) – Kanon `activity_log`/EAV, Composites, Import, Layer 1/2, Reihenfolge A–F.
**Composite-Parameter (EAV, JSONB, Archetypen):** detailliertes Umsetzungskonzept für Agenten: [`ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`](./ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md).
**Kanon (Code):** `backend/data_layer/activity_data_canon.py` (Repo-Root) — CSV-Modul `activity` vs. EAV-primär; Migration **057**.
---
## 1. Produktions-Migrationen (Pflicht)
- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story.
- Neue Migrationen: **`backend/migrations/054_*.sql`** (nächste freie Nummer nach 053 einhalten).
- **Prod-Checkliste vor Deploy:**
1. Backup / Snapshot der DB.
2. Migration auf **Kopie** der Prod-DB laufen lassen; Container-Start (`db_init`) verifizieren.
3. Stichprobe: `activity_log`-Zeilen unverändert; neue Tabellen leer oder nur Seed.
- **Datenhaltung:** Bestehende Spalten in `activity_log` bleiben **Quelle für Alt-Daten**; EAV (`activity_session_metrics`) ist der **kanonische Ort für konfigurierte Session-Metriken**, sobald geschrieben. Backfill Altspalten → EAV ist **separater Schritt** (siehe §6).
---
## 2. Datenmodell (Ist nach Migration 054)
| Tabelle | Zweck |
|---------|--------|
| `training_parameters` | Katalog messbarer Größen (`key`, `data_type`, `unit`, `validation_rules`, …) – bereits Migration 013; Admin-API ergänzt. |
| `training_category_parameter` | Welche Parameter für welche **`training_types.category`** (z. B. `cardio`) gelten: `sort_order`, `required`, `ui_group`. |
| `training_type_parameter` | Zusatzparameter oder **Overrides** pro **`training_types.id`**: `sort_order`, `required`, `ui_group` (NULL = von Kategorie erben). |
| `activity_session_metrics` | EAV: `(activity_log_id, training_parameter_id)` eindeutig; genau eine Wertspalte `value_num` / `value_int` / `value_text` / `value_bool`. |
| `activity_log` | **Neu:** `started_at`, `ended_at` (`TIMESTAMPTZ`, nullable) – für spätere Dedupe/Intervalle; **kein** Pflichtfeld in v1. |
**Merge-Logik effektives Schema** (Layer 1, eine Funktion):
1. Kategorie ermitteln: aus Zeile `training_category` oder aus `training_types.category` via `training_type_id`.
2. Basis = alle Zeilen `training_category_parameter` für diese Kategorie, Join auf `training_parameters` (aktiv).
3. Für jeden Eintrag in `training_type_parameter` zum gewählten Typ: gleiche `training_parameter_id` → Overrides anwenden; nur im Typ vorhanden → anhängen.
4. Sortierung: `sort_order` aufsteigend, dann `key`.
---
## 3. Layer 1 – Kanonische Module
| Modul | Pfad | Aufgabe |
|-------|------|---------|
| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics`. |
**Spalten vs. EAV (Lesepfad):** `merge_column_backed_and_eav_metrics` / `get_activity_session_logical_unit` / `enrich_sessions_with_metrics` werten Parameter mit `source_field` **primär aus `activity_log`** aus; EAV ist Fallback (z. B. Legacy) oder für Parameter ohne Spalte. **Kein** automatischer Spalte→EAV-Schreib-Sync mehr in `run_activity_post_write_hooks` / Import-Hooks (vermeidet Doppelhaltung).
**Regeln für Agenten:**
- **Keine** zweite Implementierung derselben Merge- oder Validierungslogik in Routern.
- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`).
- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`.
**KI-Kontext:** In `training_sessions_recent_json` enthält jedes Element von `session_metrics` neben `key`/`value` die Felder `name_de`, `name_en`, `description_de`, `description_en` (aus dem effektiven Schema). Für nicht selbsterklärende Keys soll im Katalog `training_parameters.description_*` gepflegt werden (Admin). Ergänzend liefert der Platzhalter `{{training_parameters_glossary_md}}` die gesamte aktive Parameter-Legende als Markdown-Tabelle (`get_training_parameters_ki_glossary_data` → `get_training_parameters_glossary_md`).
---
## 4. API (Ist / geplant)
### Admin (`require_admin`)
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET/POST | `/api/admin/training-parameters` | Katalog lesen / Parameter anlegen |
| PUT/DELETE | `/api/admin/training-parameters/{id}` | Aktualisieren / Soft-deaktivieren (`is_active`) |
| GET | `/api/admin/training-category-parameters?category=` | Zuordnungen Kategorie |
| POST | `/api/admin/training-category-parameters` | Zuordnung anlegen |
| DELETE | `/api/admin/training-category-parameters/{id}` | Zuordnung entfernen |
| GET | `/api/admin/training-type-parameters?training_type_id=` | Zuordnungen Typ |
| POST | `/api/admin/training-type-parameters` | Zuordnung anlegen |
| DELETE | `/api/admin/training-type-parameters/{id}` | Zuordnung entfernen |
Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_activity_attribute_profiles.py`.
### Nutzer (`require_auth`)
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/api/activity/{eid}` | Session-Kopf + `schema` + `metrics` (Layer 1) |
| PUT | `/api/activity/{eid}/metrics` | **Voller Ersatz** der EAV-Metriken für diese Session (Liste `{parameter_key, value}`) |
`ActivityEntry` unverändert für bestehende Create/Update-Routen; optionale Erweiterung um `started_at`/`ended_at` in späterem Schritt.
---
## 5. Agent-Checkliste (nächste Iterationen)
**Layer 2a (Platzhalter Aktivität):** Abgleich Registry ↔ Resolver ↔ Layer 1 — [`ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md`](./ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md) (Issue #53). **Schritt 2:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` und Korrektur `trainingstyp_verteilung` in der Registry.
Siehe **Phasen A–F** in [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md). Kurz:
- [x] **Phase A:** Kanon-Tabelle (eine Quelle pro Semantik) — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
- [ ] **Phase B:** Lesepfad Layer 1 härten (Consumer-Audit fortlaufend — siehe `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §4 Phase B).
- [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten.
- [ ] **Phase D:** Composite-MVP (ein Archetyp E2E).
- [ ] **Phase E:** Archetypen ausbauen + CSV-Typkonvertierung vollständig + Mapping-UX.
- [ ] **Phase F:** Härtung Prod (Indizes, Observability, Doku).
Legacy-Punkte:
- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“.
- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`.
- [ ] Universal CSV: Mapping inkl. EAV/Composite-Ziele + Executor (fortlaufend).
- [ ] Optional: Backfill / Abschluss `source_field`-Pfad nach Kanon (Phase A/C).
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
---
## 6. Backfill (nicht in Migration 054)
Separates Skript oder Migration **055+**, wenn fachlich freigegeben:
- Pro aktivem `training_parameter` mit gesetztem `source_field`: Wert aus `activity_log` lesen, in EAV schreiben, wenn noch keine Zeile existiert.
- Idempotent (`ON CONFLICT DO NOTHING` oder Upsert-Regel dokumentieren).
---
## 7. Automatische Tests (pytest, ohne DB)
Aus **`backend/`**:
```bash
python -m pytest tests/test_activity_session_metrics.py -v
```
Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_validate_single_value`), `resolve_activity_attribute_schema` mit Mock-Cursor, `enrich_sessions_with_metrics` mit Mock-Cursor.
---
## 8. Referenzen
- Migration 013: `training_parameters`
- Migration 004/014: `training_types`, `activity_log`-Erweiterungen
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data`
- KI-Legende: `get_training_parameters_ki_glossary_data`, Platzhalter `{{training_parameters_glossary_md}}`
---
**Version:** 1.1 · Bei Schema- oder API-Änderungen dieses Dokument und ggf. `CLAUDE.md` Kurzverweis aktualisieren.

View File

@ -0,0 +1,51 @@
# BLS Food Reference – technische Spec
**Stand:** 2026-09-14 · Migration **062**–**069**
## Tabellen
- `food_attributes` — dynamischer Stoff-/Merkmalskatalog (`attr_key`, `data_type`, `origin`)
- `food_catalog` — Lebensmittel (`bls_code` UNIQUE bei official_bls; manuell ohne BLS-Code)
- `food_attribute_values` — typisiertes EAV
- `food_name_mappings` — FDDB-Name → `food_id` (User / global)
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
- `nutrition_day_marks` — `fasting` | `incomplete`
- `food_recipes` / `food_recipe_ingredients` — Mitai-Listen/Kombinationen (Rohmischung, kein Kochschwund); `nutrition_items.recipe_id`. Kein Tabellen-Rename. Gekochte Gerichte später Tandoor + `cooked_yield_g`, nicht hier.
## Layer 1
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Listen-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen). Diese Skala gilt nur für Roh-Kombinationen, nicht für gekochte Gerichte.
- `bls/recipe_parser.py` — `produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
## Import
BLS: Admin-Upload Components + Daten-XLSX (`backend/bls/parser.py`). Upsert über `bls_code` / `attr_key`, nie Delete+Insert offizieller Zeilen.
FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / Bestätigung.
## Router
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA`
- `GET /bls/foods/mine` — eigene `manual_user`-Einträge; `GET /bls/foods/shared` — zentrale `manual_admin`; `GET /bls/foods/{id}` — Editor-Payload inkl. `visibility` und `editable` (Owner persönlich; Admin auch zentral); `PUT /bls/foods/manual/{id}` — nur Owner. Zentrale Updates: `PUT /admin/bls/foods/manual/{id}` (Admin). Suche `?scope=global` blendet persönliche Foods aus (Admin-Mappings).
- Sichtbarkeit: `data_layer/food_visibility.py`. Lookup-Mapping: User-Zeile nur wenn Food sichtbar; globale Mapping-Zeile nur `owner_profile_id IS NULL`. `upsert_food_mapping` erzwingt dasselbe. Vorschläge sortieren persönlich → zentral → BLS.
- `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST /admin/bls/foods/manual` — zentrale Foods. `PUT /admin/bls/foods/manual/{id}` — zentral **und** persönlich (`attributes` + `serving_g`). `GET /admin/bls/foods/{id}?editor=true` — Editor-Payload. Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht).
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
- `/api/admin/bls/*` — Import, Katalog, Attribute. `GET /admin/bls/attributes` liefert `group_key`/`group_label` (BLS-Gruppen, sortiert). `PUT /admin/bls/attributes/{id}` ändert Name/Einheit/Gruppe (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen und fehlende Gruppen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`.
- `/api/admin/food-mappings` — Admin-CRUD
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Eigene Foods: Tab **Lebensmittel** (`MyFoodsPanel` + `FoodEditDialog`). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat).
- Tandoor (Modul `tandoor`): Migration **067** `profile_tandoor_settings`; `GET/PUT /api/tandoor/settings` (Token nur `token_set` / `token_hint`); `POST /api/tandoor/test` (10/min, kein Redirect-Follow). Client: `data_layer/tandoor_client.py`. Import und `cooked_yield_g` später.
- Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g).
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`.
- `GET /nutrition/recipes?brief=true` — Listen ohne Zutaten, nur `ingredient_count`. `GET /nutrition/recipes/{id}` lädt eine Liste. FDDB-Listenimport antwortet nach Upsert; `rebuild_daily_nutrients` im Hintergrund (`rebuild_pending`).
- Listen-Verknüpfung (`apply` / `link_recipes_to_items`) matcht über `names_match_list` (Mengen-Prefix in gespeichertem `source_name_normalized` zählt). Unmapped-Zeilen bekommen `matching_recipe_id` + `matching_recipe_name`. Frontend: Tab Ernährung → Listen.
- `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile.
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar)
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).

View File

@ -1,7 +1,7 @@
# Dashboard-Lab-Widgets – Anleitung für Coding-Agenten
# Dashboard-Widgets – Anleitung für Coding-Agenten
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Lab-Editor für `config`).
Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON).
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Editor für `config` in **Übersicht anpassen**).
Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON). Nutzer-Oberfläche: `frontend/src/pages/DashboardConfigurePage.jsx` (Route z. B. `/settings/dashboard-layout`).
---
@ -23,7 +23,7 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
| Anforderung | Beschreibung |
|-------------|--------------|
| **A1 – Zentrale Auflösung** | Backend ermittelt pro Profil (effektiver Tier + Restrictions), welche Widget-IDs **erlaubt** sind – idealerweise in **einer** Stelle (Erweiterung des Katalog-Endpoints oder dedizierter Entitlements-Teil der Response). Intern: `check_feature_access` und später ggf. Mapping Widget-ID → Feature-ID(n) / Cluster. |
| **A2 – Nutzer-Konfigurator** | Im Dashboard-Lab (und jedem späteren Layout-Konfigurator): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
| **A2 – Nutzer-Konfigurator** | Im Layout-Konfigurator (**Übersicht anpassen**): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
| **A3 – Layout-Persistenz** | `PUT /api/app/dashboard-layout`: Layout darf **keine** nicht erlaubten Widgets dauerhaft speichern – entweder **ablehnen** (422) oder **beim Speichern entfernen/deaktivieren** (Policy festlegen und dokumentieren). Verhindert „gespeichert, aber nie sichtbar“-Zombies. |
| **A4 – API-/Datenschutz** | Sichtbarkeit im UI reicht nicht: Endpoints, die **Inhalte** für gated Widgets liefern (Charts, KI, …), müssen weiterhin wie heute **eigenständig** über Features abgesichert sein (`check_feature_access`, 403). |
@ -42,8 +42,8 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
1. **`backend/widget_catalog.py`** – `WIDGET_CATALOG`: erlaubte Widget-IDs, Reihenfolge, Titel/Beschreibung für API und Default-Layout.
2. **`backend/dashboard_layout_schema.py`** – `DashboardLayoutPayload`: jede Zeile hat `id`, `enabled`, optional `config`. IDs müssen in `ALLOWED_WIDGET_IDS` sein (aus dem Katalog abgeleitet).
3. **`backend/dashboard_widget_config.py`** – `validate_widget_entry_config`: **nur** Widgets in `WIDGETS_ALLOWING_CONFIG` dürfen **nicht-leere** `config` haben; Keys werden streng validiert (unbekannte Keys → Fehler).
4. **Frontend** – `ensurePilotLabWidgetsRegistered()` in `frontend/src/widgetSystem/registerPilotLabWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
5. **Dashboard-Lab-UI** – `frontend/src/pages/DashboardLabPage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
4. **Frontend** – `ensureDashboardWidgetsRegistered()` in `frontend/src/widgetSystem/registerDashboardWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
5. **Layout-Editor (Produkt)** – `frontend/src/pages/DashboardConfigurePage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
---
@ -52,9 +52,9 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
| Schritt | Datei | Aktion |
|--------|--------|--------|
| A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": "<features.id>"` für Tarif-Gating (`dashboard_widget_entitlements`). |
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Standard-Lab **aktiv** sein soll. |
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Pilot-Komponente) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
| D | `frontend/src/widgetSystem/registerPilotLabWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Server-Standardlayout **aktiv** sein soll (Feld `lab_default_layout` in der Layout-API). |
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Legacy-Widget unter `dashboard-widgets-legacy/`) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
| D | `frontend/src/widgetSystem/registerDashboardWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
| E | `backend/tests/test_widget_catalog.py` | Läuft implizit mit; bei Strukturänderungen Katalog-Tests beachten. |
| F | `backend/version.py` | `MODULE_VERSIONS["app_dashboard"]` MINOR erhöhen und kurz kommentieren. |
| G | Build/Tests | `pytest` (z. B. `tests/test_dashboard_layout_schema.py`, `test_widget_catalog.py`); `npm run build` im `frontend`. |
@ -110,11 +110,11 @@ mapProps: (ctx) => ({
**Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90.
### 3.4 Dashboard-Lab-Editor (`DashboardLabPage.jsx`)
### 3.4 Layout-Editor (`DashboardConfigurePage.jsx`)
Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**:
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in der Datei) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in `DashboardConfigurePage.jsx`) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
- **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden.
Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`.
@ -137,7 +137,7 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
## 5. API zum Prüfen
- `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints).
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Dashboard-Lab-Standard).
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Servertemplate für Editor/Reset; Feldname historisch).
- `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt).
---
@ -159,5 +159,5 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
| Layout-Pydantic | `backend/dashboard_layout_schema.py` |
| HTTP | `backend/routers/app_dashboard.py` |
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
| Pilot/Lab-Registrierung | `frontend/src/widgetSystem/registerPilotLabWidgets.js` |
| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` |
| Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
| Layout-Editor (Nutzer) | `frontend/src/pages/DashboardConfigurePage.jsx` |

View File

@ -0,0 +1,56 @@
# Berichtsprofile & PDF (technisch)
**Stand:** 2026-04-29
## Begriffe
| Begriff | Bedeutung |
|--------|-----------|
| **Layout-Snapshot** | PDF aus gerasteter DOM-Übersicht (`html2canvas` + `jspdf`), optional Widget `report_export`. |
| **Strukturierter Bericht** | Profil mit Blöcken (`section`, `chart`, `ai_insight`), PDF serverseitig via Data Layer + Matplotlib + ReportLab. |
Die beiden Wege sind bewusst getrennt, damit das Dashboard nicht die einzige „Wahrheit“ für Dokumente wird.
## Datenbank
- Tabelle `report_profiles` (Migration `060_report_profiles.sql`): `profile_id` PK → `profiles`, `payload` JSONB, `updated_at`.
Ohne Zeile gilt ein **Code-Standard** (`default_report_profile_dict` in `report_profile_schema.py`).
## API (`/api/reports`)
| Methode | Pfad | Zweck |
|--------|------|--------|
| GET | `/catalog` | Diagramm-Katalog + Blocktypen für UI |
| GET | `/profile` | `{ stored, profile }` |
| PUT | `/profile` | Vollständiges Profil-JSON (Pydantic-validiert) |
| DELETE | `/profile` | DB-Zeile löschen → wieder Standard |
| POST | `/generate-pdf` | PDF-Download; `data_export`-Kontingent + `increment_feature_usage` |
## Schema v1 (`report_profile_schema.py`)
- `version`: nur `1`
- `document_title`: optional
- `blocks`: Liste mit Union:
- `section`: `title`
- `chart`: `chart_id` ∈ `ALLOWED_CHART_IDS`, `window_days` 7–365
- `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title`
## Diagrammdaten
`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`.
## PDF-Rendering
`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend).
## Frontend
- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen.
- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen.
## Nächste sinnvolle Erweiterungen
- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id).
- KI: Insights-Auswahl in der UI statt manueller UUID.
- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block).

View File

@ -18,6 +18,7 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
| Admin-Systemvorlagen | `backend/routers/admin_csv_templates.py` |
| Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` |
| Vorlagen-Validierung (strukturell + Sample) | `backend/csv_parser/template_validator.py` (`validate_csv_template`) |
| Effektives Listentrennzeichen | `backend/csv_parser/core.py` (`resolve_effective_csv_delimiter`) — Datei kann `;` (z. B. Apple DE) haben, Vorlage `,` (EN); Import/Diagnose **nicht** nur das gespeicherte Trennzeichen blind nutzen. |
**Single Source of Truth** für erlaubte Zielfelder, Typen und Duplikat-Keys ist **`module_registry.py`**. Keine parallele Feldliste in Routern duplizieren.
@ -45,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
---
## 4. Bekannte Einschränkungen (Follow-up in Gitea)
## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71)
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** – Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`).
- **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern).
- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import.
- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`).
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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 111–112)
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`

View File

@ -455,15 +455,15 @@ NIEMALS gegen mitai.jinkendo.de
---
## 10. Dashboard-Lab-Widgets und Feature-System
## 10. Dashboard-Widgets und Feature-System
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, Lab unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, API unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
**Bindend:**
1. **Keine fest codierten Tier-Namen** für Widget-Rechte – Tiers und Limits kommen aus der DB.
2. **Komplexität** (Module aus, Unter-Stufen, KI vs. Standard) liegt in der **Feature-/Subscription-Logik**, nicht verteilt in Widget-Komponenten.
3. **Nutzer-Konfigurator** (z. B. Dashboard-Lab): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
3. **Nutzer-Konfigurator** (**Übersicht anpassen** / `DashboardConfigurePage`): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
4. **Backend** liefert die effektive Erlaubnis (z. B. über erweiterten Katalog oder Entitlements), und **validiert beim Speichern** des Layouts, dass keine unerlaubten Widget-IDs persistiert werden (Policy: ablehnen oder strippen – einheitlich halten).
5. **Daten/API:** Zusätzlich zur UI-Filterung müssen die **inhaltsliefernden Endpoints** weiterhin über `check_feature_access` geschützt sein (kein Leck über direkte API-Aufrufe).

View File

@ -20,6 +20,7 @@
|-----|------|----------------|
| **Fachliche Spec (WAS)** | `.claude/docs/functional/` | Domäne, Use Cases, UX-Ziele, fachliche Datenarchitektur. **Keine** reine API-Parameterliste (→ technical). |
| **Technische Spec (WIE)** | `.claude/docs/technical/` | API-, DB-, Implementierungsmuster, Agent-Guides, Migrationen. |
| **Produktfamilie / Foundation** | `.claude/docs/jinkendo-foundation/` | Übertragbare Designprinzipien (nicht Mitai-Domäne); Einstieg `design-principles/README.md`. |
| **Architektur-Querschnitt** | `.claude/docs/architecture/` | Kurze Überblicke (z. B. Frontend-Baum), ergänzend zu technical. |
| **Arbeitspapier / Zwischenstand** | `.claude/docs/working/` | Analysen, Sessions, Migration-Notizen, **keine** langfristige Norm. Kann veraltet sein → Datum im Dokument. **Nicht** als alleinige „Wahrheit“ für Produkt zitieren. |
| **Audits & Matrizen** | `.claude/docs/audit/` | Zeitlich begrenzte Reviews, Reconciliation, Gitea-Vorlagen. |
@ -63,6 +64,7 @@ Diese Ordner sind **kein** Ersatz für `working/` oder `docs/issues/`, wenn das
```
.claude/README.md ← Einstieg Agent/Human
.claude/docs/README.md ← Spec-Katalog
.claude/docs/jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
.claude/docs/functional/ ← WAS
.claude/docs/technical/ ← WIE
.claude/docs/working/ ← Arbeitspapiere / Analysen

View File

@ -176,6 +176,20 @@ known_limitations=(
**Problem:** Versuch, `.claude/task/` Files zu committen
**Lösung:** Nur `backend/` Code committen, `.claude/` ist local docs
## 9. Unmapped-Query: Kreuzjoin Liste × Tagebuch (12.09.2026)
**Was:** Offene Lebensmittel über `food_recipes` mit `nutrition_items` per `recipe_id OR name_normalized` verbinden, um Zutaten ein Datum zu geben.
**Problem:** Nach FDDB-Listenimport Minuten bis Timeout; Zutaten ohne `last_date` fielen im 28-Tage-Filter weg — leere Offene-Liste.
**Lösung:** Nur Items mit `recipe_id` gruppieren; Fallback `recipe.updated_at`. Listen-GET `brief=true`. Rebuild nach Import im Hintergrund.
**Regel:** Kein Join „Name der Liste gegen alle Tagebuchzeilen“. Datum aus der bereits gesetzten FK oder aus dem Eltern-Datensatz.
## 10. `from __future__ import annotations` + FastAPI-Limiter (13.09.2026)
**Was:** `tandoor.py` mit future-annotations und `body: TandoorProbeIn | None` unter `@limiter.limit`.
**Problem:** Pydantic löst den Typ nicht auf → Import-Crash → Backend startet nicht → Login bekommt HTML (`Unexpected token '<'`).
**Lösung:** Kein future-annotations in Routern; `Optional[TandoorProbeIn]`. Import-Test für den Router.
---
**Zusammenfassung Nutrition Cluster:**

3
.gitignore vendored
View File

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

View File

@ -10,8 +10,10 @@
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
> | **Dashboard-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
> | **Designprinzipien Produktfamilie** (Serie 1–9, Foundation) | **`.claude/docs/jinkendo-foundation/design-principles/README.md`** |
> | **Agent-Einstieg** | **`.claude/README.md`** |
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
## Claude Code Verantwortlichkeiten
@ -99,6 +101,11 @@ frontend/src/
**Branch:** develop
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
### Updates (23.04.2026 - Dashboard: veraltete Demo-Route entfernt, klare Produkt-Registry)
- **Frontend:** Veraltete Visualisierungs-Demo-Route und festes Demo-Layout entfernt; Widget-Registrierung in `frontend/src/widgetSystem/registerDashboardWidgets.js` (`ensureDashboardWidgetsRegistered`). Kern-Widgets unter `frontend/src/components/dashboard-widgets-legacy/`. Chart-Hilfen in `frontend/src/widgetSystem/dashboardChartUtils.js`. Experimentelles Layout-Lab entfernt; Konfiguration nur noch **Übersicht anpassen** (`DashboardConfigurePage`).
- **Doku:** `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` und Kommentar in `backend/widget_catalog.py` angepasst.
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
@ -110,11 +117,39 @@ frontend/src/
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
### Updates (12.09.2026 - BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch)
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Kombinationen; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**.
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
- **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel** (persönlich ändern; zentral nur Admin). Admin: Katalog → Manuell — Listen **Zentral** und **Persönlich**, beide änderbar. Stoffe & Attribute: Bezeichnung editierbar, Schlüssel fest; Makros nur ENERCC/PROT625/FAT/CHO → `nutrition_log`. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste.
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75
- **`nutrition_score`:** Registry in `backend/placeholder_registrations/nutrition_score.py`, Import in `placeholder_registrations/__init__.py`; Legacy-Duplikat unter „Scores“ im Platzhalter-Katalog entfernt.
### Updates (14.04.2026 - Activity Session Metrics EAV, Kern-Backend)
- **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`).
- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable).
- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` inkl. `name_*` / `description_*`; **`{{training_parameters_glossary_md}}`** = Markdown-Legende aller aktiven Parameter (KI).
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
### Updates (16.04.2026 - Aktivität Phase A abgeschlossen, Phase B gestartet)
- **Phase A:** Skalar-Kanon schriftlich fixiert — `.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md`; `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` v1.1; Agent-Guide Checkliste Phase A erledigt.
- **Phase B:** `GET /api/activity` (Liste) reichert jede Zeile mit `session_metrics` über `enrich_sessions_with_metrics` an (gleiche Merge-Logik wie Detail); Consumer-Audit-Tabelle in Produktions-Architektur-Dok §4 Phase B.
- **Phase B (Export):** `routers/exportdata.py` — JSON-Export `activity` mit `session_metrics`; CSV-Gesamtexport Training-Details mit EAV-Zusammenfassung; ZIP `data/activity.csv` mit Zusatzspalte `session_metrics_json` (Standard-Import unverändert).
- **Issue #53 / Layer 2a:** `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` — alle 20 Aktivitäts-Platzhalter gegen Layer 1 geprüft; Registry-Fix `activity_summary.resolver_function` → `get_activity_summary`.
- **Layer 2a Schritt 2:** Registry-Texte `activity_detail`, `training_sessions_recent_json` (dynamische session_metrics, Merge-Kanon); `trainingstyp_verteilung` Metadaten an Phase-0c-Code angeglichen.
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **Mifflin–St Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance).
@ -875,7 +910,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|Dashboard-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|Projekt-Doku (Git)|`docs/README.md` + `docs/issues/`|Issue-Specs, Reviews, Platzhalter-Governance, Status-Snapshots|
> Library-Dateien werden mit `/document` generiert und nach größeren

View File

@ -13,6 +13,8 @@ import bcrypt
from db import get_db, get_cursor
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
def hash_pin(pin: str) -> str:
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
@ -76,21 +78,24 @@ def require_auth(x_auth_token: Optional[str] = Header(default=None)):
return session
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), token: Optional[str] = Query(default=None)):
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)):
"""
FastAPI dependency - auth via header OR query parameter.
Used for endpoints accessed by <img> tags that can't send headers.
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
Usage:
@app.get("/api/photos/{id}")
def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
...
Call with: ?ssetoken=XXX or Header: X-Auth-Token: XXX
Raises:
HTTPException 401 if not authenticated
"""
session = get_session(x_auth_token or token)
session = get_session(x_auth_token or ssetoken)
if not session:
raise HTTPException(401, "Nicht eingeloggt")
return session

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

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

View File

@ -0,0 +1,321 @@
"""Official BLS 4.0 component labels (MRI documentation) and placeholder repair.
``attr_key`` stays the identity. These names only fill empty or key-equal ``name_de``.
"""
from __future__ import annotations
from typing import Any
# Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g
MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO")
GROUP_META = (
("energy", "Energie"),
("macro", "Makronährstoffe"),
("carbs", "Kohlenhydrate"),
("fiber", "Ballaststoffe"),
("fatty_acids", "Fettsäuren"),
("amino_acids", "Aminosäuren"),
("vitamins", "Vitamine"),
("minerals", "Mineralstoffe"),
("organic_acids", "Organische Säuren"),
("polyols", "Zuckeralkohole"),
("other", "Sonstige"),
("extension", "Eigene Erweiterungen"),
("unknown", "Ohne Gruppe"),
)
GROUP_LABELS = {key: label for key, label in GROUP_META}
GROUP_RANK = {key: i for i, (key, _) in enumerate(GROUP_META)}
# Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026)
_OFFICIAL: dict[str, tuple[str, str, str]] = {
"ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"),
"ENERCC": ("Energie (Kilokalorien)", "kcal", "energy"),
"WATER": ("Wasser", "g", "macro"),
"PROT625": ("Protein (N×6,25)", "g", "macro"),
"FAT": ("Fett", "g", "macro"),
"CHO": ("Kohlenhydrate, verfügbar", "g", "macro"),
"FIBT": ("Ballaststoffe, gesamt", "g", "fiber"),
"ALC": ("Alkohol (Ethanol)", "g", "macro"),
"OA": ("Organische Säuren, gesamt", "g", "organic_acids"),
"ASH": ("Rohasche", "g", "macro"),
"POLYL": ("Zuckeralkohole, gesamt", "g", "polyols"),
"GLUS": ("Glucose", "g", "carbs"),
"FRUS": ("Fructose", "g", "carbs"),
"GALS": ("Galactose", "g", "carbs"),
"MNSAC": ("Monosaccharide, gesamt", "g", "carbs"),
"SUCS": ("Saccharose", "g", "carbs"),
"MALS": ("Maltose", "g", "carbs"),
"LACS": ("Lactose", "g", "carbs"),
"DISAC": ("Disaccharide, gesamt", "g", "carbs"),
"SUGAR": ("Zucker, gesamt", "g", "carbs"),
"OLSAC": ("Oligosaccharide, verfügbar", "g", "carbs"),
"STARCH": ("Stärke", "g", "carbs"),
"FIBLMW": ("Ballaststoffe, niedermolekular", "g", "fiber"),
"FIBHMW": ("Ballaststoffe, hochmolekular", "g", "fiber"),
"FIBINS": ("Ballaststoffe, wasserunlöslich", "g", "fiber"),
"FIBSOL": ("Ballaststoffe, wasserlöslich", "g", "fiber"),
"FIBHMWS": ("Ballaststoffe, hochmolekular, wasserlöslich", "g", "fiber"),
"FIBHMWI": ("Ballaststoffe, hochmolekular, wasserunlöslich", "g", "fiber"),
"FASAT": ("Fettsäuren, gesättigt, gesamt", "g", "fatty_acids"),
"F4:0": ("Buttersäure (C4:0)", "g", "fatty_acids"),
"F6:0": ("Capronsäure (C6:0)", "g", "fatty_acids"),
"F8:0": ("Caprylsäure (C8:0)", "g", "fatty_acids"),
"F10:0": ("Caprinsäure (C10:0)", "g", "fatty_acids"),
"F12:0": ("Laurinsäure (C12:0)", "g", "fatty_acids"),
"F14:0": ("Myristinsäure (C14:0)", "g", "fatty_acids"),
"F15:0": ("Pentadecylsäure (C15:0)", "g", "fatty_acids"),
"F16:0": ("Palmitinsäure (C16:0)", "g", "fatty_acids"),
"F17:0": ("Margarinsäure (C17:0)", "g", "fatty_acids"),
"F18:0": ("Stearinsäure (C18:0)", "g", "fatty_acids"),
"F20:0": ("Arachinsäure (C20:0)", "g", "fatty_acids"),
"F22:0": ("Behensäure (C22:0)", "g", "fatty_acids"),
"F24:0": ("Lignocerinsäure (C24:0)", "g", "fatty_acids"),
"FAMS": ("Fettsäuren, einfach ungesättigt, gesamt", "g", "fatty_acids"),
"F14:1CN5": ("Myristoleinsäure (C14:1 n-5 cis)", "g", "fatty_acids"),
"F16:1CN7": ("Palmitoleinsäure (C16:1 n-7 cis)", "g", "fatty_acids"),
"F18:1CN7": ("Vaccensäure (C18:1 n-7 cis)", "g", "fatty_acids"),
"F18:1CN9": ("Ölsäure (C18:1 n-9 cis)", "g", "fatty_acids"),
"F20:1CN9": ("Gondosäure (C20:1 n-9 cis)", "g", "fatty_acids"),
"F22:1CN9": ("Erucasäure (C22:1 n-9 cis)", "g", "fatty_acids"),
"FAPU": ("Fettsäuren, mehrfach ungesättigt, gesamt", "g", "fatty_acids"),
"FAPUN3": ("Omega-3-Fettsäuren, gesamt", "g", "fatty_acids"),
"FAPUN6": ("Omega-6-Fettsäuren, gesamt", "g", "fatty_acids"),
"F18:2CN6": ("Linolsäure (C18:2 n-6 cis)", "g", "fatty_acids"),
"F18:2C9T11": ("Konjugierte Linolsäure (C18:2 cis-9, trans-11)", "g", "fatty_acids"),
"F18:3CN3": ("Alpha-Linolensäure (C18:3 n-3)", "g", "fatty_acids"),
"F18:3CN6": ("Gamma-Linolensäure (C18:3 n-6)", "g", "fatty_acids"),
"F18:4CN3": ("Stearidonsäure (C18:4 n-3)", "g", "fatty_acids"),
"F20:2CN6": ("Eicosadiensäure (C20:2 n-6)", "g", "fatty_acids"),
"F20:3CN6": ("Dihomo-Gamma-Linolensäure (C20:3 n-6)", "g", "fatty_acids"),
"F20:4CN6": ("Arachidonsäure (C20:4 n-6)", "g", "fatty_acids"),
"F20:5CN3": ("Eicosapentaensäure EPA (C20:5 n-3)", "g", "fatty_acids"),
"F22:5CN3": ("Docosapentaensäure DPA (C22:5 n-3)", "g", "fatty_acids"),
"F22:6CN3": ("Docosahexaensäure DHA (C22:6 n-3)", "g", "fatty_acids"),
"FAX": ("Fettsäuren, sonstige", "g", "fatty_acids"),
"AAE9": ("Aminosäuren, unentbehrlich, gesamt", "g", "amino_acids"),
"ILE": ("Isoleucin", "g", "amino_acids"),
"LEU": ("Leucin", "g", "amino_acids"),
"LYS": ("Lysin", "g", "amino_acids"),
"MET": ("Methionin", "g", "amino_acids"),
"PHE": ("Phenylalanin", "g", "amino_acids"),
"THR": ("Threonin", "g", "amino_acids"),
"TRP": ("Tryptophan", "g", "amino_acids"),
"VAL": ("Valin", "g", "amino_acids"),
"HIS": ("Histidin", "g", "amino_acids"),
"ALA": ("Alanin", "g", "amino_acids"),
"ARG": ("Arginin", "g", "amino_acids"),
"ASP": ("Asparaginsäure inkl. Asparagin", "g", "amino_acids"),
"CYS": ("Cystein", "g", "amino_acids"),
"GLU": ("Glutaminsäure inkl. Glutamin", "g", "amino_acids"),
"GLY": ("Glycin", "g", "amino_acids"),
"PRO": ("Prolin", "g", "amino_acids"),
"SER": ("Serin", "g", "amino_acids"),
"TYR": ("Tyrosin", "g", "amino_acids"),
"VITA": ("Vitamin A, Retinol-Äquivalent (RE)", "µg", "vitamins"),
"VITAA": ("Vitamin A, Retinol-Aktivitätsäquivalent (RAE)", "µg", "vitamins"),
"RETOL": ("Retinol", "µg", "vitamins"),
"CARTB": ("Beta-Carotin", "µg", "vitamins"),
"CAROTPAXB": ("Carotinoide außer Beta-Carotin", "µg", "vitamins"),
"VITD": ("Vitamin D", "µg", "vitamins"),
"ERGCAL": ("Vitamin D2 (Ergocalciferol)", "µg", "vitamins"),
"CHOCAL": ("Vitamin D3 (Cholecalciferol)", "µg", "vitamins"),
"VITE": ("Vitamin E (Alpha-Tocopherol)", "mg", "vitamins"),
"TOCPHA": ("Alpha-Tocopherol", "mg", "vitamins"),
"TOCPHB": ("Beta-Tocopherol", "mg", "vitamins"),
"TOCPHG": ("Gamma-Tocopherol", "mg", "vitamins"),
"TOCPHD": ("Delta-Tocopherol", "mg", "vitamins"),
"TOCTRA": ("Alpha-Tocotrienol", "mg", "vitamins"),
"VITK": ("Vitamin K", "µg", "vitamins"),
"VITK1": ("Vitamin K1 (Phyllochinon)", "µg", "vitamins"),
"VITK2": ("Vitamin K2 (Menachinone)", "µg", "vitamins"),
"THIA": ("Vitamin B1 (Thiamin)", "mg", "vitamins"),
"RIBF": ("Vitamin B2 (Riboflavin)", "mg", "vitamins"),
"NIA": ("Niacin", "mg", "vitamins"),
"NIAEQ": ("Niacin-Äquivalent", "mg", "vitamins"),
"PANTAC": ("Pantothensäure", "mg", "vitamins"),
"VITB6": ("Vitamin B6", "µg", "vitamins"),
"BIOT": ("Biotin", "µg", "vitamins"),
"FOL": ("Folat-Äquivalent", "µg", "vitamins"),
"FOLFD": ("Folat", "µg", "vitamins"),
"FOLAC": ("Folsäure, synthetisch", "µg", "vitamins"),
"VITB12": ("Vitamin B12 (Cobalamine)", "µg", "vitamins"),
"VITC": ("Vitamin C", "mg", "vitamins"),
"NACL": ("Salz (Natriumchlorid)", "g", "minerals"),
"NA": ("Natrium", "mg", "minerals"),
"CLD": ("Chlorid", "mg", "minerals"),
"K": ("Kalium", "mg", "minerals"),
"CA": ("Calcium", "mg", "minerals"),
"MG": ("Magnesium", "mg", "minerals"),
"P": ("Phosphor", "mg", "minerals"),
"S": ("Schwefel", "mg", "minerals"),
"FE": ("Eisen", "mg", "minerals"),
"ZN": ("Zink", "mg", "minerals"),
"ID": ("Iodid", "µg", "minerals"),
"CU": ("Kupfer", "µg", "minerals"),
"MN": ("Mangan", "µg", "minerals"),
"FD": ("Fluorid", "µg", "minerals"),
"CR": ("Chrom", "µg", "minerals"),
"MO": ("Molybdän", "µg", "minerals"),
"ACEAC": ("Essigsäure", "g", "organic_acids"),
"CITAC": ("Zitronensäure", "g", "organic_acids"),
"LACAC": ("Milchsäure", "g", "organic_acids"),
"MALAC": ("Äpfelsäure", "g", "organic_acids"),
"TARAC": ("Weinsäure", "g", "organic_acids"),
"MANTL": ("Mannit", "g", "polyols"),
"SORTL": ("Sorbit", "g", "polyols"),
"XYLTL": ("Xylit", "g", "polyols"),
"CHORL": ("Cholesterin", "mg", "other"),
"NT": ("Stickstoff, gesamt", "g", "other"),
"EPA": ("Eicosapentaensäure (EPA)", "g", "fatty_acids"),
"DHA": ("Docosahexaensäure (DHA)", "g", "fatty_acids"),
"DPA": ("Docosapentaensäure (DPA)", "g", "fatty_acids"),
"ALA": ("Alpha-Linolensäure (ALA)", "g", "fatty_acids"),
"OMEGA3": ("Omega-3-Fettsäuren gesamt", "g", "fatty_acids"),
"OMEGA6": ("Omega-6-Fettsäuren gesamt", "g", "fatty_acids"),
}
def lookup_key(raw: str | None) -> str:
return str(raw or "").strip().upper()
def resolve_group(attr_key: str | None, category: str | None = None, origin: str | None = None) -> tuple[str, str]:
cat = (category or "").strip().lower()
if cat in GROUP_LABELS:
return cat, GROUP_LABELS[cat]
lab = official_label(attr_key)
if lab and lab["category"] in GROUP_LABELS:
return lab["category"], GROUP_LABELS[lab["category"]]
if (origin or "") == "extension":
return "extension", GROUP_LABELS["extension"]
return "unknown", GROUP_LABELS["unknown"]
def annotate_attribute(row: dict[str, Any]) -> dict[str, Any]:
key = row.get("attr_key")
group_key, group_label = resolve_group(key, row.get("category"), row.get("origin"))
row["is_macro"] = key in MACRO_KEYS
row["needs_label"] = label_is_placeholder(row.get("name_de"), key)
row["group_key"] = group_key
row["group_label"] = group_label
row["group_rank"] = GROUP_RANK[group_key]
return row
def sort_attributes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
def key_of(row: dict[str, Any]):
return (
row.get("group_rank", 99),
0 if row.get("is_macro") else 1,
row.get("sort_order") if row.get("sort_order") is not None else 0,
(row.get("name_de") or row.get("attr_key") or "").casefold(),
row.get("attr_key") or "",
)
return sorted(rows, key=key_of)
def official_label(attr_key: str | None) -> dict[str, str] | None:
key = lookup_key(attr_key)
hit = _OFFICIAL.get(key)
if not hit:
hit = _OFFICIAL.get(key.replace("_", ":"))
if not hit:
return None
name_de, unit, category = hit
return {"name_de": name_de, "unit": unit, "category": category}
def label_is_placeholder(name_de: str | None, attr_key: str | None) -> bool:
name = (name_de or "").strip()
key = lookup_key(attr_key)
return (not name) or name.upper() == key
def apply_official_name(attr_key: str | None, name_de: str | None) -> str:
if not label_is_placeholder(name_de, attr_key):
return (name_de or "").strip()
lab = official_label(attr_key)
if lab:
return lab["name_de"]
return (name_de or "").strip() or lookup_key(attr_key)
def enrich_attributes_in_memory(attributes: list[dict[str, Any]]) -> list[dict[str, Any]]:
for a in attributes:
key = a.get("attr_key")
lab = official_label(key)
if label_is_placeholder(a.get("name_de"), key) and lab:
a["name_de"] = lab["name_de"]
a["unit"] = a.get("unit") or lab["unit"]
a["category"] = a.get("category") or lab["category"]
return attributes
def fill_placeholder_labels(cur) -> dict[str, int]:
cur.execute(
"""
SELECT id, attr_key, name_de, unit, category
FROM food_attributes
WHERE is_active = true
"""
)
updated = 0
for row in cur.fetchall():
rec = dict(row)
lab = official_label(rec.get("attr_key"))
if not lab:
continue
need_name = label_is_placeholder(rec.get("name_de"), rec.get("attr_key"))
need_cat = not (rec.get("category") or "").strip()
if not need_name and not need_cat:
continue
cur.execute(
"""
UPDATE food_attributes
SET name_de = %s,
unit = COALESCE(NULLIF(BTRIM(COALESCE(unit, '')), ''), %s),
category = COALESCE(NULLIF(BTRIM(COALESCE(category, '')), ''), %s),
updated_at = NOW()
WHERE id = %s
""",
(
lab["name_de"] if need_name else rec.get("name_de"),
lab["unit"],
lab["category"],
rec["id"],
),
)
updated += 1
return {"updated": updated}
def enrich_labels_from_food_headers(cur, headers: list[dict[str, Any]] | None) -> int:
updated = 0
for h in headers or []:
key = h.get("attr_key")
name = (h.get("name_de") or "").strip()
unit = (h.get("unit") or "").strip() or None
if not key or not name or label_is_placeholder(name, key):
lab = official_label(key)
if not lab:
continue
name = lab["name_de"]
unit = unit or lab["unit"]
cur.execute(
"""
UPDATE food_attributes
SET name_de = %s,
unit = COALESCE(%s, unit),
updated_at = NOW()
WHERE attr_key = %s
AND (
name_de IS NULL OR BTRIM(name_de) = '' OR name_de = attr_key
)
""",
(name, unit, key),
)
if cur.rowcount:
updated += cur.rowcount
return updated

View File

@ -0,0 +1,153 @@
"""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]:
from bls.attribute_labels import enrich_attributes_in_memory
attributes = enrich_attributes_in_memory(list(attributes))
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",
attribute_headers: list[dict[str, Any]] | None = None,
) -> dict[str, int]:
if attribute_headers:
from bls.attribute_labels import enrich_labels_from_food_headers
enrich_labels_from_food_headers(cur, attribute_headers)
cur.execute("SELECT attr_key, id FROM food_attributes")
attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()}
codes = [f["bls_code"] for f in foods if f.get("bls_code")]
existing: set[str] = set()
if codes:
cur.execute("SELECT bls_code FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
existing = {r["bls_code"] for r in cur.fetchall()}
inserted = sum(1 for c in codes if c not in existing)
updated = len(codes) - inserted
food_rows = [
(
f["bls_code"], f["name_de"], f.get("name_en"), f.get("food_group"),
"official_bls", bls_version, "bls_4.0",
)
for f in foods if f.get("bls_code")
]
if food_rows:
execute_values(
cur,
"""
INSERT INTO food_catalog
(bls_code, name_de, name_en, food_group, catalog_kind, bls_version, source)
VALUES %s
ON CONFLICT (bls_code) DO UPDATE SET
name_de = EXCLUDED.name_de,
name_en = EXCLUDED.name_en,
food_group = EXCLUDED.food_group,
bls_version = EXCLUDED.bls_version,
catalog_kind = 'official_bls',
source = 'bls_4.0',
is_active = true,
updated_at = NOW()
WHERE food_catalog.catalog_kind = 'official_bls'
""",
food_rows,
page_size=FOOD_PAGE,
)
food_ids: dict[str, Any] = {}
if codes:
cur.execute("SELECT bls_code, id FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
food_ids = {r["bls_code"]: r["id"] for r in cur.fetchall()}
value_rows = []
for f in foods:
food_id = food_ids.get(f.get("bls_code"))
if not food_id:
continue
for val in f.get("values") or []:
if not should_persist_value(val):
continue
aid = attr_ids.get(val["attr_key"])
if not aid:
continue
value_rows.append((
food_id,
aid,
None if val.get("is_trace") else val.get("value_num"),
bool(val.get("is_trace")),
val.get("origin_code"),
val.get("reference_text"),
))
if value_rows:
execute_values(
cur,
"""
INSERT INTO food_attribute_values
(food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
VALUES %s
ON CONFLICT (food_id, attribute_id) DO UPDATE SET
value_num = EXCLUDED.value_num,
is_trace = EXCLUDED.is_trace,
origin_code = EXCLUDED.origin_code,
reference_text = EXCLUDED.reference_text,
updated_at = NOW()
""",
value_rows,
template="(%s, %s, %s, %s, %s, %s, NOW())",
page_size=VALUE_PAGE,
)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index()
return {
"inserted": inserted,
"updated": updated,
"foods_inserted": inserted,
"foods_updated": updated,
"values_written": len(value_rows),
"foods_total": len(foods),
}

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

@ -0,0 +1,176 @@
"""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})
headers = parsed.get("attribute_headers")
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,
attribute_headers=headers if i == 0 else None,
)
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))

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

@ -0,0 +1,168 @@
"""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, exclude: tuple[str, ...] = ()) -> int | None:
for n in names:
if n.lower() in idx:
return idx[n.lower()]
for key, i in idx.items():
if any(ex in key for ex in exclude):
continue
for n in names:
if n.lower() in key:
return i
return None
i_code = col(
"component code", "komponentencode", "attr_key", "schlüssel", "schluessel",
"code", "komponente",
)
i_de = col(
"name_de", "bezeichnung_de", "bezeichnung deutsch", "name de",
"deutsch", "german", "bezeichnung",
exclude=("name_en", "bezeichnung_en", "english", "englisch", "name en"),
)
i_en = col("name_en", "bezeichnung_en", "name en", "english", "englisch")
i_unit = col("unit", "einheit")
i_cat = col("category", "kategorie", "gruppe")
if i_code is None:
i_code = 0
from bls.attribute_labels import apply_official_name
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", "component code"):
continue
raw_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "")
name_de = apply_official_name(code, raw_de)
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

@ -223,6 +223,11 @@ def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]:
"""28-day relaxed arm circumference change (cm)."""
return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28)
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
"""Calculate 28-day thigh circumference change (cm)"""
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)

View File

@ -47,6 +47,46 @@ def sniff_delimiter(sample_line: str) -> str:
return best
def _csv_field_count(line: str, delimiter: str) -> int:
"""Anzahl Felder in einer Zeile (csv.reader, berücksichtigt Anführungszeichen)."""
if not line or not line.strip():
return 0
try:
row = next(csv.reader(io.StringIO(line), delimiter=delimiter))
except StopIteration:
return 0
return len(row)
def resolve_effective_csv_delimiter(text: str, template_delimiter: str | None = None) -> str:
"""
Trennzeichen für die hochgeladene Datei wählen. Gespeicherte Vorlagen haben oft «,»
(Apple EN), tatsächliche Exporte je nach Region «;» (Apple DE / Excel) — mit falschem
Zeichen wird die Kopfzeile zu **einer** Spalte und das Mapping bricht vollständig.
"""
tpl = (template_delimiter or "").strip()
if tpl not in _DEFAULT_DELIMS:
tpl = None
lines = _split_first_lines(text, max_lines=5)
if not lines:
return tpl or ","
header = lines[0]
scores: list[tuple[int, str]] = []
for d in _DEFAULT_DELIMS:
scores.append((_csv_field_count(header, d), d))
max_n = max(n for n, _ in scores)
if max_n <= 1:
return tpl or sniff_delimiter(header)
at_max = [d for n, d in scores if n == max_n]
if tpl and tpl in at_max:
return tpl
return at_max[0]
def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
lines: List[str] = []
for line in text.splitlines():
@ -57,6 +97,18 @@ def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
return lines
def canonical_csv_header_label(name: str | None) -> str:
"""
Einheitlicher Spalten-Key für Analyse (Vorlage/Dialog), Import und Signatur.
BOM und NBSP (häufig in Excel/Apple-Exporten) werden vereinheitlicht, damit
field_mappings exakt zu DictReader-Zeilen passt.
"""
if name is None:
return ""
s = str(name).replace("\ufeff", "").replace("\u00a0", " ").strip()
return s
def parse_csv_sample(
text: str,
delimiter: str | None = None,
@ -85,7 +137,7 @@ def parse_csv_sample(
return [], [], delim
if has_header:
headers = [h.strip() for h in rows_raw[0]]
headers = [canonical_csv_header_label(h) for h in rows_raw[0]]
data = rows_raw[1 : 1 + max_data_rows]
else:
n = len(rows_raw[0])
@ -103,7 +155,7 @@ def parse_csv_sample(
def normalize_header_for_signature(name: str) -> str:
s = name.strip().lower()
s = canonical_csv_header_label(name).lower()
s = re.sub(r"\s+", "_", s)
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
return s.strip("_")
@ -111,7 +163,9 @@ def normalize_header_for_signature(name: str) -> str:
def column_signature(headers: List[str]) -> List[str]:
"""Sortierte normalisierte Spaltennamen für Signatur-Vergleich."""
return sorted({normalize_header_for_signature(h) for h in headers if h is not None and str(h).strip()})
return sorted(
{normalize_header_for_signature(h) for h in headers if h is not None and canonical_csv_header_label(str(h))}
)
def headers_signature_match_score(sig_csv: List[str], sig_template: List[str]) -> float:
@ -178,12 +232,6 @@ def get_csv_import_limits(conn_row: dict | None) -> dict[str, int]:
return defaults
def _strip_header_key(k: str | None) -> str:
if k is None:
return ""
return str(k).strip().removeprefix("\ufeff")
def iter_csv_dict_rows(
text: str,
delimiter: str,
@ -205,4 +253,8 @@ def iter_csv_dict_rows(
continue
if not any(v and str(v).strip() for v in row.values()):
continue
yield {_strip_header_key(k): (v or "").strip() for k, v in row.items() if _strip_header_key(k)}
yield {
canonical_csv_header_label(k): (v or "").strip()
for k, v in row.items()
if canonical_csv_header_label(k)
}

View File

@ -11,7 +11,31 @@ from typing import Any
import logging
from csv_parser.core import iter_csv_dict_rows
from csv_parser.core import iter_csv_dict_rows, normalize_header_for_signature, resolve_effective_csv_delimiter
_FOOD_NAME_HEADERS = frozenset({
"bezeichnung", "lebensmittel", "food", "food_name", "name", "gericht",
})
_QTY_HEADERS = frozenset({
"menge", "quantity", "quantity_raw", "portion", "amount", "gramm",
})
def guess_nutrition_item_fields(csv_row: dict[str, Any] | None) -> tuple[str | None, str | None]:
"""FDDB-Spalten auch ohne Vorlagen-Mapping auf food_name / menge erkennen."""
name = qty = None
for key, val in (csv_row or {}).items():
if val is None:
continue
text = str(val).strip().strip('"')
if not text:
continue
norm = normalize_header_for_signature(str(key))
if name is None and (norm in _FOOD_NAME_HEADERS or "bezeichnung" in norm):
name = text
if qty is None and (norm in _QTY_HEADERS or norm.startswith("menge")):
qty = text
return name, qty
from csv_parser.import_row_processing import (
aggregate_mapped_rows,
resolve_import_row_processing,
@ -23,14 +47,6 @@ from csv_parser.type_converter import build_row_after_mapping
logger = logging.getLogger(__name__)
try:
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
_EVALUATION_AVAILABLE = True
except Exception: # pragma: no cover
_evaluate_and_save_activity = None
_EVALUATION_AVAILABLE = False
def _resolve_training_type_for_activity(cur, activity_type: str, profile_id: str):
"""Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock)."""
@ -105,7 +121,8 @@ def run_universal_csv_import(
if tc is not None and not isinstance(tc, dict):
tc = None
delim = mapping.get("delimiter") or ","
tpl_delim = str(mapping.get("delimiter") or ",").strip() or ","
delim = resolve_effective_csv_delimiter(text, tpl_delim)
has_header = mapping.get("has_header", True)
if module == "nutrition":
@ -188,6 +205,8 @@ def run_universal_csv_import(
"error_details": error_details[:50],
"new_entries": stats.get("new_entries", stats.get("inserted", 0)),
"affected_ids": dict(affected_ids),
"items_written": stats.get("items_written", 0),
"unmapped_names": stats.get("unmapped_names", 0),
}
return out
@ -210,6 +229,11 @@ def _import_nutrition(
for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header):
rows_total += 1
mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition")
guessed_name, guessed_qty = guess_nutrition_item_fields(csv_row)
if not mapped.get("food_name") and guessed_name:
mapped["food_name"] = guessed_name
if not mapped.get("quantity_raw") and guessed_qty:
mapped["quantity_raw"] = guessed_qty
d = coerce_date(mapped.get("date"))
if d is None:
error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"})
@ -230,6 +254,50 @@ def _import_nutrition(
skipped_groups = sum(n.get("rows_in_group", 0) for n in (agg_notes or []) if n.get("error") == "mehrere_zeilen_pro_schluessel")
item_rows = []
for mapped in mapped_rows:
name = mapped.get("food_name")
if not name:
continue
d_item = coerce_date(mapped.get("date"))
if d_item is None:
continue
item_rows.append({
"date": d_item.isoformat(),
"logged_at": mapped.get("logged_at") or mapped.get("date"),
"food_name": str(name).strip(),
"quantity_raw": mapped.get("quantity_raw"),
"kcal": mapped.get("kcal"),
"protein_g": mapped.get("protein_g"),
"fat_g": mapped.get("fat_g"),
"carbs_g": mapped.get("carbs_g"),
})
if item_rows:
from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
policy = get_import_policy(cur, profile_id)
ingest = replace_csv_items_for_dates(cur, profile_id, item_rows, policy=policy)
cur.execute(
"""
SELECT COUNT(DISTINCT source_name_normalized) AS n
FROM nutrition_items
WHERE profile_id = %s AND food_id IS NULL
""",
(profile_id,),
)
unmapped_row = cur.fetchone() or {}
return {
"rows_total": rows_total,
"inserted": ingest.get("new_log_days", 0),
"updated": max(0, ingest.get("days_written", 0) - ingest.get("new_log_days", 0)),
"skipped": skipped_groups,
"new_entries": ingest.get("new_log_days", 0),
"items_written": ingest.get("items_written", 0),
"unmapped_names": int(unmapped_row.get("n") or 0),
"conflicts": ingest.get("conflicts") or [],
"policy": ingest.get("policy"),
}
inserted = 0
updated = 0
new_entries = 0
@ -814,6 +882,17 @@ def _import_activity(
error_details: list,
affected_ids: dict,
) -> dict[str, int]:
from data_layer.activity_time_normalize import normalize_activity_start
from data_layer.activity_persistence_orchestrator import (
activity_csv_registry_updates_from_mapped,
find_activity_duplicate_id,
insert_activity_csv_minimal,
new_activity_id,
run_activity_post_write_hooks_import,
update_activity_columns,
)
from data_layer.activity_session_metrics import upsert_session_metrics_from_csv_mapped
rows_total = 0
inserted = 0
updated = 0
@ -885,6 +964,7 @@ def _import_activity(
wtype = str(activity_type).strip()
iso = date_d.isoformat()
_, workout_start_t = normalize_activity_start(start_key)
# Pro Zeile: bei SQL-Fehler sonst „current transaction is aborted“ bis Xact-Ende.
cur.execute("SAVEPOINT csv_activity_row")
@ -892,113 +972,79 @@ def _import_activity(
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
cur, wtype, profile_id
)
cur.execute(
"""
SELECT id FROM activity_log
WHERE profile_id = %s AND date = %s AND start_time = %s
""",
(profile_id, iso, start_key),
)
existing = cur.fetchone()
registry_updates = activity_csv_registry_updates_from_mapped(mapped)
existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t)
if existing:
eid = existing["id"]
cur.execute(
"""
UPDATE activity_log
SET end_time = %s,
activity_type = %s,
duration_min = %s,
kcal_active = %s,
kcal_resting = %s,
hr_avg = %s,
hr_max = %s,
distance_km = %s,
training_type_id = %s,
training_category = %s,
training_subcategory = %s,
source = 'csv'
WHERE id = %s
RETURNING id
""",
(
end_str or None,
wtype,
duration_min,
kcal_a,
kcal_r,
hr_a,
hr_m,
dist,
training_type_id,
training_category,
training_subcategory,
eid,
),
)
row = cur.fetchone()
updated += 1
if row and row.get("id"):
affected_ids["activity_log"].append(str(row["id"]))
aid = eid
else:
eid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO activity_log (
id, profile_id, date, start_time, end_time, activity_type, duration_min,
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
source, training_type_id, training_category, training_subcategory, created
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s,%s,%s,CURRENT_TIMESTAMP)
RETURNING id
""",
(
eid,
profile_id,
iso,
start_key,
end_str or None,
wtype,
duration_min,
kcal_a,
kcal_r,
hr_a,
hr_m,
dist,
training_type_id,
training_category,
training_subcategory,
),
)
row = cur.fetchone()
inserted += 1
new_entries += 1
if row and row.get("id"):
affected_ids["activity_log"].append(str(row["id"]))
aid = eid
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
try:
activity_dict = {
"id": aid,
"profile_id": profile_id,
"date": iso,
"training_type_id": training_type_id,
if existing_id:
upd = {
"start_time": workout_start_t,
"end_time": end_str or None,
"activity_type": wtype,
"duration_min": duration_min,
"kcal_active": kcal_a,
"kcal_resting": kcal_r,
"hr_avg": hr_a,
"hr_max": hr_m,
"distance_km": dist,
"kcal_active": kcal_a,
"kcal_resting": kcal_r,
"rpe": None,
"pace_min_per_km": None,
"cadence": None,
"elevation_gain": None,
"training_type_id": training_type_id,
"training_category": training_category,
"training_subcategory": training_subcategory,
"source": "csv",
}
_evaluate_and_save_activity(cur, aid, activity_dict, training_type_id, profile_id)
except Exception as eval_err:
logger.warning("[csv activity] Auto-Eval fehlgeschlagen: %s", eval_err)
upd.update(registry_updates)
update_activity_columns(cur, profile_id, existing_id, upd)
updated += 1
affected_ids["activity_log"].append(str(existing_id))
aid = existing_id
else:
eid = new_activity_id()
insert_activity_csv_minimal(
cur,
profile_id,
eid,
date_iso=iso,
start_time=workout_start_t,
end_time=end_str or None,
activity_type=wtype,
duration_min=duration_min,
kcal_active=kcal_a,
kcal_resting=kcal_r,
hr_avg=hr_a,
hr_max=hr_m,
distance_km=dist,
training_type_id=training_type_id,
training_category=training_category,
training_subcategory=training_subcategory,
source="csv",
)
inserted += 1
new_entries += 1
affected_ids["activity_log"].append(str(eid))
aid = eid
if registry_updates:
update_activity_columns(cur, profile_id, aid, registry_updates)
run_activity_post_write_hooks_import(
cur,
profile_id,
str(aid),
workout_date=iso,
training_type_id=training_type_id,
duration_min=duration_min,
hr_avg=hr_a,
hr_max=hr_m,
distance_km=dist,
kcal_active=kcal_a,
kcal_resting=kcal_r,
)
upsert_session_metrics_from_csv_mapped(
cur,
profile_id,
str(aid),
mapped,
training_category,
training_type_id,
)
cur.execute("RELEASE SAVEPOINT csv_activity_row")
except Exception as e:
try:

View File

@ -37,12 +37,16 @@ def validate_import_row_processing(
module: str,
spec: Mapping[str, Any],
field_mappings: Mapping[str, Any],
cur=None,
) -> None:
"""Wirft ValueError bei ungültiger Konfiguration."""
mod = get_module_definition(module)
if not mod:
raise ValueError(f"Unbekanntes Modul: {module}")
allowed = set(mod.get("fields") or [])
if module == "activity" and cur is not None:
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
allowed.update(str(r["key"]) for r in cur.fetchall())
fm_targets = {str(v) for v in field_mappings.values() if v and v not in ("-", "_skip")}
group_by = spec.get("group_by") or []

View File

@ -20,6 +20,8 @@ _MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = {
"protein_g": frozenset({"protein", "eiwei", "eiweiss"}),
"fat_g": frozenset({"fett", "fat", "lipid"}),
"carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}),
"food_name": frozenset({"bezeichnung", "lebensmittel", "food", "gericht"}),
"quantity_raw": frozenset({"menge", "quantity", "portion", "gramm"}),
},
"weight": {
"date": frozenset({"datum", "date", "tag", "day", "zeit"}),
@ -127,13 +129,19 @@ def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | No
return None
def _alias_suggest(norm: str, module: str, used: set[str]) -> str | None:
def _alias_suggest(
norm: str,
module: str,
used: set[str],
*,
field_order: list[str] | None = None,
) -> str | None:
aliases = _MODULE_HEADER_ALIASES.get(module, {})
mod = get_module_definition(module)
if not mod:
return None
field_order = list(mod["fields"].keys())
for db_field in field_order:
order = field_order if field_order is not None else list(mod["fields"].keys())
for db_field in order:
if db_field in used:
continue
tokens = aliases.get(db_field, frozenset())
@ -152,6 +160,8 @@ def suggest_field_mappings(
headers: list[str],
module: str,
seed_fm: Mapping[str, str] | None = None,
*,
effective_fields: Mapping[str, Any] | None = None,
) -> dict[str, str]:
"""
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
@ -164,13 +174,16 @@ def suggest_field_mappings(
if not mod:
return {h: "-" for h in headers}
fields_map = dict(effective_fields) if effective_fields is not None else dict(mod["fields"])
field_order = list(fields_map.keys())
fm: dict[str, str] = {h: "-" for h in headers}
used: set[str] = set()
if seed_fm:
for h in headers:
db = _match_seed_to_db_field(h, seed_fm)
if db and db not in used:
if db and db not in used and db in fields_map:
fm[h] = db
used.add(db)
@ -178,7 +191,7 @@ def suggest_field_mappings(
if fm[h] != "-":
continue
norm = _norm_key(h)
db = _alias_suggest(norm, module, used)
db = _alias_suggest(norm, module, used, field_order=field_order)
if db:
fm[h] = db
used.add(db)
@ -190,6 +203,8 @@ def build_type_conversions_for_mapping(
module: str,
field_mappings: Mapping[str, str],
seed_tc: Mapping[str, Any] | None = None,
*,
effective_fields: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
if module == "sleep":
@ -198,6 +213,7 @@ def build_type_conversions_for_mapping(
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
out: dict[str, Any] = {}
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
field_meta = dict(effective_fields) if effective_fields is not None else None
if seed_tc:
for k, v in seed_tc.items():
@ -208,6 +224,20 @@ def build_type_conversions_for_mapping(
if t not in out and t in defaults:
out[t] = deepcopy(defaults[t])
for t in sorted(targets):
if t in out:
continue
finfo = (field_meta or {}).get(t) if field_meta else None
if not finfo:
continue
typ = finfo.get("type")
if typ == "int":
out[t] = {"type": "int", "flexible": True}
elif typ == "float":
out[t] = {"type": "float", "decimal_separator": "auto", "flexible": True}
else:
out[t] = {"type": "string"}
_apply_energy_kj_hint_from_headers(module, field_mappings, out)
return out

View File

@ -20,6 +20,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"fat_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"carbs_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
"food_name": {"type": "string", "required": False, "label_de": "Lebensmittel"},
"quantity_raw": {"type": "string", "required": False, "label_de": "Menge"},
},
"duplicate_key": ["profile_id", "date"],
"duplicate_strategy": "update",
@ -34,19 +36,39 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
},
},
},
# Kanon: nur Kern/spine + „heiße“ Metriken → activity_log. Erweiterte Parameter → training_parameters / EAV
# (siehe backend/data_layer/activity_data_canon.py).
"activity": {
"table": "activity_log",
"fields": {
"date": {"type": "date", "required": False},
"start_time": {"type": "datetime", "required": False},
"end_time": {"type": "datetime", "required": False},
"activity_type": {"type": "string", "required": True},
"duration_min": {"type": "float", "required": False, "min": 0},
"kcal_active": {"type": "float", "required": False, "unit": "kcal"},
"kcal_resting": {"type": "float", "required": False, "unit": "kcal"},
"distance_km": {"type": "float", "required": False, "unit": "km"},
"hr_avg": {"type": "float", "required": False, "min": 30, "max": 220},
"hr_max": {"type": "float", "required": False, "min": 30, "max": 220},
"date": {"type": "date", "required": False, "label_de": "Datum"},
"start_time": {
"type": "datetime",
"required": False,
"label_de": "Start (Datum/Uhrzeit)",
},
"end_time": {"type": "datetime", "required": False, "label_de": "Ende (Datum/Uhrzeit)"},
"activity_type": {"type": "string", "required": True, "label_de": "Trainingsart / Workout-Typ"},
"duration_min": {"type": "float", "required": False, "min": 0, "label_de": "Dauer (Minuten)"},
"kcal_active": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien aktiv"},
"kcal_resting": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien Ruhe"},
"distance_km": {"type": "float", "required": False, "unit": "km", "label_de": "Distanz (km)"},
"hr_avg": {
"type": "float",
"required": False,
"min": 30,
"max": 220,
"label_de": "Herzfrequenz Ø (bpm)",
},
"hr_max": {
"type": "float",
"required": False,
"min": 30,
"max": 220,
"label_de": "Herzfrequenz max (bpm)",
},
"rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"},
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
},
"derive_date_from_datetime_field": "start_time",
"duplicate_key": ["profile_id", "date", "start_time"],
@ -125,13 +147,16 @@ def list_modules() -> list[str]:
return sorted(MODULE_DEFINITIONS.keys())
def validate_field_mappings(module: str, field_mappings: dict) -> None:
def validate_field_mappings(module: str, field_mappings: dict, cur=None) -> None:
"""Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld."""
mod = get_module_definition(module)
if not mod:
raise ValueError(f"Unbekanntes Modul: {module}")
fields = cast(dict, mod["fields"])
allowed = set(fields.keys())
if module == "activity" and cur is not None:
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
allowed.update(str(r["key"]) for r in cur.fetchall())
if not allowed:
for _csv_col, db_field in field_mappings.items():
if db_field not in ("", None, "-", "_skip"):

View File

@ -15,6 +15,7 @@ from csv_parser.module_registry import (
validate_field_mappings,
validate_required_field_targets,
)
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
ALLOWED_SPEC_TYPES = frozenset(
{"string", "float", "number", "int", "date", "time", "datetime", "duration"}
@ -50,6 +51,8 @@ def validate_csv_template(
type_conversions: Mapping[str, Any] | None = None,
import_row_processing: Mapping[str, Any] | None = None,
column_signature: list[str] | None = None,
*,
cur=None,
) -> dict[str, Any]:
"""
Prüft eine Vorlage ohne Datei-Upload.
@ -74,8 +77,12 @@ def validate_csv_template(
)
return {"valid": False, "errors": errors, "warnings": warnings}
field_defs = dict(mod.get("fields") or {})
if module == "activity" and cur is not None:
field_defs = merge_activity_csv_module_fields(cur, field_defs)
try:
validate_field_mappings(module, fm)
validate_field_mappings(module, fm, cur=cur)
except ValueError as e:
errors.append(
_issue(
@ -100,7 +107,7 @@ def validate_csv_template(
if import_row_processing:
try:
validate_import_row_processing_spec(module, import_row_processing, fm)
validate_import_row_processing_spec(module, import_row_processing, fm, cur=cur)
except ValueError as e:
errors.append(
_issue(
@ -111,7 +118,6 @@ def validate_csv_template(
)
)
field_defs = mod.get("fields") or {}
for db_field, spec in tc.items():
if db_field not in field_defs:
errors.append(

View File

@ -14,7 +14,7 @@ from typing import Any, Mapping, Sequence
from dateutil import parser as dateutil_parser
from csv_parser.core import normalize_header_for_signature
from csv_parser.core import canonical_csv_header_label, normalize_header_for_signature
from csv_parser.field_units import factor_source_to_canonical
# Alias → strptime (JSON in Kleinbuchstaben)
@ -477,7 +477,12 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert
(wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
dann Abgleich aller Vorlagen-Keys über deren Normalform.
Zusätzlich: Präfix-Treffer für lange manuelle Keys (z. B. Apple
„Aufgestiegene Höhe (m)“ → ``aufgestiegene_höhe_(m)`` vs. Mapping
„aufgestiegene Höhe“ → ``aufgestiegene_höhe``) — gewinnt der längste passende Key.
"""
csv_col = canonical_csv_header_label(csv_col)
v = field_mappings.get(csv_col)
if v:
return v if v not in ("-", "_skip") else None
@ -488,6 +493,27 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
for k, fv in field_mappings.items():
if normalize_header_for_signature(str(k)) == norm:
return fv if fv not in ("-", "_skip") else None
# Präfix-Match (min. Länge gegen false positives wie „datum“ → „datum_xyz“)
best_fv: str | None = None
best_nk_len = 0
min_prefix = 10
for k, fv in field_mappings.items():
if not fv or fv in ("-", "_skip"):
continue
nk = normalize_header_for_signature(str(k))
if len(nk) < min_prefix or len(nk) >= len(norm):
continue
if not norm.startswith(nk):
continue
boundary = norm[len(nk) : len(nk) + 1]
if boundary not in ("", "_", "("):
continue
if len(nk) > best_nk_len:
best_nk_len = len(nk)
best_fv = fv
if best_fv:
return best_fv
return None

View File

@ -1,10 +1,11 @@
"""
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Servertemplate (`lab_default_layout_dict`).
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
"""
from __future__ import annotations
import copy
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
@ -25,12 +26,13 @@ __all__ = [
"coalesce_effective_layout",
"default_layout_dict",
"lab_default_layout_dict",
"merge_missing_catalog_widgets",
"product_default_layout_dict",
]
def lab_default_layout_dict() -> dict[str, Any]:
"""Standard für Dashboard-Lab (Experimentier-Widgets)."""
"""Serverseitiges Standardlayout (DEFAULT_LAB_WIDGET_IDS); API-Feld `lab_default_layout`, u. a. für Editor/Reset."""
on = DEFAULT_LAB_WIDGET_IDS
return {
"version": 1,
@ -52,6 +54,25 @@ def default_layout_dict() -> dict[str, Any]:
return product_default_layout_dict()
def merge_missing_catalog_widgets(layout: dict[str, Any]) -> dict[str, Any]:
"""
Hängt fehlende Widget-IDs aus WIDGET_CATALOG an (enabled=False, leere config).
Bestehende Reihenfolge bleibt erhalten — nötig, damit neue Katalog-Einträge in
„Übersicht anpassen“ / Lab erscheinen, ohne dass Nutzer:innen das Layout resetten müssen.
"""
out = copy.deepcopy(layout)
widgets: list[dict[str, Any]] = list(out.get("widgets") or [])
seen: set[str] = {str(w["id"]) for w in widgets if w.get("id")}
for e in WIDGET_CATALOG:
wid = e["id"]
if wid not in seen:
widgets.append({"id": wid, "enabled": False, "config": {}})
seen.add(wid)
out["version"] = out.get("version", 1)
out["widgets"] = widgets
return out
class DashboardWidgetEntry(BaseModel):
id: str = Field(min_length=1, max_length=64)
enabled: bool = True

View File

@ -14,12 +14,18 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"body_overview",
"body_history_viz",
"nutrition_history_viz",
"fitness_history_viz",
"recovery_history_viz",
"history_overview_viz",
"activity_overview",
"kpi_board",
"quick_capture",
"trend_kcal_weight",
"nutrition_detail_charts",
"recovery_charts_panel",
"report_export",
})
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
@ -32,6 +38,141 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
_BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_goals_strip",
"show_intro_blurb",
"show_layer_meta",
"show_kpis",
"show_weight_chart",
"show_body_fat_chart",
"show_proportion_chart",
"show_circumference_index_chart",
"show_circumference_lines_chart",
})
_BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_goals_strip": False,
"show_intro_blurb": False,
"show_layer_meta": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_weight_chart": True,
"show_body_fat_chart": False,
"show_proportion_chart": False,
"show_circumference_index_chart": False,
"show_circumference_lines_chart": False,
}
_NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_goals_strip",
"show_intro_blurb",
"show_kpis",
"show_kcal_vs_weight",
"show_calorie_balance_chart",
"show_protein_lean_chart",
"show_heuristics",
"show_macro_daily_bars",
"show_macro_distribution_pair",
"show_energy_protein_charts",
})
_NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_goals_strip": False,
"show_intro_blurb": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_kcal_vs_weight": True,
"show_calorie_balance_chart": False,
"show_protein_lean_chart": False,
"show_heuristics": False,
"show_macro_daily_bars": True,
"show_macro_distribution_pair": True,
"show_energy_protein_charts": False,
}
_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_layer_meta",
"show_kpis",
"show_progress_insights",
"show_chart_training_volume",
"show_chart_training_type_distribution",
"show_chart_quality_sessions",
"show_chart_load_monitoring",
})
_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_layer_meta": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_progress_insights": False,
"show_chart_training_volume": True,
"show_chart_training_type_distribution": True,
"show_chart_quality_sessions": False,
"show_chart_load_monitoring": False,
}
_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_layer_meta",
"show_kpis",
"show_progress_insights",
"show_sleep_section_heading",
"show_chart_recovery_score",
"show_chart_sleep_quality",
"show_chart_sleep_debt",
"show_heart_section_heading",
"show_heart_context_card",
"show_chart_hrv_rhr",
"show_vitals_extra_heading",
"show_vitals_extra_trends",
})
_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_layer_meta": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_progress_insights": False,
"show_sleep_section_heading": True,
"show_chart_recovery_score": True,
"show_chart_sleep_quality": True,
"show_chart_sleep_debt": False,
"show_heart_section_heading": True,
"show_heart_context_card": False,
"show_chart_hrv_rhr": True,
"show_vitals_extra_heading": False,
"show_vitals_extra_trends": False,
}
_HISTORY_OVERVIEW_VIZ_SECTION_KEYS: frozenset[str] = frozenset({
"show_section_body",
"show_section_nutrition",
"show_section_fitness",
"show_section_recovery",
})
_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_confidence_banner",
"show_intro_blurb",
*_HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
"show_correlation_c1_c3",
"show_drivers_c4",
})
_HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_confidence_banner": True,
"show_intro_blurb": True,
"show_section_body": True,
"show_section_nutrition": True,
"show_section_fitness": True,
"show_section_recovery": True,
"show_correlation_c1_c3": True,
"show_drivers_c4": True,
}
def _config_json_size_bytes(config: dict[str, Any]) -> int:
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
@ -39,19 +180,44 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
if raw is None:
return {}
raw = {}
if not isinstance(raw, dict):
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
if not raw:
return {}
if widget_id not in WIDGETS_ALLOWING_CONFIG:
if raw:
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
return {}
if not raw:
if widget_id == "body_history_viz":
return _validate_body_history_viz_config({})
if widget_id == "nutrition_history_viz":
return _validate_nutrition_history_viz_config({})
if widget_id == "fitness_history_viz":
return _validate_fitness_history_viz_config({})
if widget_id == "recovery_history_viz":
return _validate_recovery_history_viz_config({})
if widget_id == "history_overview_viz":
return _validate_history_overview_viz_config({})
if widget_id == "report_export":
return _validate_report_export_config({})
return {}
if widget_id == "body_overview":
return _validate_chart_days_only(raw, label="body_overview")
if widget_id == "body_history_viz":
return _validate_body_history_viz_config(raw)
if widget_id == "nutrition_history_viz":
return _validate_nutrition_history_viz_config(raw)
if widget_id == "fitness_history_viz":
return _validate_fitness_history_viz_config(raw)
if widget_id == "recovery_history_viz":
return _validate_recovery_history_viz_config(raw)
if widget_id == "history_overview_viz":
return _validate_history_overview_viz_config(raw)
if widget_id == "activity_overview":
return _validate_chart_days_only(raw, label="activity_overview")
if widget_id == "kpi_board":
@ -64,6 +230,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
if widget_id == "recovery_charts_panel":
return _validate_chart_days_only(raw, label="recovery_charts_panel")
if widget_id == "report_export":
return _validate_report_export_config(raw)
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
@ -150,6 +318,210 @@ def _parse_chart_days(v: Any, label: str) -> int:
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "body_history_viz"
allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS)
for k in _BODY_HISTORY_VIZ_BOOL_KEYS:
if k not in raw:
continue
v = raw[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
v = _parse_chart_days(raw["chart_days"], label)
if v < 7 or v > 90:
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
if not out["show_kpis"] and not any(
out[k]
for k in (
"show_weight_chart",
"show_body_fat_chart",
"show_proportion_chart",
"show_circumference_index_chart",
"show_circumference_lines_chart",
)
):
raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein")
return out
def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "nutrition_history_viz"
allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS)
for k in _NUTRITION_HISTORY_VIZ_BOOL_KEYS:
if k not in raw:
continue
v = raw[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
v = _parse_chart_days(raw["chart_days"], label)
if v < 7 or v > 90:
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
if not out["show_kpis"] and not any(
out[k]
for k in (
"show_kcal_vs_weight",
"show_calorie_balance_chart",
"show_protein_lean_chart",
"show_heuristics",
"show_macro_daily_bars",
"show_macro_distribution_pair",
"show_energy_protein_charts",
)
):
raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein")
return out
def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "fitness_history_viz"
allowed = _FITNESS_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_FITNESS_HISTORY_VIZ_DEFAULTS)
for k in _FITNESS_HISTORY_VIZ_BOOL_KEYS:
if k not in raw:
continue
v = raw[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
v = _parse_chart_days(raw["chart_days"], label)
if v < 7 or v > 90:
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
if not out["show_kpis"] and not out["show_progress_insights"] and not any(
out[k]
for k in (
"show_chart_training_volume",
"show_chart_training_type_distribution",
"show_chart_quality_sessions",
"show_chart_load_monitoring",
)
):
raise ValueError(f"{label}: mindestens KPIs, Einschätzungen oder ein Chart muss sichtbar sein")
return out
def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "recovery_history_viz"
allowed = _RECOVERY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_RECOVERY_HISTORY_VIZ_DEFAULTS)
for k in _RECOVERY_HISTORY_VIZ_BOOL_KEYS:
if k not in raw:
continue
v = raw[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
v = _parse_chart_days(raw["chart_days"], label)
if v < 7 or v > 90:
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
if not out["show_kpis"] and not out["show_progress_insights"] and not out["show_heart_context_card"] and not out[
"show_vitals_extra_trends"
] and not any(
out[k]
for k in (
"show_chart_recovery_score",
"show_chart_sleep_quality",
"show_chart_sleep_debt",
"show_chart_hrv_rhr",
)
):
raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein")
return out
def _migrate_history_overview_viz_raw(raw: dict[str, Any]) -> dict[str, Any]:
"""Alt: show_area_summaries → vier show_section_* (nur wo keine expliziten Section-Keys gesetzt)."""
r = dict(raw)
if "show_area_summaries" not in r:
return r
leg = r.pop("show_area_summaries")
if not isinstance(leg, bool):
raise ValueError("history_overview_viz: show_area_summaries muss boolean sein (veraltet — nutze show_section_*)")
for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS:
if k not in r:
r[k] = leg
return r
def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "history_overview_viz"
raw_m = _migrate_history_overview_viz_raw(raw)
allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"})
unknown = set(raw_m) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_HISTORY_OVERVIEW_VIZ_DEFAULTS)
for k in _HISTORY_OVERVIEW_VIZ_BOOL_KEYS:
if k not in raw_m:
continue
v = raw_m[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
if "chart_days" in raw_m:
v = _parse_chart_days(raw_m["chart_days"], label)
if v < 7 or v > 90:
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
has_section = any(out[k] for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS)
has_other = any(
out[k]
for k in (
"show_confidence_banner",
"show_correlation_c1_c3",
"show_drivers_c4",
)
)
if not has_section and not has_other:
raise ValueError(
f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1–C3) oder Treiber (C4) muss sichtbar sein"
)
return out
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
allowed = frozenset({"chart_days"})
unknown = set(raw) - allowed
@ -163,3 +535,43 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
return {"chart_days": v}
def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "report_export"
allowed = frozenset({"document_title", "subtitle", "capture_scale"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = {"capture_scale": 2}
if "document_title" in raw:
t = raw["document_title"]
if t is not None and not isinstance(t, str):
raise ValueError(f"{label}: document_title muss Text sein")
s = (t or "").strip()
if len(s) > 120:
raise ValueError(f"{label}: document_title max. 120 Zeichen")
if s:
out["document_title"] = s
if "subtitle" in raw:
t = raw["subtitle"]
if t is not None and not isinstance(t, str):
raise ValueError(f"{label}: subtitle muss Text sein")
s = (t or "").strip()
if len(s) > 240:
raise ValueError(f"{label}: subtitle max. 240 Zeichen")
if s:
out["subtitle"] = s
if "capture_scale" in raw:
v = raw["capture_scale"]
if isinstance(v, bool) or isinstance(v, float):
if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
v = int(round(v))
else:
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
if not isinstance(v, int):
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
if v < 1 or v > 3:
raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
out["capture_scale"] = v
return out

View File

@ -70,6 +70,7 @@ __all__ = [
'calculate_hip_28d_delta',
'calculate_chest_28d_delta',
'calculate_arm_28d_delta',
'calculate_arm_relaxed_28d_delta',
'calculate_thigh_28d_delta',
'calculate_waist_hip_ratio',
'calculate_recomposition_quadrant',

View File

@ -0,0 +1,61 @@
"""
Kanonische Aufteilung activity_log vs. EAV für Aktivitätssessions.
- **Kern / Mapping-Ziele für activity_log:** ausschließlich die Keys aus
``csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields`` (keine zweite hartcodierte Liste).
- **Alle anderen Attribute:** ``training_parameters`` + Attributprofil (Kategorie/Typ) → EAV;
Lesefallback für bekannte Legacy-Spalten siehe unten.
Normative Doku: .claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md,
ACTIVITY_SCALAR_KANON_TABLE.md
"""
from __future__ import annotations
from typing import Dict, Final
from csv_parser.module_registry import get_module_definition
def get_activity_module_registry_field_keys() -> frozenset[str]:
"""Keys des Universal-CSV-Moduls ``activity`` (= feste activity_log-Kernfelder / Mapping-Ziele)."""
mod = get_module_definition("activity")
if not mod:
return frozenset()
return frozenset((mod.get("fields") or {}).keys())
# Gleiche Menge wie ``MODULE_DEFINITIONS["activity"].fields`` — zur Laufzeit aus der Registry abgeleitet.
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module_registry_field_keys()
# Teil-UPDATEs (Import): alle Kernfelder außer ``date`` (Identität / Duplikat-Key).
ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"}
# Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL.
# Lesen (Merge): activity_log-Legacy-Spalte schlägt EAV, wenn beide befüllt; sonst EAV.
ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset(
{
"min_hr",
"pace_min_per_km",
"cadence",
"avg_power",
"elevation_gain",
"temperature_celsius",
"humidity_percent",
"avg_hr_percent",
"kcal_per_km",
}
)
# Spaltenname activity_log für Legacy-Merge (Vorrang vor EAV bei gesetztem Spaltenwert).
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = {
"min_hr": "hr_min",
"pace_min_per_km": "pace_min_per_km",
"cadence": "cadence",
"avg_power": "avg_power",
"elevation_gain": "elevation_gain",
"temperature_celsius": "temperature_celsius",
"humidity_percent": "humidity_percent",
"avg_hr_percent": "avg_hr_percent",
"kcal_per_km": "kcal_per_km",
}

View File

@ -10,6 +10,7 @@ Functions:
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
- get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI
All functions return structured data (dict) without formatting.
Use placeholder_resolver.py for formatted strings for AI.
@ -22,7 +23,12 @@ from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta, date, time
import statistics
from db import get_db, get_cursor, r2d
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates
from data_layer.prompt_output_compact import (
normalize_prompt_number,
session_metrics_list_to_key_value_compact,
)
def get_activity_summary_data(
@ -123,7 +129,8 @@ def get_activity_detail_data(
"duration_min": int,
"kcal_active": int,
"hr_avg": int | None,
"training_category": str | None
"training_category": str | None,
"session_metrics": list | None, # EAV (enrich_sessions_with_metrics)
},
...
],
@ -142,6 +149,7 @@ def get_activity_detail_data(
cur.execute(
"""SELECT
id,
date,
activity_type,
duration_min,
@ -152,7 +160,7 @@ def get_activity_detail_data(
WHERE profile_id=%s AND date >= %s
ORDER BY date DESC
LIMIT %s""",
(profile_id, cutoff, limit)
(profile_id, cutoff, limit),
)
rows = cur.fetchall()
@ -161,19 +169,24 @@ def get_activity_detail_data(
"activities": [],
"total_count": 0,
"confidence": "insufficient",
"days_analyzed": days
"days_analyzed": days,
}
activities = []
for row in rows:
activities.append({
"date": row['date'],
"activity_type": row['activity_type'],
"duration_min": safe_int(row['duration_min']),
"kcal_active": safe_int(row['kcal_active']),
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
"training_category": row.get('training_category')
})
activities.append(
{
"id": str(row["id"]),
"date": row["date"],
"activity_type": row["activity_type"],
"duration_min": safe_int(row["duration_min"]),
"kcal_active": safe_int(row["kcal_active"]),
"hr_avg": safe_int(row["hr_avg"]) if row.get("hr_avg") else None,
"training_category": row.get("training_category"),
}
)
enrich_sessions_with_metrics(cur, activities)
confidence = calculate_confidence(len(activities), days, "general")
@ -181,7 +194,7 @@ def get_activity_detail_data(
"activities": activities,
"total_count": len(activities),
"confidence": confidence,
"days_analyzed": days
"days_analyzed": days,
}
@ -317,24 +330,30 @@ def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
return int(row['session_count']) if row else None
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
"""Calculate percentage of quality sessions (good or better) last 28 days"""
def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]:
"""Anteil qualitativ guter Sessions (quality_label) im Zeitfenster ``days``."""
if days < 1:
days = 28
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
cur.execute(
"""
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
FROM activity_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '28 days'
""", (profile_id,))
AND date >= %s
""",
(profile_id, cutoff),
)
row = cur.fetchone()
if not row or row['total'] == 0:
if not row or row["total"] == 0:
return None
pct = (row['quality_count'] / row['total']) * 100
pct = (row["quality_count"] / row["total"]) * 100
return int(pct)
@ -482,11 +501,12 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
# A5: Load Monitoring (Proxy-based)
# ============================================================================
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Optional[float]:
"""
Calculate proxy internal load (last 7 days)
Formula: duration × intensity_factor × quality_factor
Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage).
"""
if days < 1:
days = 7
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
quality_factors = {
'excellent': 1.15,
@ -499,12 +519,15 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
cur.execute(
"""
SELECT duration_min, hr_avg, rpe
FROM activity_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '7 days'
""", (profile_id,))
AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day')
""",
(profile_id, days),
)
activities = cur.fetchall()
@ -541,7 +564,12 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
total_load += load
return int(total_load)
return float(total_load)
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[float]:
"""Letzte 7 Tage — Kompatibilität mit Platzhaltern / älteren Aufrufern."""
return calculate_proxy_internal_load_window(profile_id, 7)
def calculate_monotony_score(profile_id: str) -> Optional[float]:
@ -1085,6 +1113,10 @@ def get_training_sessions_recent_weeks_data(
Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4).
session_metrics pro Einheit: kompaktes Objekt ``{key: Wert}`` (keine wiederholten
Namen/Beschreibungen). Bedeutung der Keys: Platzhalter ``{{training_parameters_glossary_md}}``.
Zahlen werden für Prompt-Token kompakt gerundet.
"""
days = max(weeks * 7, 7)
with get_db() as conn:
@ -1112,6 +1144,7 @@ def get_training_sessions_recent_weeks_data(
(profile_id, cutoff),
)
rows = [r2d(r) for r in cur.fetchall()]
enrich_sessions_with_metrics(cur, rows)
if not rows:
return {
@ -1121,6 +1154,8 @@ def get_training_sessions_recent_weeks_data(
"days_loaded": days,
"session_count": 0,
"confidence": "insufficient",
"session_metrics_shape": "key_value",
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
},
}
@ -1139,18 +1174,21 @@ def get_training_sessions_recent_weeks_data(
kcal_f = float(kcal) if kcal is not None else None
hr_a = r.get("hr_avg")
hr_m = r.get("hr_max")
sm_compact = session_metrics_list_to_key_value_compact(r.get("session_metrics"))
by_week[wk].append(
{
"id": str(r["id"]),
"date": d,
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
"activity_type": r.get("activity_type"),
"training_category": r.get("training_category"),
"training_type_name": r.get("training_type_name"),
"duration_min": dur_f,
"kcal_active": kcal_f,
"duration_min": normalize_prompt_number(dur_f) if dur_f is not None else None,
"kcal_active": normalize_prompt_number(kcal_f) if kcal_f is not None else None,
"hr_avg": int(hr_a) if hr_a is not None else None,
"hr_max": int(hr_m) if hr_m is not None else None,
"rpe": int(r["rpe"]) if r.get("rpe") is not None else None,
"session_metrics": sm_compact,
}
)
@ -1165,6 +1203,335 @@ def get_training_sessions_recent_weeks_data(
"days_loaded": days,
"session_count": len(rows),
"confidence": confidence,
"session_metrics_shape": "key_value",
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
},
}
)
def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
"""
Alle aktiven ``training_parameters`` für KI-Kontext (z. B. neben ``training_sessions_recent_json``).
Enthält technischen key, name_de/name_en, description_de/description_en, data_type, unit, category.
Args:
profile_id: Reserviert für spätere Einschränkung (z. B. nur im Profil vorkommende Keys);
aktuell ungenutzt, Signatur bleibt für Platzhalter-Resolver.
"""
_ = profile_id
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT key, name_de, name_en, description_de, description_en,
data_type, unit, category
FROM training_parameters
WHERE is_active = true
ORDER BY category, key
"""
)
rows = [r2d(r) for r in cur.fetchall()]
return {
"parameters": rows,
"meta": {"count": len(rows), "scope": "global_active_catalog"},
}
# ============================================================================
# Chart payloads (Phase 0c / Layer 1) — gemeinsam mit charts-Router und Layer-2b-Bundles
# ============================================================================
def build_training_volume_chart_payload(profile_id: str, weeks: int) -> Dict[str, Any]:
"""
Wöchentliches Trainingsvolumen (Minuten) — gleiche Logik wie GET /api/charts/training-volume.
"""
if weeks < 4:
weeks = 4
if weeks > 52:
weeks = 52
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT
DATE_TRUNC('week', date) as week_start,
SUM(duration_min) as total_minutes,
COUNT(*) as session_count
FROM activity_log
WHERE profile_id=%s AND date >= %s
GROUP BY week_start
ORDER BY week_start""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Aktivitätsdaten vorhanden",
},
}
labels = [row["week_start"].strftime("KW %V") for row in rows]
values = [safe_float(row["total_minutes"]) for row in rows]
confidence = calculate_confidence(len(rows), weeks * 7, "general")
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Trainingsminuten",
"data": values,
"backgroundColor": "#1D9E75",
"borderColor": "#085041",
"borderWidth": 1,
}
],
},
"metadata": serialize_dates(
{
"confidence": confidence,
"data_points": len(rows),
"avg_minutes_week": round(sum(values) / len(values), 1) if values else 0,
"total_sessions": sum(row["session_count"] for row in rows),
}
),
}
def build_training_type_distribution_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""
Trainingstyp-Verteilung — gleiche Logik wie GET /api/charts/training-type-distribution.
"""
dist_data = get_training_type_distribution_data(profile_id, days)
if dist_data["confidence"] == "insufficient":
return {
"chart_type": "pie",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Trainingstypen-Daten",
},
}
labels = [item["category"] for item in dist_data["distribution"]]
values = [item["count"] for item in dist_data["distribution"]]
colors = [
"#1D9E75",
"#3B82F6",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#10B981",
"#F97316",
"#06B6D4",
]
return {
"chart_type": "pie",
"data": {
"labels": labels,
"datasets": [
{
"data": values,
"backgroundColor": colors[: len(values)],
"borderWidth": 2,
"borderColor": "#fff",
}
],
},
"metadata": {
"confidence": dist_data["confidence"],
"total_sessions": dist_data["total_sessions"],
"categorized_sessions": dist_data["categorized_sessions"],
"uncategorized_sessions": dist_data["uncategorized_sessions"],
},
}
def get_training_volume_two_week_delta(profile_id: str) -> Dict[str, Any]:
"""
Trainingsminuten: letzte 7 Kalendertage vs. die 7 Tage davor (Fortschritt Volumen).
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT
COALESCE(SUM(duration_min) FILTER (WHERE date >= CURRENT_DATE - INTERVAL '7 days'), 0)::bigint AS last7,
COALESCE(SUM(duration_min) FILTER (
WHERE date < CURRENT_DATE - INTERVAL '7 days'
AND date >= CURRENT_DATE - INTERVAL '14 days'), 0)::bigint AS prev7
FROM activity_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '14 days'
""",
(profile_id,),
)
row = cur.fetchone()
if not row:
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
last7 = int(row["last7"] or 0)
prev7 = int(row["prev7"] or 0)
if last7 == 0 and prev7 == 0:
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
delta_pct: Optional[float] = None
if prev7 > 0:
delta_pct = round((last7 - prev7) / float(prev7) * 100.0, 1)
return {
"last7_min": last7,
"prior7_min": prev7,
"delta_pct": delta_pct,
"has_data": True,
}
def build_quality_sessions_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""Qualitäts-Sessions vs. regulär — gleiche Logik wie GET /api/charts/quality-sessions."""
if days < 7:
days = 7
if days > 90:
days = 90
quality_pct = calculate_quality_sessions_pct(profile_id, days)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT COUNT(*) as total
FROM activity_log
WHERE profile_id=%s AND date >= %s""",
(profile_id, cutoff),
)
row = cur.fetchone()
total_sessions = row["total"] if row else 0
if total_sessions == 0:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Aktivitätsdaten",
},
}
q = float(quality_pct or 0)
quality_count = int(round(q / 100.0 * total_sessions))
quality_count = max(0, min(quality_count, total_sessions))
regular_count = total_sessions - quality_count
return {
"chart_type": "bar",
"data": {
"labels": ["Qualitäts-Sessions", "Reguläre Sessions"],
"datasets": [
{
"label": "Anzahl",
"data": [quality_count, regular_count],
"backgroundColor": ["#1D9E75", "#888"],
"borderColor": "#085041",
"borderWidth": 1,
}
],
},
"metadata": {
"confidence": calculate_confidence(total_sessions, days, "general"),
"data_points": total_sessions,
"quality_pct": round(q, 1),
"quality_count": quality_count,
"regular_count": regular_count,
},
}
def build_load_monitoring_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""Tages-Load-Zeitreihe + ACWR — gleiche Logik wie GET /api/charts/load-monitoring."""
if days < 14:
days = 14
if days > 90:
days = 90
acute_load = calculate_proxy_internal_load_window(profile_id, 7)
chronic_load = calculate_proxy_internal_load_window(profile_id, 28)
acwr = (
(acute_load / chronic_load) if acute_load is not None and chronic_load and chronic_load > 0 else 0.0
)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT
date,
SUM(duration_min * COALESCE(rpe, 5)) as daily_load
FROM activity_log
WHERE profile_id=%s AND date >= %s
GROUP BY date
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Load-Daten",
},
}
labels = [row["date"].isoformat() for row in rows]
values = [safe_float(row["daily_load"]) for row in rows]
al = float(acute_load) if acute_load is not None else 0.0
cl = float(chronic_load) if chronic_load is not None else 0.0
return {
"chart_type": "line",
"data": {
"labels": labels,
"datasets": [
{
"label": "Tages-Load",
"data": values,
"borderColor": "#1D9E75",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"fill": True,
}
],
},
"metadata": serialize_dates(
{
"confidence": calculate_confidence(len(rows), days, "general"),
"data_points": len(rows),
"acute_load_7d": round(al, 1),
"chronic_load_28d": round(cl, 1),
"acwr": round(acwr, 2),
"acwr_status": "optimal" if 0.8 <= acwr <= 1.3 else "suboptimal",
}
),
}

View File

@ -0,0 +1,406 @@
"""
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval).
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.
Feld-Katalog für CSV-Mappings: get_mappable_activity_field_catalog()
"""
from __future__ import annotations
import datetime as dt
import logging
import uuid
from typing import Any, Dict, List, Mapping, Optional
from models import ActivityEntry
from csv_parser.module_registry import get_module_definition
from data_layer.activity_data_canon import get_activity_module_registry_field_keys
logger = logging.getLogger(__name__)
try:
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
_EVALUATION_AVAILABLE = True
except Exception: # pragma: no cover
_evaluate_and_save_activity = None
_EVALUATION_AVAILABLE = False
def find_activity_duplicate_id(
cur,
profile_id: str,
date_iso: str,
start_time: Optional[Any],
) -> Optional[str]:
cur.execute(
"""
SELECT id FROM activity_log
WHERE profile_id = %s AND date = %s::date
AND start_time IS NOT DISTINCT FROM %s::time
""",
(profile_id, date_iso, start_time),
)
row = cur.fetchone()
return str(row["id"]) if row else None
# Datum/Start/Ende/Typ setzt der CSV-Executor explizit (Normalisierung); nicht aus diesem Patch überschreiben.
_ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "activity_type"})
def activity_registry_field_keys() -> frozenset[str]:
"""Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Registry als Single Source)."""
return get_activity_module_registry_field_keys()
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
"""
activity_log-Updates nur aus Modul-Registry-Feldern (Kernspalten).
Trainingsparameter-Keys (nur in training_parameters) laufen über EAV, nicht hier.
"""
mod = get_module_definition("activity")
if not mod:
return {}
fields = mod.get("fields") or {}
out: Dict[str, Any] = {}
def _sf(v: Any) -> float | None:
try:
if v is None or (isinstance(v, str) and not str(v).strip()):
return None
return round(float(v), 1)
except (TypeError, ValueError):
return None
def _si(v: Any) -> int | None:
try:
if v is None or (isinstance(v, str) and not str(v).strip()):
return None
return int(round(float(v)))
except (TypeError, ValueError):
return None
def _hr(v: Any) -> float | None:
x = _sf(v)
if x is None or x < 20 or x > 280:
return None
return x
for key, spec in fields.items():
if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE:
continue
if key not in mapped:
continue
raw = mapped[key]
if raw is None or raw == "":
continue
if isinstance(raw, str) and not raw.strip():
continue
typ = spec.get("type", "string")
if typ == "float":
v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw)
if v is not None:
out[key] = v
elif typ == "int":
v = _si(raw)
if v is not None:
out[key] = v
elif typ == "datetime":
if isinstance(raw, dt.datetime):
out[key] = raw.strftime("%Y-%m-%d %H:%M:%S")
elif isinstance(raw, dt.date):
out[key] = f"{raw.isoformat()} 00:00:00"
elif isinstance(raw, str) and raw.strip():
out[key] = raw.strip()
elif typ == "date":
if isinstance(raw, dt.date):
out[key] = raw.isoformat()
elif isinstance(raw, dt.datetime):
out[key] = raw.date().isoformat()
elif isinstance(raw, str) and raw.strip():
out[key] = raw.strip()
else:
out[key] = str(raw).strip()
return out
def insert_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
"""INSERT activity_log aus ActivityEntry (manueller API-Pfad)."""
d = e.model_dump()
cur.execute(
"""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
hr_avg,hr_max,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain,
temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_km,rpe,source,notes,
training_type_id,training_category,training_subcategory,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(
eid,
profile_id,
d["date"],
d["start_time"],
d["end_time"],
d["activity_type"],
d["duration_min"],
d["kcal_active"],
d["kcal_resting"],
d["hr_avg"],
d["hr_max"],
d.get("hr_min"),
d["distance_km"],
d.get("pace_min_per_km"),
d.get("cadence"),
d.get("avg_power"),
d.get("elevation_gain"),
d.get("temperature_celsius"),
d.get("humidity_percent"),
d.get("avg_hr_percent"),
d.get("kcal_per_km"),
d["rpe"],
d["source"],
d["notes"],
d.get("training_type_id"),
d.get("training_category"),
d.get("training_subcategory"),
),
)
def update_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
"""Volles UPDATE aus ActivityEntry (REST PUT)."""
d = e.model_dump()
cur.execute(
f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
list(d.values()) + [eid, profile_id],
)
def update_activity_columns(
cur,
profile_id: str,
eid: str,
updates: Dict[str, Any],
) -> None:
"""Teil-UPDATE nur für übergebene Spalten (Importe)."""
if not updates:
return
cols = [f"{k} = %s" for k in updates]
vals = list(updates.values()) + [eid, profile_id]
cur.execute(
f"UPDATE activity_log SET {', '.join(cols)} WHERE id = %s AND profile_id = %s",
vals,
)
def insert_activity_csv_minimal(
cur,
profile_id: str,
eid: str,
*,
date_iso: str,
start_time: Any,
end_time: Any,
activity_type: str,
duration_min: Any,
kcal_active: Any,
kcal_resting: Any,
hr_avg: Any,
hr_max: Any,
distance_km: Any,
training_type_id: Any,
training_category: Any,
training_subcategory: Any,
source: str,
) -> None:
"""INSERT minimale activity_log-Zeile (Universal-CSV)."""
cur.execute(
"""
INSERT INTO activity_log (
id, profile_id, date, start_time, end_time, activity_type, duration_min,
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
source, training_type_id, training_category, training_subcategory, created
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)
""",
(
eid,
profile_id,
date_iso,
start_time,
end_time,
activity_type,
duration_min,
kcal_active,
kcal_resting,
hr_avg,
hr_max,
distance_km,
source,
training_type_id,
training_category,
training_subcategory,
),
)
def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
"""Auto-Eval (falls aktiv). Kein Spalte→EAV-Sync: Lesepfad merge_column_backed_and_eav_metrics."""
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
cur.execute(
"""
SELECT id, profile_id, date, training_type_id, duration_min,
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
rpe, pace_min_per_km, cadence, elevation_gain
FROM activity_log
WHERE id = %s
""",
(eid,),
)
activity_row = cur.fetchone()
if activity_row:
activity_dict = dict(activity_row)
training_type_id = activity_dict.get("training_type_id")
if training_type_id:
try:
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
except Exception as eval_error:
logger.error("[AUTO-EVAL] activity %s: %s", eid, eval_error)
def run_activity_post_write_hooks_import(
cur,
profile_id: str,
eid: str,
*,
workout_date: str,
training_type_id: Optional[int],
duration_min: Any,
hr_avg: Any,
hr_max: Any,
distance_km: Any,
kcal_active: Any,
kcal_resting: Any,
) -> None:
"""Auto-Eval nach Import. Kein Spalte→EAV-Sync (siehe run_activity_post_write_hooks)."""
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
try:
activity_dict = {
"id": eid,
"profile_id": profile_id,
"date": workout_date,
"training_type_id": training_type_id,
"duration_min": duration_min,
"hr_avg": hr_avg,
"hr_max": hr_max,
"distance_km": distance_km,
"kcal_active": kcal_active,
"kcal_resting": kcal_resting,
"rpe": None,
"pace_min_per_km": None,
"cadence": None,
"elevation_gain": None,
}
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
except Exception as eval_err:
logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", eval_err)
def merge_activity_csv_module_fields(
cur,
static_fields: Dict[str, Any],
) -> Dict[str, Any]:
"""
activity-Modul für CSV: statische Registry-Felder + alle aktiven training_parameters.
Gleiche Quelle wie get_mappable_activity_field_catalog.training_parameters — erscheint
in Admin-CSV-Ziel-Liste, Validierung und Import-Zeilenaggregation.
"""
out = dict(static_fields)
cur.execute(
"""
SELECT key, data_type, unit, name_de
FROM training_parameters
WHERE is_active = true
ORDER BY key
"""
)
for row in cur.fetchall():
k = row["key"]
if k in out:
continue
dt = row["data_type"] or "float"
if dt == "integer":
mtype = "int"
elif dt == "float":
mtype = "float"
elif dt == "boolean":
mtype = "string"
else:
mtype = "string"
spec: Dict[str, Any] = {
"type": mtype,
"required": False,
"from_training_parameter": True,
}
if row.get("unit"):
spec["unit"] = row["unit"]
if row.get("name_de"):
spec["label_de"] = row["name_de"]
out[k] = spec
return out
def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]:
"""
Felder für konfigurierbare Import-Mappings.
core_fields: module_registry „activity“ → activity_log.
training_parameters: alle aktiven Parameter (global); bei Anwendung auf eine Session
werden Keys verworfen, die nicht in resolve_activity_attribute_schema(Kategorie/Typ) liegen.
profile_id: reserviert für künftige Profil-Filter.
"""
_ = profile_id
mod = get_module_definition("activity") or {}
fields = mod.get("fields") or {}
core_fields: List[Dict[str, Any]] = []
for key, spec in fields.items():
s = spec or {}
core_fields.append(
{
"key": key,
"target": "activity_log",
"column": key,
"data_type": s.get("type", "string"),
"required": bool(s.get("required")),
"unit": s.get("unit"),
"label_de": s.get("label_de") or key,
}
)
core_fields.sort(key=lambda x: x["key"])
cur.execute(
"""
SELECT id, key, name_de, name_en, category AS param_category,
data_type, unit, source_field
FROM training_parameters
WHERE is_active = true
ORDER BY key
"""
)
parameters = [dict(r) for r in cur.fetchall()]
return {
"core_fields": core_fields,
"training_parameters": parameters,
"notes": (
"training_parameters listet alle aktiven Keys. Pro Session werden Werte ignoriert, "
"die für deren training_category/training_type_id nicht im Attribut-Schema vorkommen."
),
}
def new_activity_id() -> str:
return str(uuid.uuid4())

View File

@ -0,0 +1,779 @@
"""
Activity session metrics (EAV) and resolved attribute schema — Layer 1.
See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any, Dict, List, Mapping, Optional, Sequence
from data_layer.activity_data_canon import (
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM,
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS,
)
from data_layer.prompt_output_compact import normalize_prompt_number
logger = logging.getLogger(__name__)
def _normalize_metric_value_for_read(data_type: str, val: Any) -> Any:
"""Lesepfad (Layer 1): keine unnötig langen Float-Strings für KI/UI (Issue 53 / Platzhalter)."""
if val is None:
return None
dt = (data_type or "").strip().lower()
if dt == "string":
return normalize_prompt_number(val)
if dt == "boolean":
return bool(val)
if dt == "integer":
try:
if isinstance(val, bool):
return int(val)
return int(val)
except (TypeError, ValueError):
return normalize_prompt_number(val)
if dt == "float":
return normalize_prompt_number(val)
return normalize_prompt_number(val)
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
{
"id",
"profile_id",
"date",
"created",
"training_type_id",
"training_category",
"training_subcategory",
"source",
}
)
class ActivitySessionMetricsError(Exception):
"""Raised by Layer 1; routers map to HTTP (404/400)."""
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(detail)
def _effective_training_category(
cur, training_category: Optional[str], training_type_id: Optional[int]
) -> Optional[str]:
if training_category:
return training_category.strip() or None
if training_type_id is None:
return None
cur.execute("SELECT category FROM training_types WHERE id = %s", (training_type_id,))
row = cur.fetchone()
if row and row.get("category"):
return row["category"]
return None
def merge_parameter_schema_rows(
category_rows: Sequence[Dict[str, Any]],
type_rows: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Pure merge: category assignments + type assignments → sorted schema list.
Row shapes match SELECTs in resolve_activity_attribute_schema (cat_sort / typ_* aliases).
"""
merged: Dict[int, Dict[str, Any]] = {}
for r in category_rows:
pid = r["training_parameter_id"]
merged[pid] = {
"training_parameter_id": pid,
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
"description_de": r.get("description_de"),
"description_en": r.get("description_en"),
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
"validation_rules": r["validation_rules"] or {},
"source_field": r["source_field"],
"sort_order": r["cat_sort"],
"required": bool(r["cat_required"]),
"ui_group": r["cat_ui_group"],
}
for r in type_rows:
pid = r["training_parameter_id"]
base = merged.get(pid)
if base is None:
merged[pid] = {
"training_parameter_id": pid,
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
"description_de": r.get("description_de"),
"description_en": r.get("description_en"),
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
"validation_rules": r["validation_rules"] or {},
"source_field": r["source_field"],
"sort_order": r["typ_sort"] if r["typ_sort"] is not None else 0,
"required": bool(r["typ_required"]) if r["typ_required"] is not None else False,
"ui_group": r["typ_ui_group"],
}
else:
if r["typ_sort"] is not None:
base["sort_order"] = r["typ_sort"]
if r["typ_required"] is not None:
base["required"] = bool(r["typ_required"])
if r["typ_ui_group"] is not None:
base["ui_group"] = r["typ_ui_group"]
out = list(merged.values())
out.sort(key=lambda x: (x["sort_order"], x["key"]))
return out
def resolve_activity_attribute_schema(
cur,
training_category: Optional[str],
training_type_id: Optional[int],
) -> List[Dict[str, Any]]:
"""
Merged parameter definitions for UI / validation (category base + type overrides/additions).
Sorted by sort_order, then key.
"""
cat = _effective_training_category(cur, training_category, training_type_id)
category_rows: List[Dict[str, Any]] = []
type_rows: List[Dict[str, Any]] = []
if cat:
cur.execute(
"""
SELECT
tcp.training_parameter_id,
tcp.sort_order AS cat_sort,
tcp.required AS cat_required,
tcp.ui_group AS cat_ui_group,
tp.key, tp.name_de, tp.name_en,
tp.description_de, tp.description_en,
tp.category AS param_category,
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
FROM training_category_parameter tcp
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
WHERE tcp.training_category = %s AND tp.is_active = true
""",
(cat,),
)
category_rows = list(cur.fetchall())
if training_type_id is not None:
cur.execute(
"""
SELECT
ttp.training_parameter_id,
ttp.sort_order AS typ_sort,
ttp.required AS typ_required,
ttp.ui_group AS typ_ui_group,
tp.key, tp.name_de, tp.name_en,
tp.description_de, tp.description_en,
tp.category AS param_category,
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
FROM training_type_parameter ttp
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
WHERE ttp.training_type_id = %s AND tp.is_active = true
""",
(training_type_id,),
)
type_rows = list(cur.fetchall())
return merge_parameter_schema_rows(category_rows, type_rows)
def _metric_human_labels(schema_row: Mapping[str, Any]) -> Dict[str, Any]:
"""Bezeichnung + Kurzbeschreibung aus training_parameters (KI / Export)."""
return {
"name_de": schema_row.get("name_de"),
"name_en": schema_row.get("name_en"),
"description_de": schema_row.get("description_de"),
"description_en": schema_row.get("description_en"),
}
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
return {}
def _validate_single_value(data_type: str, value: Any, rules: Dict[str, Any]) -> None:
if data_type == "integer":
if not isinstance(value, int) or isinstance(value, bool):
raise ActivitySessionMetricsError(400, f"Erwartet integer, erhalten: {type(value).__name__}")
if "min" in rules and value < rules["min"]:
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
if "max" in rules and value > rules["max"]:
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
elif data_type == "float":
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
raise ActivitySessionMetricsError(400, f"Erwartet Zahl, erhalten: {type(value).__name__}")
v = float(value)
if "min" in rules and v < float(rules["min"]):
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
if "max" in rules and v > float(rules["max"]):
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
elif data_type == "string":
if not isinstance(value, str):
raise ActivitySessionMetricsError(400, f"Erwartet string, erhalten: {type(value).__name__}")
if rules.get("not_empty") and not value.strip():
raise ActivitySessionMetricsError(400, "Leerer String nicht erlaubt")
if "max_length" in rules and len(value) > int(rules["max_length"]):
raise ActivitySessionMetricsError(400, f"String zu lang (max {rules['max_length']})")
allowed = rules.get("allowed_values")
if allowed and value not in allowed:
raise ActivitySessionMetricsError(400, "Wert nicht in erlaubter Menge")
elif data_type == "boolean":
if not isinstance(value, bool):
raise ActivitySessionMetricsError(400, f"Erwartet boolean, erhalten: {type(value).__name__}")
else:
raise ActivitySessionMetricsError(400, f"Unbekannter data_type: {data_type}")
def _row_value_tuple(data_type: str, value: Any) -> tuple:
if data_type == "integer":
return (None, int(value), None, None)
if data_type == "float":
return (float(value), None, None, None)
if data_type == "string":
return (None, None, str(value), None)
if data_type == "boolean":
return (None, None, None, bool(value))
raise ValueError(data_type)
def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
"""Wert aus activity_log-Spalte in den Typ bringen, den training_parameters.data_type erwartet."""
if data_type == "integer":
if isinstance(raw, bool):
raise TypeError("boolean nicht als integer erlaubt")
if isinstance(raw, str):
s = raw.strip().replace(",", ".")
return int(round(float(s)))
return int(round(float(raw)))
if data_type == "float":
if isinstance(raw, str):
s = raw.strip().replace(",", ".")
return float(s)
return float(raw)
if data_type == "string":
return str(raw) if raw is not None else ""
if data_type == "boolean":
if isinstance(raw, bool):
return raw
s = str(raw).strip().lower()
if s in ("true", "1", "t", "yes"):
return True
if s in ("false", "0", "f", "no", ""):
return False
raise TypeError(f"boolean-Koercion nicht möglich: {raw!r}")
raise ValueError(data_type)
def upsert_session_metrics_from_csv_mapped(
cur,
profile_id: str,
activity_log_id: str,
mapped: Mapping[str, Any],
training_category: Optional[str],
training_type_id: Optional[int],
) -> None:
"""
EAV für Trainingsparameter aus CSV.
Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
Trainingstyp) vorkommen. CSV-Spalten-Mappings sind import-spezifisch und definieren **nicht** das
UI-/Auswertungs-Schema — fehlende tcp/ttp-Zuordnung bedeutet: kein EAV für diesen Key (Werte ggf.
nur in ``activity_log``-Kernfeldern).
Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys.
Hat ein Parameter ``source_field`` (Semantik aus ``activity_log``), wird EAV nur dann **nicht**
geschrieben, wenn diese Spalte nach dem Import bereits befüllt ist — sonst gäbe es doppelte
Speicherung und der Merge würde ohnehin die Spalte bevorzugen. Ist die Spalte leer (z. B. Feld
nur noch über EAV / Custom-Mapping, ohne Registry-Patch), schreibt der Import den Wert aus
``mapped`` nach EAV — analog zum Lesepfad (Spalte zuerst, sonst EAV).
"""
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
row = cur.fetchone()
if not row or str(row["profile_id"]) != str(profile_id):
return
header = dict(row)
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
for spec in schema:
pkey = spec["key"]
if pkey not in mapped:
continue
raw = mapped[pkey]
if raw is None or raw == "":
continue
if pkey in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS:
continue
sf_raw = spec.get("source_field")
if sf_raw is not None and str(sf_raw).strip():
col = str(sf_raw).strip()
if col in header and header[col] is not None:
continue
tid = spec["training_parameter_id"]
dt = spec["data_type"]
rules = _validation_rules_dict(spec["validation_rules"])
try:
coerced = _coerce_raw_value_for_parameter(dt, raw)
_validate_single_value(dt, coerced, rules)
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
logger.warning("CSV EAV skipped %s: %s", pkey, ex)
continue
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
cur.execute(
"""
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (activity_log_id, training_parameter_id)
DO UPDATE SET
value_num = EXCLUDED.value_num,
value_int = EXCLUDED.value_int,
value_text = EXCLUDED.value_text,
value_bool = EXCLUDED.value_bool,
updated_at = NOW()
""",
(activity_log_id, tid, vn, vi, vt, vb),
)
def merge_column_backed_and_eav_metrics(
header: Mapping[str, Any],
schema: Sequence[Dict[str, Any]],
eav_metrics: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
Kanon beim Lesen: **activity_log** schlägt EAV, sobald ein passender Spaltenwert existiert und
koerzierbar ist — in dieser Reihenfolge:
1. ``source_field`` → Spalte
2. Parameter-Key = Registry-Kernfeld (``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS``) → gleichnamige Spalte
3. EAV-primäre Keys → Legacy-Spalte laut ``ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM``
4. sonst EAV
EAV-Zeilen zu Parametern, die nicht im Schema sind, werden nicht ausgegeben.
"""
eav_by_key = {m["key"]: m for m in eav_metrics}
merged: List[Dict[str, Any]] = []
keys_handled: set[str] = set()
for s in schema:
k = s["key"]
tid = s["training_parameter_id"]
dt = s["data_type"]
unit = s.get("unit")
sf = s.get("source_field")
used_column = False
if sf and isinstance(sf, str) and str(sf).strip():
col = str(sf).strip()
if col in header and header[col] is not None:
try:
val = _coerce_raw_value_for_parameter(dt, header[col])
merged.append(
{
"training_parameter_id": tid,
"key": k,
"data_type": dt,
"unit": unit,
"value": val,
**_metric_human_labels(s),
}
)
used_column = True
keys_handled.add(k)
except (TypeError, ValueError):
pass
if used_column:
continue
if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS and k in header and header[k] is not None:
try:
val = _coerce_raw_value_for_parameter(dt, header[k])
merged.append(
{
"training_parameter_id": tid,
"key": k,
"data_type": dt,
"unit": unit,
"value": val,
**_metric_human_labels(s),
}
)
keys_handled.add(k)
continue
except (TypeError, ValueError):
pass
legacy_col = ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM.get(k)
if legacy_col and legacy_col in header and header[legacy_col] is not None:
try:
val = _coerce_raw_value_for_parameter(dt, header[legacy_col])
merged.append(
{
"training_parameter_id": tid,
"key": k,
"data_type": dt,
"unit": unit,
"value": val,
**_metric_human_labels(s),
}
)
keys_handled.add(k)
continue
except (TypeError, ValueError):
pass
if k in eav_by_key:
row = dict(eav_by_key[k])
row.update(_metric_human_labels(s))
merged.append(row)
keys_handled.add(k)
merged.sort(key=lambda x: x["key"])
for m in merged:
m["value"] = _normalize_metric_value_for_read(m.get("data_type") or "", m.get("value"))
return merged
def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None:
"""
[Veraltet / nicht mehr in Schreibpfaden aufgerufen]
Früher: EAV spiegelte activity_log-Spalten für Parameter mit source_field.
Kanon: Spaltenwerte werden bei merge_column_backed_and_eav_metrics beim Lesen berücksichtigt; keine
doppelte Speicherung. Funktion bleibt für optionale Admin-/Reparatur-Skripte.
"""
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
row = cur.fetchone()
if not row or str(row["profile_id"]) != str(profile_id):
return
header = dict(row)
schema = resolve_activity_attribute_schema(
cur, header.get("training_category"), header.get("training_type_id")
)
for spec in schema:
sf = spec.get("source_field")
if sf is None or (isinstance(sf, str) and not str(sf).strip()):
continue
col = str(sf).strip()
if col not in header:
continue
raw = header[col]
tid = spec["training_parameter_id"]
dt = spec["data_type"]
rules = _validation_rules_dict(spec["validation_rules"])
if raw is None:
cur.execute(
"""
DELETE FROM activity_session_metrics
WHERE activity_log_id = %s AND training_parameter_id = %s
""",
(activity_log_id, tid),
)
continue
try:
coerced = _coerce_raw_value_for_parameter(dt, raw)
_validate_single_value(dt, coerced, rules)
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
logger.warning(
"sync_column_backed_session_metrics: überspringe %s (Spalte %s): %s",
spec.get("key"),
col,
ex,
)
continue
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
cur.execute(
"""
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (activity_log_id, training_parameter_id)
DO UPDATE SET
value_num = EXCLUDED.value_num,
value_int = EXCLUDED.value_int,
value_text = EXCLUDED.value_text,
value_bool = EXCLUDED.value_bool,
updated_at = NOW()
""",
(activity_log_id, tid, vn, vi, vt, vb),
)
def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]:
cur.execute(
"""
SELECT
m.id,
m.activity_log_id,
m.training_parameter_id,
m.value_num,
m.value_int,
m.value_text,
m.value_bool,
tp.key,
tp.data_type,
tp.unit
FROM activity_session_metrics m
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE m.activity_log_id = %s
ORDER BY tp.key
""",
(activity_log_id,),
)
rows = cur.fetchall()
out: List[Dict[str, Any]] = []
for r in rows:
dt = r["data_type"]
if dt == "integer":
val = int(r["value_int"]) if r["value_int"] is not None else None
elif dt == "float":
val = float(r["value_num"]) if r["value_num"] is not None else None
elif dt == "string":
val = r["value_text"]
else:
val = r["value_bool"]
out.append(
{
"training_parameter_id": r["training_parameter_id"],
"key": r["key"],
"data_type": dt,
"unit": r["unit"],
"value": val,
}
)
return out
def replace_activity_session_metrics(
cur,
profile_id: str,
activity_log_id: str,
metrics: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Full replace of EAV rows for this session. metrics: [{ "parameter_key": str, "value": ... }, ...]
"""
cur.execute(
"""
SELECT id, profile_id, training_category, training_type_id
FROM activity_log WHERE id = %s
""",
(activity_log_id,),
)
row = cur.fetchone()
if not row or str(row["profile_id"]) != str(profile_id):
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
schema = resolve_activity_attribute_schema(
cur, row.get("training_category"), row.get("training_type_id")
)
by_key = {s["key"]: s for s in schema}
payload_by_key: Dict[str, Dict[str, Any]] = {}
for item in metrics:
raw_k = item.get("parameter_key")
if raw_k is None or not str(raw_k).strip():
raise ActivitySessionMetricsError(400, "parameter_key fehlt")
k = str(raw_k).strip()
if k not in by_key:
raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}")
payload_by_key[k] = item
for s in schema:
if not s["required"]:
continue
itk = s["key"]
hit = payload_by_key.get(itk)
if hit is None or hit.get("value") is None:
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
cur.execute(
"DELETE FROM activity_session_metrics WHERE activity_log_id = %s",
(activity_log_id,),
)
for item in metrics:
k = str(item["parameter_key"]).strip()
spec = by_key[k]
val = item.get("value")
if val is None:
if spec["required"]:
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}")
continue
rules = _validation_rules_dict(spec["validation_rules"])
_validate_single_value(spec["data_type"], val, rules)
vn, vi, vt, vb = _row_value_tuple(spec["data_type"], val)
cur.execute(
"""
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
""",
(activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb),
)
# Kein sync_column_backed nach PUT /metrics: der Request ist maßgeblich für EAV. Ein Spalten-Sync würde
# Werte aus nicht mitgeschriebenen activity_log-Spalten wieder verwerfen.
return fetch_activity_session_metrics(cur, activity_log_id)
def get_activity_session_logical_unit(
cur,
profile_id: str,
activity_log_id: str,
*,
use_form_training_context: bool = False,
form_training_category: Optional[str] = None,
form_training_type_id: Optional[int] = None,
) -> Dict[str, Any]:
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
row = cur.fetchone()
if not row or str(row["profile_id"]) != str(profile_id):
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
header = dict(row)
if use_form_training_context:
cat = form_training_category
if isinstance(cat, str):
cat = cat.strip() or None
tid = form_training_type_id
else:
cat = header.get("training_category")
tid = header.get("training_type_id")
if tid is not None:
try:
tid = int(tid)
except (TypeError, ValueError):
tid = None
schema = resolve_activity_attribute_schema(cur, cat, tid)
metrics = fetch_activity_session_metrics(cur, activity_log_id)
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
return {
"header": header,
"schema": schema,
"metrics": merged_metrics,
}
def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
"""
Mutates each session dict: adds key 'session_metrics' (list).
Kombiniert EAV mit activity_log-Spalten für Parameter mit source_field (kanonisch: Spalte),
analog zu get_activity_session_logical_unit – ohne doppelte EAV-Speicherung beim Import.
"""
if not sessions:
return
ids = [str(s["id"]) for s in sessions if s.get("id")]
if not ids:
return
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"SELECT * FROM activity_log WHERE id IN ({ph})",
ids,
)
headers_by_id: Dict[str, Dict[str, Any]] = {}
for r in cur.fetchall():
h = dict(r)
headers_by_id[str(h["id"])] = h
cur.execute(
f"""
SELECT
m.activity_log_id,
m.training_parameter_id,
tp.key,
tp.data_type,
tp.unit,
m.value_num,
m.value_int,
m.value_text,
m.value_bool
FROM activity_session_metrics m
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE m.activity_log_id IN ({ph})
ORDER BY m.activity_log_id, tp.key
""",
ids,
)
by_act: Dict[str, List[Dict[str, Any]]] = {}
for r in cur.fetchall():
aid = str(r["activity_log_id"])
dt = r["data_type"]
if dt == "integer":
val = int(r["value_int"]) if r["value_int"] is not None else None
elif dt == "float":
val = float(r["value_num"]) if r["value_num"] is not None else None
elif dt == "string":
val = r["value_text"]
else:
val = r["value_bool"]
by_act.setdefault(aid, []).append(
{
"training_parameter_id": r["training_parameter_id"],
"key": r["key"],
"data_type": dt,
"unit": r["unit"],
"value": val,
}
)
schema_cache: Dict[tuple[Any, Any], List[Dict[str, Any]]] = {}
def _schema(cat: Any, tid: Any) -> List[Dict[str, Any]]:
cache_key = (cat, tid)
if cache_key not in schema_cache:
schema_cache[cache_key] = resolve_activity_attribute_schema(cur, cat, tid)
return schema_cache[cache_key]
for s in sessions:
aid = str(s.get("id"))
header = headers_by_id.get(aid)
if not header:
s["session_metrics"] = []
continue
schema = _schema(header.get("training_category"), header.get("training_type_id"))
eav_list = by_act.get(aid, [])
merged = merge_column_backed_and_eav_metrics(header, schema, eav_list)
s["session_metrics"] = [
{
"key": m["key"],
"data_type": m["data_type"],
"unit": m["unit"],
"value": m["value"],
"name_de": m.get("name_de"),
"name_en": m.get("name_en"),
"description_de": m.get("description_de"),
"description_en": m.get("description_en"),
}
for m in merged
]

View File

@ -0,0 +1,30 @@
"""
Einheitliche Startzeit-Normalisierung für Aktivität (CSV, Legacy-Import, Dedupe).
Anbieter-agnostisch: beliebige ISO-/Export-Strings über dateutil.
"""
from __future__ import annotations
from datetime import time as dt_time
from typing import Optional
from dateutil import parser as du_parser
def normalize_activity_start(start_raw: str) -> tuple[str, Optional[dt_time]]:
"""
Roh-String „Start“ aus Exporten → (YYYY-MM-DD, TIME ohne μs) für DB Dedupe/INSERT.
Leerer Input → ("", None). Fallback bei Parse-Fehler: erstes Datum aus ersten 10 Zeichen.
"""
s = (start_raw or "").strip()
if not s:
return "", None
try:
parsed = du_parser.parse(s, dayfirst=False)
t = parsed.time().replace(microsecond=0)
return parsed.date().isoformat(), t
except (ValueError, TypeError, OverflowError):
if len(s) >= 10:
return s[:10], None
return "", None

View File

@ -0,0 +1,330 @@
"""
Body interpretation tiles for Layer 2b (Verlauf UI).
Logic aligned with frontend/src/utils/interpret.js (Körper-Kontext).
Uses the same thresholds; outputs structured tiles + related_placeholder_keys
for alignment with Layer 2a registry keys.
No formatting for KI — structured dicts only.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
def _safe_float(v: Any) -> Optional[float]:
if v is None:
return None
try:
return round(float(v), 4)
except (TypeError, ValueError):
return None
def _calc_derived(m: Dict, height_cm: float) -> Dict[str, float]:
out: Dict[str, float] = {}
w = _safe_float(m.get("c_waist"))
h = _safe_float(m.get("c_hip"))
lean = _safe_float(m.get("lean_mass"))
if w and h:
out["whr"] = round(w / h, 2)
if w and height_cm:
out["whtr"] = round(w / height_cm, 2)
if lean and height_cm:
hm = height_cm / 100.0
out["ffmi"] = round(lean / (hm ** 2), 1)
return out
def _bf_status_ranges(sex: str) -> Dict[str, float]:
if sex == "f":
return {"essential": 14, "athletic": 21, "fit": 25, "avg": 32}
return {"essential": 6, "athletic": 14, "fit": 18, "avg": 25}
def get_body_interpretation_tiles(
measurement: Dict[str, Any],
profile: Dict[str, Any],
prev_measurement: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""
Returns interpretation tiles. Each tile includes related_placeholder_keys
pointing to Layer 2a registry keys fed by the same Layer-1 metrics.
"""
results: List[Dict[str, Any]] = []
sex = profile.get("sex") or "m"
height = _safe_float(profile.get("height")) or 178.0
m = measurement
derived = _calc_derived(m, height)
# ── Körperfett ──────────────────────────────────────────────────────────
bf = _safe_float(m.get("body_fat_pct"))
if bf is not None:
ranges = _bf_status_ranges(sex)
if bf <= ranges["essential"]:
msg = "Sehr niedriger Körperfettanteil"
detail = (
"Essenzielle Fettwerte – nur für Leistungssportler geeignet, "
"auf Dauer nicht empfehlenswert."
)
status = "warn"
elif bf <= ranges["athletic"]:
msg = "Athletischer Körperfettanteil"
detail = "Ausgezeichnet. Typisch für aktive Sportler mit hohem Trainingsvolumen."
status = "good"
elif bf <= ranges["fit"]:
msg = "Guter Körperfettanteil"
detail = "Sehr gute Fitness-Kategorie. Gesund und gut in Form."
status = "good"
elif bf <= ranges["avg"]:
msg = "Durchschnittlicher Körperfettanteil"
detail = (
"Im normalen Bereich. Verbesserung durch Kombination aus Kraft- "
"und Ausdauertraining möglich."
)
status = "warn"
else:
msg = "Erhöhter Körperfettanteil"
detail = (
"Über dem empfohlenen Bereich. Ernährungsumstellung und "
"regelmäßiges Training empfohlen."
)
status = "bad"
results.append(
{
"category": "Körperfett",
"icon": "🫧",
"status": status,
"title": msg,
"detail": detail,
"value": f"{bf}%",
"related_placeholder_keys": ["caliper_summary", "fm_28d_change"],
}
)
# ── WHR ─────────────────────────────────────────────────────────────────
whr = derived.get("whr")
if whr is not None:
limit = 0.90 if sex == "m" else 0.85
limit_high = 1.0 if sex == "m" else 0.95
if whr < limit:
status = "good"
title = "Günstige Fettverteilung"
detail = (
f"Dein WHR von {whr} liegt unter dem Grenzwert ({limit}). "
"Birnenförmige Fettverteilung – metabolisch günstig."
)
elif whr < limit_high:
status = "warn"
title = "Grenzwertiger WHR"
detail = (
f"Dein WHR von {whr} liegt leicht über dem Zielwert ({limit}). "
"Apfelförmige Tendenz – Bauchfett reduzieren empfohlen."
)
else:
status = "bad"
title = "Erhöhtes Risiko durch Fettverteilung"
detail = (
f"WHR von {whr} deutlich über dem Grenzwert. Erhöhtes "
"kardiovaskuläres Risiko durch viszerales Fett."
)
results.append(
{
"category": "Fettverteilung",
"icon": "📐",
"status": status,
"title": title,
"detail": detail,
"value": str(whr),
"related_placeholder_keys": ["waist_hip_ratio", "circ_summary"],
}
)
# ── WHtR ────────────────────────────────────────────────────────────────
whtr = derived.get("whtr")
if whtr is not None:
if whtr < 0.40:
status = "warn"
title = "Sehr schlanke Taille"
detail = f"WHtR {whtr} – möglicherweise zu wenig Körpermasse."
elif whtr < 0.50:
status = "good"
title = "Optimale Taillen-Größen-Relation"
detail = (
f"WHtR {whtr} – im optimalen Bereich. Geringstes kardiovaskuläres Risiko."
)
elif whtr < 0.60:
status = "warn"
title = "Leicht erhöhter WHtR"
detail = f"WHtR {whtr} – Ziel ist unter 0,50. Moderat erhöhtes Risiko."
else:
status = "bad"
title = "Stark erhöhter WHtR"
detail = (
f"WHtR {whtr} – deutlich erhöhtes Risiko. Taille sollte weniger "
"als die Hälfte der Körpergröße betragen."
)
results.append(
{
"category": "Taille/Größe",
"icon": "📏",
"status": status,
"title": title,
"detail": detail,
"value": str(whtr),
"related_placeholder_keys": ["circ_summary", "waist_28d_delta"],
}
)
# ── FFMI ─────────────────────────────────────────────────────────────────
ffmi = derived.get("ffmi")
if ffmi is not None:
natural_limit = 25.0 if sex == "m" else 22.0
if ffmi < (18.0 if sex == "m" else 15.0):
status = "warn"
title = "Unterdurchschnittliche Muskelmasse"
detail = (
f"FFMI {ffmi} – Krafttraining kann die Muskelmasse und den "
"Grundumsatz deutlich verbessern."
)
elif ffmi < (22.0 if sex == "m" else 19.0):
status = "good"
title = "Durchschnittliche Muskelmasse"
detail = f"FFMI {ffmi} – gute Basis. Mit regelmäßigem Krafttraining weiter ausbaubar."
elif ffmi <= natural_limit:
status = "good"
title = "Überdurchschnittliche Muskelmasse"
detail = f"FFMI {ffmi} – sehr gut. Oberes natürliches Spektrum für Kraftsportler."
else:
status = "warn"
title = "Außergewöhnlich hohe Muskelmasse"
detail = (
f"FFMI {ffmi} – oberhalb der natürlichen Grenze (~{natural_limit}). "
"Selten ohne unterstützende Mittel erreichbar."
)
results.append(
{
"category": "Muskelmasse",
"icon": "💪",
"status": status,
"title": title,
"detail": detail,
"value": str(ffmi),
"related_placeholder_keys": ["lbm_28d_change", "caliper_summary"],
}
)
# ── BMI ───────────────────────────────────────────────────────────────────
w_kg = _safe_float(m.get("weight"))
if w_kg is not None and height > 0:
bmi = round(w_kg / ((height / 100.0) ** 2), 1)
if bmi < 18.5:
status = "warn"
title = "Untergewicht (BMI)"
detail = f"BMI {bmi} – unter 18,5. Auf ausreichende Kalorienzufuhr und Nährstoffversorgung achten."
elif bmi < 25:
status = "good"
title = "Normalgewicht (BMI)"
detail = f"BMI {bmi} – im optimalen Bereich (18,5–24,9)."
elif bmi < 30:
status = "warn"
title = "Übergewicht (BMI)"
detail = (
f"BMI {bmi} – leichtes Übergewicht. BMI allein ist wenig aussagekräftig "
"bei Muskelmasse – Körperfett-% beachten."
)
else:
status = "bad"
title = "Adipositas (BMI)"
detail = f"BMI {bmi} – deutliches Übergewicht. Ärztliche Beratung empfohlen."
results.append(
{
"category": "BMI",
"icon": "⚖️",
"status": status,
"title": title,
"detail": detail,
"value": str(bmi),
"related_placeholder_keys": ["bmi", "weight_aktuell"],
}
)
# ── Vergleich zur letzten Messung (Caliper) ───────────────────────────────
if prev_measurement:
p = prev_measurement
m_date = m.get("date")
p_date = p.get("date")
days = 0
if m_date and p_date:
if isinstance(m_date, str):
m_date = datetime.fromisoformat(m_date[:10]).date()
if isinstance(p_date, str):
p_date = datetime.fromisoformat(p_date[:10]).date()
if isinstance(m_date, date) and isinstance(p_date, date):
days = (m_date - p_date).days
changes: List[Dict[str, Any]] = []
if m.get("body_fat_pct") is not None and p.get("body_fat_pct") is not None:
diff = round(float(m["body_fat_pct"]) - float(p["body_fat_pct"]), 1)
if abs(diff) >= 0.3:
changes.append({"label": "Körperfett", "diff": diff, "unit": "%", "invert": True})
if m.get("weight") is not None and p.get("weight") is not None:
diff = round(float(m["weight"]) - float(p["weight"]), 1)
if abs(diff) >= 0.2:
changes.append({"label": "Gewicht", "diff": diff, "unit": "kg", "invert": True})
if m.get("lean_mass") is not None and p.get("lean_mass") is not None:
diff = round(float(m["lean_mass"]) - float(p["lean_mass"]), 1)
if abs(diff) >= 0.2:
changes.append({"label": "Magermasse", "diff": diff, "unit": "kg", "invert": False})
if m.get("c_waist") is not None and p.get("c_waist") is not None:
diff = round(float(m["c_waist"]) - float(p["c_waist"]), 1)
if abs(diff) >= 0.5:
changes.append({"label": "Taille", "diff": diff, "unit": "cm", "invert": True})
if m.get("c_belly") is not None and p.get("c_belly") is not None:
diff = round(float(m["c_belly"]) - float(p["c_belly"]), 1)
if abs(diff) >= 0.5:
changes.append({"label": "Bauch", "diff": diff, "unit": "cm", "invert": True})
if changes:
positive = [c for c in changes if (c["diff"] < 0 if c["invert"] else c["diff"] > 0)]
negative = [c for c in changes if (c["diff"] > 0 if c["invert"] else c["diff"] < 0)]
detail_parts = []
for c in changes:
sign = "+" if c["diff"] > 0 else ""
good = (c["diff"] < 0) if c["invert"] else (c["diff"] > 0)
detail_parts.append(
f"{c['label']}: {sign}{c['diff']} {c['unit']} {'✓' if good else '↑'}"
)
detail = " · ".join(detail_parts)
if len(positive) > len(negative):
st = "good"
title = "Positive Entwicklung seit letzter Messung"
elif len(negative) > len(positive):
st = "warn"
title = "Verschlechterung seit letzter Messung"
else:
st = "warn"
title = "Gemischte Entwicklung seit letzter Messung"
results.append(
{
"category": f"Seit letzter Messung ({days} Tage)",
"icon": "📊",
"status": st,
"title": title,
"detail": detail,
"value": f"{days}d",
"related_placeholder_keys": [
"caliper_summary",
"weight_trend",
"lbm_28d_change",
"waist_28d_delta",
],
}
)
return results

View File

@ -370,7 +370,8 @@ def get_circumference_summary_data(
('c_hip', 'Hüfte'),
('c_thigh', 'Oberschenkel'),
('c_calf', 'Wade'),
('c_arm', 'Arm')
('c_arm', 'Oberarm kontrahiert'),
('c_arm_relaxed', 'Oberarm'),
]
measurements = []
@ -401,7 +402,7 @@ def get_circumference_summary_data(
})
# Calculate confidence based on how many points we have
confidence = calculate_confidence(len(measurements), 8, "general")
confidence = calculate_confidence(len(measurements), 9, "general")
if not measurements:
return {
@ -640,10 +641,15 @@ def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
"""Calculate 28-day arm circumference change (cm)"""
"""28-Tage-Delta Oberarm kontrahiert (c_arm), cm."""
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]:
"""28-Tage-Delta Oberarm entspannt (c_arm_relaxed), cm."""
return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28)
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
"""Calculate 28-day thigh circumference change (cm)"""
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)

View File

@ -0,0 +1,494 @@
"""
Layer 2b: Structured body history / Verlauf «Körper» bundle.
Single source for Verlauf-UI: series + Kennzahlen + Interpretation tiles.
All queries use the same tables as Layer 1 / Layer 2a body placeholders.
See: placeholder_registrations/body_metrics.py, body_extras.py
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from db import get_db, get_cursor, r2d
from data_layer.body_interpretation import get_body_interpretation_tiles
from data_layer.utils import safe_float
def _cutoff_sql(days: int) -> Optional[str]:
if days >= 9999:
return None
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for i, d in enumerate(rows):
sl = rows[max(0, i - window + 1) : i + 1]
vals: List[float] = []
for x in sl:
v = safe_float(x.get(key))
if v is not None:
vals.append(v)
if not vals:
out.append({**d, f"{key}_avg": None})
continue
avg = round(sum(vals) / len(vals), 1)
out.append({**d, f"{key}_avg": avg})
return out
def _iso(d: Any) -> Optional[str]:
if d is None:
return None
if hasattr(d, "isoformat"):
return d.isoformat()
return str(d)[:10]
def _weight_trend_kpi(trend_periods: List[Dict[str, Any]]) -> Dict[str, str]:
"""
Kurzurteil Gewichtstrend (Schwelle ±0,25 kg, Priorität 90T → 30T → erste Periode).
Eine Quelle mit dem Verlauf-Bundle — kein paralleles Frontend-Routing mehr.
"""
if not trend_periods:
return {"verdict": "Stabil", "status": "good"}
t90 = next((t for t in trend_periods if t.get("label") == "90T"), None)
t30 = next((t for t in trend_periods if t.get("label") == "30T"), None)
d: Optional[float] = None
if t90 is not None and t90.get("diff_kg") is not None:
d = float(t90["diff_kg"])
elif t30 is not None and t30.get("diff_kg") is not None:
d = float(t30["diff_kg"])
elif trend_periods[0].get("diff_kg") is not None:
d = float(trend_periods[0]["diff_kg"])
else:
return {"verdict": "Stabil", "status": "good"}
if d < -0.25:
return {"verdict": "Trend ↓", "status": "good"}
if d > 0.25:
return {"verdict": "Trend ↑", "status": "warn"}
return {"verdict": "Stabil", "status": "good"}
def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
"""
Returns chart-ready series and interpretation tiles for the body history tab.
Args:
profile_id: profiles.id
days: analysis window (use >= 9999 for full history)
Tables: weight_log, caliper_log, circumference_log, profiles
"""
cutoff = _cutoff_sql(days)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, sex, height, dob, goal_weight, goal_bf_pct
FROM profiles WHERE id = %s
""",
(profile_id,),
)
pr = r2d(cur.fetchone())
if not pr:
return {
"confidence": "insufficient",
"message": "Profil nicht gefunden",
"profile": {},
"weight": {},
"caliper": {},
"circumference": {},
"interpretation_tiles": [],
"meta": {},
}
profile_ui = {
"sex": pr.get("sex") or "m",
"height": safe_float(pr.get("height")) or 178.0,
"goal_weight_kg": safe_float(pr.get("goal_weight")),
"goal_bf_pct": safe_float(pr.get("goal_bf_pct")),
}
# ── Weight (same window as Verlauf-Filter) ────────────────────────────
if cutoff:
cur.execute(
"""
SELECT date, weight FROM weight_log
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, weight FROM weight_log
WHERE profile_id = %s
ORDER BY date ASC
""",
(profile_id,),
)
wrows = [r2d(r) for r in cur.fetchall()]
w_points = [
{"date": r["date"], "weight": safe_float(r["weight"])}
for r in wrows
if r.get("weight") is not None
]
w_with_avg7 = _rolling_avg([dict(x) for x in w_points], "weight", 7)
w_with_avg14 = _rolling_avg([dict(x) for x in w_points], "weight", 14)
weight_series: List[Dict[str, Any]] = []
for i, base in enumerate(w_points):
weight_series.append(
{
"date": _iso(base["date"]),
"weight": base["weight"],
"avg7": w_with_avg7[i].get("weight_avg") if i < len(w_with_avg7) else None,
"avg14": w_with_avg14[i].get("weight_avg") if i < len(w_with_avg14) else None,
}
)
ws = [p["weight"] for p in w_points if p.get("weight") is not None]
overall_avg = round(sum(ws) / len(ws), 1) if len(ws) else None
min_w = min(ws) if ws else None
max_w = max(ws) if ws else None
today = datetime.now().date()
trend_periods: List[Dict[str, Any]] = []
for span in (7, 30, 90):
cut = today - timedelta(days=span)
per = [p for p in w_points if p["date"] >= cut]
if len(per) >= 2:
diff = round(float(per[-1]["weight"]) - float(per[0]["weight"]), 1)
trend_periods.append({"label": f"{span}T", "diff_kg": diff, "count": len(per)})
# ── Caliper series ───────────────────────────────────────────────────
if cutoff:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass, fat_mass
FROM caliper_log
WHERE profile_id = %s
AND body_fat_pct IS NOT NULL
AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass, fat_mass
FROM caliper_log
WHERE profile_id = %s AND body_fat_pct IS NOT NULL
ORDER BY date ASC
""",
(profile_id,),
)
cal_rows = [r2d(r) for r in cur.fetchall()]
caliper_series = [
{
"date": _iso(r["date"]),
"body_fat_pct": safe_float(r.get("body_fat_pct")),
"lean_mass": safe_float(r.get("lean_mass")),
}
for r in cal_rows
]
# Latest / prev caliper in window (for interpretation)
if cutoff:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass
FROM caliper_log
WHERE profile_id = %s AND date >= %s
ORDER BY date DESC
LIMIT 2
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, body_fat_pct, lean_mass
FROM caliper_log
WHERE profile_id = %s
ORDER BY date DESC
LIMIT 2
""",
(profile_id,),
)
cal_latest_rows = [r2d(r) for r in cur.fetchall()]
latest_cal = cal_latest_rows[0] if cal_latest_rows else None
prev_cal = cal_latest_rows[1] if len(cal_latest_rows) > 1 else None
# ── Circumference rows ───────────────────────────────────────────────
if cutoff:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s
ORDER BY date ASC
""",
(profile_id,),
)
cir_rows = [r2d(r) for r in cur.fetchall()]
if cutoff:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s AND date >= %s
ORDER BY date DESC
LIMIT 2
""",
(profile_id, cutoff),
)
else:
cur.execute(
"""
SELECT date, c_chest, c_waist, c_hip, c_belly
FROM circumference_log
WHERE profile_id = %s
ORDER BY date DESC
LIMIT 2
""",
(profile_id,),
)
circ_latest_desc = [r2d(r) for r in cur.fetchall()]
latest_circ_row = circ_latest_desc[0] if circ_latest_desc else None
prev_circ_row = circ_latest_desc[1] if len(circ_latest_desc) > 1 else None
# Latest weight in window
latest_w = w_points[-1] if w_points else None
# ── Proportion & index (computed from L1 rows only) ─────────────────────
prop_base: List[Dict[str, Any]] = []
for r in cir_rows:
ch = safe_float(r.get("c_chest"))
wa = safe_float(r.get("c_waist"))
if ch is None or wa is None:
continue
belly = safe_float(r.get("c_belly"))
prop_base.append(
{
"date": _iso(r["date"]),
"v_taper_cm": round(ch - wa, 1),
"belly_cm": belly,
}
)
prop_chart = _rolling_avg([dict(x) for x in prop_base], "v_taper_cm", 3) if len(prop_base) >= 2 else []
for i, row in enumerate(prop_chart):
row["belly_cm"] = prop_base[i].get("belly_cm")
fb_first: Dict[str, Optional[float]] = {"chest": None, "waist": None, "belly": None}
for r in cir_rows:
if fb_first["chest"] is None and r.get("c_chest") is not None:
fb_first["chest"] = safe_float(r["c_chest"])
if fb_first["waist"] is None and r.get("c_waist") is not None:
fb_first["waist"] = safe_float(r["c_waist"])
if fb_first["belly"] is None and r.get("c_belly") is not None:
fb_first["belly"] = safe_float(r["c_belly"])
index_series: List[Dict[str, Any]] = []
for r in cir_rows:
idx_row: Dict[str, Any] = {"date": _iso(r["date"])}
cc = safe_float(r.get("c_chest"))
ww = safe_float(r.get("c_waist"))
bb = safe_float(r.get("c_belly"))
if cc is not None and fb_first["chest"]:
idx_row["chest_idx"] = round(cc / fb_first["chest"] * 100, 1)
else:
idx_row["chest_idx"] = None
if ww is not None and fb_first["waist"]:
idx_row["waist_idx"] = round(ww / fb_first["waist"] * 100, 1)
else:
idx_row["waist_idx"] = None
if bb is not None and fb_first["belly"]:
idx_row["belly_idx"] = round(bb / fb_first["belly"] * 100, 1)
else:
idx_row["belly_idx"] = None
index_series.append(idx_row)
idx_nonempty = sum(
1
for row in index_series
if row.get("chest_idx") is not None
or row.get("waist_idx") is not None
or row.get("belly_idx") is not None
)
fallback_circ = [
{
"date": _iso(r["date"]),
"waist": safe_float(r.get("c_waist")),
"hip": safe_float(r.get("c_hip")),
"belly": safe_float(r.get("c_belly")),
}
for r in cir_rows
if r.get("c_waist") or r.get("c_hip") or r.get("c_belly")
]
# ── Merge measurement for interpretation ────────────────────────────────
measurement: Dict[str, Any] = {}
if latest_cal:
measurement.update(
{
"date": latest_cal.get("date"),
"body_fat_pct": safe_float(latest_cal.get("body_fat_pct")),
"lean_mass": safe_float(latest_cal.get("lean_mass")),
}
)
if latest_circ_row:
measurement["c_waist"] = safe_float(latest_circ_row.get("c_waist"))
measurement["c_hip"] = safe_float(latest_circ_row.get("c_hip"))
measurement["c_belly"] = safe_float(latest_circ_row.get("c_belly"))
if latest_w:
measurement["weight"] = safe_float(latest_w.get("weight"))
# Referenzdatum für „aktuell“: neueste verfügbare Quelle (Caliper > Umfang > Gewicht)
if not measurement.get("date"):
if latest_circ_row and latest_circ_row.get("date"):
measurement["date"] = latest_circ_row.get("date")
elif latest_w and latest_w.get("date"):
measurement["date"] = latest_w.get("date")
# Vorperiode: vorherige Caliper-Zeile + vorherige Umfangsmessung + vorheriges Gewicht (w_points[-2])
prev_for_interp: Optional[Dict[str, Any]] = {}
if prev_cal:
prev_for_interp["date"] = prev_cal.get("date")
prev_for_interp["body_fat_pct"] = safe_float(prev_cal.get("body_fat_pct"))
prev_for_interp["lean_mass"] = safe_float(prev_cal.get("lean_mass"))
if prev_circ_row:
prev_for_interp["c_waist"] = safe_float(prev_circ_row.get("c_waist"))
prev_for_interp["c_hip"] = safe_float(prev_circ_row.get("c_hip"))
prev_for_interp["c_belly"] = safe_float(prev_circ_row.get("c_belly"))
if not prev_for_interp.get("date") and prev_circ_row.get("date"):
prev_for_interp["date"] = prev_circ_row.get("date")
if len(w_points) >= 2:
prev_for_interp["weight"] = safe_float(w_points[-2].get("weight"))
if not prev_for_interp.get("date") and w_points[-2].get("date"):
prev_for_interp["date"] = w_points[-2].get("date")
if not prev_for_interp:
prev_for_interp = None
else:
# Mindestens ein vergleichbares Feld zur aktuellen Messung
has_cmp = any(
prev_for_interp.get(k) is not None
for k in ("body_fat_pct", "lean_mass", "weight", "c_waist", "c_belly")
)
if not has_cmp:
prev_for_interp = None
tiles = get_body_interpretation_tiles(measurement, profile_ui, prev_for_interp)
last_dates: List[date] = []
if w_points:
last_dates.append(w_points[-1]["date"])
if latest_cal and latest_cal.get("date"):
d = latest_cal["date"]
if isinstance(d, str):
d = datetime.fromisoformat(d[:10]).date()
last_dates.append(d)
if latest_circ_row and latest_circ_row.get("date"):
d = latest_circ_row["date"]
if isinstance(d, str):
d = datetime.fromisoformat(d[:10]).date()
last_dates.append(d)
last_updated = max(last_dates).isoformat() if last_dates else None
bf_cat = None
if measurement.get("body_fat_pct") is not None:
# simple label bucket (aligned with frontend BF_CATEGORIES order)
bf = float(measurement["body_fat_pct"])
sex = profile_ui["sex"]
if sex == "f":
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
bounds = [14, 21, 25, 32, 1000]
else:
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
bounds = [6, 14, 18, 25, 1000]
for i, b in enumerate(bounds):
if bf <= b:
bf_cat = labels[i]
break
summary = {
"weight_kg": measurement.get("weight"),
"body_fat_pct": measurement.get("body_fat_pct"),
"lean_mass_kg": measurement.get("lean_mass"),
"whr": (
round(measurement["c_waist"] / measurement["c_hip"], 2)
if measurement.get("c_waist") and measurement.get("c_hip")
else None
),
"whtr": (
round(measurement["c_waist"] / profile_ui["height"], 2)
if measurement.get("c_waist") and profile_ui.get("height")
else None
),
"ffmi": None,
"bf_category_label": bf_cat,
}
if measurement.get("lean_mass") and profile_ui.get("height"):
hm = float(profile_ui["height"]) / 100.0
summary["ffmi"] = round(float(measurement["lean_mass"]) / (hm**2), 1)
return {
"confidence": "high" if w_points or caliper_series or cir_rows else "insufficient",
"days_requested": days,
"last_updated": last_updated,
"profile": profile_ui,
"summary": summary,
"weight": {
"series": weight_series,
"overall_avg_kg": overall_avg,
"min_kg": min_w,
"max_kg": max_w,
"trend_periods": trend_periods,
"trend_kpi": _weight_trend_kpi(trend_periods),
"data_points": len(w_points),
"related_placeholder_keys": [
"weight_aktuell",
"weight_trend",
"weight_7d_median",
"weight_28d_slope",
"weight_90d_slope",
],
},
"caliper": {
"series": caliper_series,
"data_points": len(caliper_series),
"related_placeholder_keys": ["caliper_summary", "fm_28d_change", "lbm_28d_change"],
},
"circumference": {
"proportion_series": prop_chart,
"index_series": index_series,
"index_usable": idx_nonempty >= 2 and any(v for v in fb_first.values()),
"fallback_multiline": fallback_circ,
"has_chest_waist": len(prop_base) >= 2,
"related_placeholder_keys": ["circ_summary", "waist_hip_ratio", "waist_28d_delta"],
},
"interpretation_tiles": tiles,
"meta": {
"layer_1": "data_layer.body_viz + data_layer.body_interpretation",
"layer_2b": "This bundle — sole numeric source for Verlauf Körper charts/tiles",
"layer_2a_alignment": "Tiles carry related_placeholder_keys; metrics from same tables as body_metrics placeholders",
},
}

View File

@ -0,0 +1,256 @@
"""
Chart.js-kompatible Payloads für Lag-Korrelationen C1–C3 und Treiber C4.
Gemeinsame Quelle für GET /charts/* und history_overview_viz.chart_payloads (Issue 53).
"""
from __future__ import annotations
from typing import Any, Dict
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
def build_weight_energy_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
if not corr_data or corr_data.get("correlation") is None:
msg = "Nicht genug Daten für Korrelationsanalyse"
if isinstance(corr_data, dict):
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
"message": msg,
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
},
}
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
correlation = corr_data.get("correlation", 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Lag {best_lag} Tage"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#1D9E75",
"borderColor": "#085041",
"borderWidth": 2,
"pointRadius": 8,
}
],
},
"metadata": {
"confidence": corr_data.get("confidence", "low"),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"interpretation": corr_data.get("interpretation", ""),
"data_points": corr_data.get("data_points", 0),
"lag_details": corr_data.get("lag_details"),
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
"layer_1": "correlations._correlate_energy_weight",
},
}
def build_lbm_protein_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
if not corr_data or corr_data.get("correlation") is None:
msg = "Nicht genug Daten für LBM-Protein Korrelation"
if isinstance(corr_data, dict):
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
"message": msg,
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
},
}
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
correlation = corr_data.get("correlation", 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Lag {best_lag} Tage"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#3B82F6",
"borderColor": "#1E40AF",
"borderWidth": 2,
"pointRadius": 8,
}
],
},
"metadata": {
"confidence": corr_data.get("confidence", "low"),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"interpretation": corr_data.get("interpretation", ""),
"data_points": corr_data.get("data_points", 0),
"lag_details": corr_data.get("lag_details"),
"layer_1": "correlations._correlate_protein_lbm",
},
}
def build_load_vitals_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
def _abs_corr(c: Any) -> float:
if not c or c.get("correlation") is None:
return -1.0
try:
return abs(float(c["correlation"]))
except (TypeError, ValueError):
return -1.0
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
msg = "Nicht genug Daten für Load-Vitals Korrelation"
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
if h_msg or r_msg:
msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}"
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": msg,
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
},
}
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
corr_data = corr_hrv
metric_name = "HRV"
else:
corr_data = corr_rhr
metric_name = "RHR"
if not corr_data or corr_data.get("correlation") is None:
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
},
}
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
correlation = corr_data.get("correlation", 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Load → {metric_name} (Lag {best_lag}d)"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#F59E0B",
"borderColor": "#D97706",
"borderWidth": 2,
"pointRadius": 8,
}
],
},
"metadata": {
"confidence": corr_data.get("confidence", "low"),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"metric": metric_name,
"interpretation": corr_data.get("interpretation", ""),
"data_points": corr_data.get("data_points", 0),
"lag_details": corr_data.get("lag_details"),
"layer_1": "correlations._correlate_load_vitals",
},
}
def build_recovery_performance_chart_payload(profile_id: str) -> Dict[str, Any]:
drivers = calculate_top_drivers(profile_id)
if not drivers or len(drivers) == 0:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Nicht genug Daten für Driver-Analyse",
},
}
hindering = [d for d in drivers if d.get("impact", "") == "hindering"]
helpful = [d for d in drivers if d.get("impact", "") == "helpful"]
top_hindering = hindering[:3]
top_helpful = helpful[:3]
labels = []
values = []
colors = []
for d in top_hindering:
labels.append(f"❌ {d.get('factor', '')}")
values.append(-abs(d.get("score", 0)))
colors.append("#EF4444")
for d in top_helpful:
labels.append(f"✅ {d.get('factor', '')}")
values.append(abs(d.get("score", 0)))
colors.append("#1D9E75")
if not labels:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "low",
"data_points": 0,
"message": "Keine signifikanten Treiber gefunden",
},
}
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Impact Score",
"data": values,
"backgroundColor": colors,
"borderColor": "#085041",
"borderWidth": 1,
}
],
},
"metadata": {
"confidence": "medium",
"hindering_count": len(top_hindering),
"helpful_count": len(top_helpful),
"total_factors": len(drivers),
},
}

View File

@ -17,116 +17,401 @@ Phase 0c: Multi-Layer Architecture
Version: 1.0
"""
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, timedelta, date
from db import get_db, get_cursor, r2d
import statistics
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
from data_layer.nutrition_metrics import estimate_tdee_kcal_from_latest_weight
# Lag-Korrelation (Issue #53): gleiche TDEE-Logik wie nutrition_metrics / nutrition_viz
MIN_PAIRS_LAG_CORR = 15
LAG_CORR_LOOKBACK_DAYS = 120
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
"""
Calculate lagged correlation between two variables
Pearson-Korrelation mit Lag-Sweep (Issue 53, Data-Layer).
Args:
var1: 'energy', 'protein', 'training_load'
var2: 'weight', 'lbm', 'hrv', 'rhr'
max_lag_days: Maximum lag to test
C1: Tagesbilanz (kcal − TDEE wie ``estimate_tdee_kcal_from_latest_weight``) vs. ΔGewicht [t→t+L], L≥1.
C2: Protein (g) vs. ΔMager [t→t+L] aus ``build_merged_daily_nutrition_body_rows``, L≥1.
C3: Summe ``duration_min`` pro Tag vs. HRV oder Ruhepuls am Tag t+L (L≥0).
Returns:
{
'best_lag': X, # days
'correlation': 0.XX, # -1 to 1
'direction': 'positive'/'negative'/'none',
'confidence': 'high'/'medium'/'low',
'data_points': N
}
Rückgabe enthält u. a. ``best_lag`` / ``best_lag_days``, ``correlation``, ``interpretation``,
optional ``lag_details`` (r, n je Lag), mindestens ``MIN_PAIRS_LAG_CORR`` Paare am besten Lag.
"""
if var1 == 'energy' and var2 == 'weight':
return _correlate_energy_weight(profile_id, max_lag_days)
elif var1 == 'protein' and var2 == 'lbm':
return _correlate_protein_lbm(profile_id, max_lag_days)
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
return _correlate_load_vitals(profile_id, var2, max_lag_days)
v1 = (var1 or "").strip().lower()
if v1 in ("energy", "energy_balance"):
v1n = "energy"
elif v1 in ("training_load", "load"):
v1n = "training_load"
elif v1 == "protein":
v1n = "protein"
else:
v1n = v1
if v1n == 'energy' and var2 == 'weight':
return _normalize_lag_payload(_correlate_energy_weight(profile_id, max_lag_days))
elif v1n == 'protein' and var2 == 'lbm':
return _normalize_lag_payload(_correlate_protein_lbm(profile_id, max_lag_days))
elif v1n == 'training_load' and var2 in ['hrv', 'rhr']:
return _normalize_lag_payload(_correlate_load_vitals(profile_id, var2, max_lag_days))
else:
return None
def _normalize_lag_payload(raw: Optional[Dict]) -> Optional[Dict]:
"""Charts erwarten u. a. ``best_lag_days``; Layer liefert teils ``best_lag``."""
if not raw:
return None
out = dict(raw)
if out.get("best_lag_days") is None and out.get("best_lag") is not None:
out["best_lag_days"] = out["best_lag"]
return out
def _iso_date_key(d: Any) -> str:
if d is None:
return ""
if hasattr(d, "isoformat"):
return str(d.isoformat())[:10]
s = str(d)
return s[:10] if len(s) >= 10 else s
def _parse_iso_to_date(ds: str) -> Optional[date]:
if not ds or len(ds) < 10:
return None
try:
return date.fromisoformat(ds[:10])
except ValueError:
return None
def _pearson_r(xs: List[float], ys: List[float]) -> Optional[float]:
"""Pearson-Korrelation; mindestens ``MIN_PAIRS_LAG_CORR`` Paare."""
n = len(xs)
if n < MIN_PAIRS_LAG_CORR or n != len(ys):
return None
mx = sum(xs) / n
my = sum(ys) / n
num = sum((xs[i] - mx) * (ys[i] - my) for i in range(n))
dx = sum((xs[i] - mx) ** 2 for i in range(n))
dy = sum((ys[i] - my) ** 2 for i in range(n))
if dx <= 1e-12 or dy <= 1e-12:
return None
r = num / ((dx**0.5) * (dy**0.5))
return float(max(-1.0, min(1.0, r)))
def _direction_from_r(r: float) -> str:
if r > 0.05:
return "positive"
if r < -0.05:
return "negative"
return "none"
def _lag_confidence(n_pairs: int, r: float) -> str:
return calculate_correlation_confidence(n_pairs, abs(r))
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
"""
Correlate energy balance with weight change
Test lags: 0, 3, 7, 10, 14 days
Pearson: Tagesbilanz (kcal − TDEE wie nutrition_metrics) vs. Gewichtsdifferenz
vom Tag t zu Tag t+L (L = 0 … max_lag). Bestes Lag nach maximalem |r|.
"""
with get_db() as conn:
cur = get_cursor(conn)
# Get energy balance data (daily calories - estimated TDEE)
cur.execute("""
SELECT n.date, n.kcal, w.weight
FROM nutrition_log n
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
AND w.date = n.date
WHERE n.profile_id = %s
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
ORDER BY n.date
""", (profile_id,))
data = cur.fetchall()
if len(data) < 30:
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
if tdee is None or float(tdee) <= 0:
return {
'best_lag': None,
'correlation': None,
'direction': 'none',
'confidence': 'low',
'data_points': len(data),
'reason': 'Insufficient data (<30 days)'
"best_lag": None,
"correlation": None,
"direction": "none",
"confidence": "insufficient",
"data_points": 0,
"interpretation": "Keine TDEE-Schätzung möglich (Gewicht/Demografie).",
"reason": "no_tdee",
}
# Calculate 7d rolling energy balance
# (Simplified - actual implementation would need TDEE estimation)
tdee_f = float(tdee)
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
# For now, return placeholder
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT date::date AS d, SUM(kcal)::float AS kcal
FROM nutrition_log
WHERE profile_id = %s AND date >= %s::date AND kcal IS NOT NULL
GROUP BY date
ORDER BY date
""",
(profile_id, cutoff),
)
kcal_rows = cur.fetchall()
cur.execute(
"""
SELECT date::date AS d, weight::float AS weight
FROM weight_log
WHERE profile_id = %s AND date >= %s::date AND weight IS NOT NULL
ORDER BY date
""",
(profile_id, cutoff),
)
w_rows = cur.fetchall()
kcal_by: Dict[str, float] = {}
for r in kcal_rows:
kcal_by[_iso_date_key(r["d"])] = float(r["kcal"] or 0)
weight_by: Dict[str, float] = {}
for r in w_rows:
weight_by[_iso_date_key(r["d"])] = float(r["weight"])
balance_by = {d: kcal_by[d] - tdee_f for d in kcal_by}
best: Optional[Tuple[int, float, int]] = None
lag_details: List[Dict[str, Any]] = []
max_l = max(0, min(int(max_lag), 28))
# Lag 0: ΔGewicht am selben Tag ist immer 0 → sinnvoll erst ab Tag 1
for lag in range(1, max_l + 1):
xs: List[float] = []
ys: List[float] = []
for ds in sorted(balance_by.keys()):
d0 = _parse_iso_to_date(ds)
if d0 is None:
continue
d1 = d0 + timedelta(days=lag)
ds1 = d1.isoformat()
w0 = weight_by.get(ds)
w1 = weight_by.get(ds1)
if w0 is None or w1 is None:
continue
xs.append(balance_by[ds])
ys.append(w1 - w0)
r = _pearson_r(xs, ys)
n_p = len(xs)
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
if r is None:
continue
if best is None or abs(r) > abs(best[1]):
best = (lag, r, n_p)
if best is None:
return {
'best_lag': 7,
'correlation': -0.45, # Placeholder
'direction': 'negative', # Higher deficit = lower weight (expected)
'confidence': 'medium',
'data_points': len(data)
"best_lag": None,
"correlation": None,
"direction": "none",
"confidence": "insufficient",
"data_points": 0,
"interpretation": "Zu wenige gepaarte Tage mit Ernährung, Gewicht und gewähltem Lag.",
"reason": "insufficient_pairs",
"lag_details": lag_details,
"tdee_kcal_used": round(tdee_f, 0),
}
lag_b, r_b, n_b = best
direction = _direction_from_r(r_b)
conf = _lag_confidence(n_b, r_b)
interp = (
f"Tagesbilanz (kcal − TDEE ~{tdee_f:.0f}) vs. Gewichtsänderung nach {lag_b} Tagen: "
f"r ≈ {r_b:.2f} ({direction}). "
f"Basierend auf {n_b} Kalendertagen mit vollständigen Paaren."
)
return {
"best_lag": lag_b,
"correlation": round(r_b, 4),
"direction": direction,
"confidence": conf,
"data_points": n_b,
"interpretation": interp,
"lag_details": lag_details,
"tdee_kcal_used": round(tdee_f, 0),
}
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
"""Correlate protein intake with LBM trend"""
# TODO: Implement full correlation calculation
"""
Pearson: Protein (g/Tag) vs. Magermasse-Differenz (kg) vom Tag t zu t+L.
Datenbasis: nutrition_body_merge (Caliper-LBM forward-filled wie Ernährungs-Verlauf).
"""
merged = build_merged_daily_nutrition_body_rows(profile_id)
if not merged:
return {
'best_lag': 0,
'correlation': 0.32, # Placeholder
'direction': 'positive',
'confidence': 'medium',
'data_points': 28
"best_lag": None,
"correlation": None,
"direction": "none",
"confidence": "insufficient",
"data_points": 0,
"interpretation": "Keine zusammengeführten Ernährungs-/Körperdaten.",
"reason": "no_merged_rows",
}
protein_by: Dict[str, float] = {}
lbm_by: Dict[str, float] = {}
for row in merged:
ds = _iso_date_key(row.get("date"))
if not ds:
continue
pg = row.get("protein_g")
lm = row.get("lean_mass")
if pg is not None:
protein_by[ds] = float(pg)
if lm is not None:
lbm_by[ds] = float(lm)
best: Optional[Tuple[int, float, int]] = None
lag_details: List[Dict[str, Any]] = []
max_l = max(0, min(int(max_lag), 28))
for lag in range(1, max_l + 1):
xs: List[float] = []
ys: List[float] = []
for ds in sorted(protein_by.keys()):
if ds not in lbm_by:
continue
d0 = _parse_iso_to_date(ds)
if d0 is None:
continue
d1 = d0 + timedelta(days=lag)
ds1 = d1.isoformat()
if ds1 not in lbm_by:
continue
xs.append(protein_by[ds])
ys.append(lbm_by[ds1] - lbm_by[ds])
r = _pearson_r(xs, ys)
n_p = len(xs)
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
if r is None:
continue
if best is None or abs(r) > abs(best[1]):
best = (lag, r, n_p)
if best is None:
return {
"best_lag": None,
"correlation": None,
"direction": "none",
"confidence": "insufficient",
"data_points": 0,
"interpretation": "Zu wenige Tage mit Protein und Magermasse (Caliper) für die gewählten Lags.",
"reason": "insufficient_pairs",
"lag_details": lag_details,
}
lag_b, r_b, n_b = best
direction = _direction_from_r(r_b)
conf = _lag_confidence(n_b, r_b)
interp = (
f"Protein (g/Tag) vs. Magermasse-Änderung nach {lag_b} Tagen: r ≈ {r_b:.2f} ({direction}). "
f"{n_b} gepaarte Tage."
)
return {
"best_lag": lag_b,
"correlation": round(r_b, 4),
"direction": direction,
"confidence": conf,
"data_points": n_b,
"interpretation": interp,
"lag_details": lag_details,
}
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
"""
Correlate training load with HRV or RHR
Test lags: 1, 2, 3 days
Pearson: Tages-Trainingslast (Summe duration_min) vs. Vitals (HRV ms oder Ruhepuls)
am Kalendertag t+Lag (typisch: Belastung am Vortag, Vitalwert am Folgetag bei Lag ≥ 1).
"""
# TODO: Implement full correlation calculation
if vital == 'hrv':
col = "hrv" if vital == "hrv" else "resting_hr"
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
FROM activity_log
WHERE profile_id = %s AND date >= %s::date
AND duration_min IS NOT NULL AND duration_min > 0
GROUP BY date
ORDER BY date
""",
(profile_id, cutoff),
)
load_rows = cur.fetchall()
cur.execute(
f"""
SELECT date::text AS d, {col}::float AS v
FROM vitals_baseline
WHERE profile_id = %s AND date >= %s::date AND {col} IS NOT NULL
ORDER BY date
""",
(profile_id, cutoff),
)
vit_rows = cur.fetchall()
load_by = {str(r["d"])[:10]: float(r["minutes"] or 0) for r in load_rows}
vital_by = {str(r["d"])[:10]: float(r["v"]) for r in vit_rows}
best: Optional[Tuple[int, float, int]] = None
lag_details: List[Dict[str, Any]] = []
max_l = max(0, min(int(max_lag), 28))
vlabel = "HRV (ms)" if vital == "hrv" else "Ruhepuls (bpm)"
for lag in range(0, max_l + 1):
xs: List[float] = []
ys: List[float] = []
for ds in sorted(load_by.keys()):
d0 = _parse_iso_to_date(ds)
if d0 is None:
continue
d1 = d0 + timedelta(days=lag)
ds1 = d1.isoformat()
if ds1 not in vital_by:
continue
xs.append(load_by[ds])
ys.append(vital_by[ds1])
r = _pearson_r(xs, ys)
n_p = len(xs)
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
if r is None:
continue
if best is None or abs(r) > abs(best[1]):
best = (lag, r, n_p)
if best is None:
return {
'best_lag': 1,
'correlation': -0.38, # Negative = high load reduces HRV (expected)
'direction': 'negative',
'confidence': 'medium',
'data_points': 25
"best_lag": None,
"correlation": None,
"direction": "none",
"confidence": "insufficient",
"data_points": 0,
"interpretation": f"Zu wenige gepaarte Tage mit Training und {vlabel}.",
"reason": "insufficient_pairs",
"lag_details": lag_details,
"vital": vital,
}
else: # rhr
lag_b, r_b, n_b = best
direction = _direction_from_r(r_b)
conf = _lag_confidence(n_b, r_b)
interp = (
f"Trainingsminuten/Tag vs. {vlabel} nach {lag_b} Tagen Lag: r ≈ {r_b:.2f} ({direction}). "
f"{n_b} Paare."
)
return {
'best_lag': 1,
'correlation': 0.42, # Positive = high load increases RHR (expected)
'direction': 'positive',
'confidence': 'medium',
'data_points': 25
"best_lag": lag_b,
"correlation": round(r_b, 4),
"direction": direction,
"confidence": conf,
"data_points": n_b,
"interpretation": interp,
"lag_details": lag_details,
"vital": vital,
}

View File

@ -0,0 +1,283 @@
"""
KPI-Kacheln für Layer-2b Fitness-Dashboard (Issue #53).
Ausgabe für KpiTilesOverview; ``keys`` = Platzhalter-Registry-Referenzen.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _verdict(status: str) -> str:
if status == "good":
return "Gut"
if status == "warn":
return "Hinweis"
return "Achtung"
def _minutes_status(minutes: Optional[int]) -> str:
if minutes is None:
return "warn"
if 150 <= minutes <= 300:
return "good"
if minutes < 150:
return "warn" if minutes >= 90 else "bad"
return "warn"
def _quality_status(pct: Optional[int]) -> str:
if pct is None:
return "warn"
if pct >= 60:
return "good"
if pct >= 40:
return "warn"
return "bad"
def _score_status(score: Optional[int]) -> str:
if score is None:
return "warn"
if score >= 70:
return "good"
if score >= 50:
return "warn"
return "bad"
def _vo2_status(trend: Optional[float]) -> str:
if trend is None:
return "warn"
if trend > 0.5:
return "good"
if trend >= -0.5:
return "warn"
return "bad"
def _vol_delta_status(delta_pct: Optional[float], prior7: int, last7: int) -> str:
if delta_pct is None:
if last7 > 0 and prior7 == 0:
return "good"
return "warn"
if delta_pct >= 5:
return "good"
if delta_pct >= -10:
return "warn"
return "bad"
def build_fitness_progress_insights(
vol_delta: Dict[str, Any],
load_meta: Dict[str, Any],
quality_pct: Optional[int],
) -> List[Dict[str, Any]]:
"""
Kurz-Aussagen für die UI (Layer 2b), keine zweite Datenquelle.
"""
out: List[Dict[str, Any]] = []
if vol_delta.get("has_data"):
last7 = int(vol_delta.get("last7_min") or 0)
prev7 = int(vol_delta.get("prior7_min") or 0)
d = vol_delta.get("delta_pct")
if d is not None:
sign = "+" if d > 0 else ""
body = (
f"Trainingsminuten letzte 7 Tage ({last7} min) vs. Vorwoche ({prev7} min): "
f"{sign}{d} %."
)
elif last7 > 0 and prev7 == 0:
body = f"Mehr Volumen als in der Vorwoche: zuletzt {last7} min (Vorwoche 0 min)."
else:
body = "Zu wenig Daten für einen Vorwochen-Vergleich."
out.append(
{
"key": "ins_vol_trend",
"tone": _vol_delta_status(
float(d) if d is not None else None, prev7, last7
),
"title": "Volumen-Trend",
"body": body,
}
)
acwr = load_meta.get("acwr")
st = load_meta.get("acwr_status")
if acwr is not None and isinstance(load_meta, dict) and load_meta.get("data_points", 0) > 0:
if st == "optimal":
tone = "good"
hint = "Akute zu chronischer Last (ACWR) liegt im oft empfohlenen Bereich (ca. 0,8–1,3)."
else:
tone = "warn"
hint = (
"ACWR außerhalb des häufig genannten Zielkorridors — bei anhaltender Belastung "
"Erholung oder Volumen prüfen (Proxy-Modell)."
)
out.append(
{
"key": "ins_acwr",
"tone": tone,
"title": "Belastungsverhältnis (ACWR)",
"body": f"Verhältnis akut (7 Tage) zu chronisch (28 Tage): {float(acwr):.2f}. {hint}",
}
)
if quality_pct is not None:
tone = "good" if quality_pct >= 60 else "warn" if quality_pct >= 40 else "bad"
out.append(
{
"key": "ins_quality",
"tone": tone,
"title": "Session-Qualität",
"body": f"{quality_pct} % der Sessions sind als «gut» oder besser eingestuft — Grundlage für progressive Belastung.",
}
)
return out
def build_fitness_dashboard_kpi_tiles(
summary: Dict[str, Any],
minutes_7d: Optional[int],
quality_pct: Optional[int],
quality_window_days: int,
activity_score: Optional[int],
vo2_trend: Optional[float],
top_focus: Optional[Dict[str, Any]],
vol_delta: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
spw = summary.get("sessions_per_week")
try:
spw_f = float(spw) if spw is not None else None
except (TypeError, ValueError):
spw_f = None
spw_s = f"{spw_f:.1f}".replace(".", ",") if spw_f is not None else "—"
m_status = _minutes_status(minutes_7d)
q_status = _quality_status(quality_pct)
s_status = _score_status(activity_score)
v_status = _vo2_status(vo2_trend)
tiles: List[Dict[str, Any]] = []
if vol_delta and vol_delta.get("has_data"):
d = vol_delta.get("delta_pct")
last7 = int(vol_delta.get("last7_min") or 0)
prev7 = int(vol_delta.get("prior7_min") or 0)
if d is not None:
sign = "+" if float(d) > 0 else ""
v_s = f"{sign}{d:.1f} %".replace(".", ",")
sub = f"{last7} min vs. {prev7} min (7-Tage-Fenster)"
elif last7 > 0 and prev7 == 0:
v_s = "neu"
sub = f"{last7} min letzte Woche"
else:
v_s = "—"
sub = "Vergleich Vorwoche"
vd_st = _vol_delta_status(float(d) if d is not None else None, prev7, last7)
tiles.append(
{
"key": "volume_vs_prior_week",
"category": "Volumen vs. Vorwoche",
"icon": "📈",
"value": v_s,
"sublabel": sub,
"status": vd_st,
"verdict": _verdict(vd_st),
"hoverTop": "Fortschritt Trainingsminuten",
"hoverBody": "Letzte 7 Kalendertage vs. die 7 Tage davor (activity_log).",
"keys": ["training_minutes_week", "activity_summary"],
}
)
tiles.extend(
[
{
"key": "minutes_week",
"category": "Minuten (7 Tage)",
"icon": "⏱",
"value": f"{minutes_7d} min" if minutes_7d is not None else "—",
"sublabel": "WHO: 150–300 min/Woche",
"status": m_status,
"verdict": _verdict(m_status),
"hoverTop": "Summe Trainingsminuten (letzte 7 Tage)",
"hoverBody": "Gleiche Quelle wie Platzhalter training_minutes_week.",
"keys": ["training_minutes_week", "activity_score"],
},
{
"key": "sessions_per_week",
"category": "Sessions / Woche",
"icon": "📅",
"value": spw_s,
"sublabel": f"Fenster: {summary.get('days_analyzed', '—')} Tage",
"status": "good",
"verdict": "Gut",
"hoverTop": "Durchschnittliche Sessions pro Woche",
"hoverBody": "Aus activity_summary (activity_log im gewählten Zeitraum).",
"keys": ["activity_summary"],
},
{
"key": "quality_pct",
"category": "Qualitätssessions",
"icon": "✓",
"value": f"{quality_pct} %" if quality_pct is not None else "—",
"sublabel": f"Anteil «gut+» · {quality_window_days} Tage",
"status": q_status,
"verdict": _verdict(q_status),
"hoverTop": "Anteil Sessions mit guter Qualitätslabel-Klassifikation",
"hoverBody": "Entspricht quality_sessions_pct (Fenster wie gewählt).",
"keys": ["quality_sessions_pct"],
},
{
"key": "activity_score",
"category": "Activity-Score",
"icon": "🎯",
"value": str(activity_score) if activity_score is not None else "—",
"sublabel": "Ausrichtung an gewichteten Fokusbereichen",
"status": s_status,
"verdict": _verdict(s_status) if activity_score is not None else "Hinweis",
"hoverTop": "Gewichteter Score (0–100)",
"hoverBody": "Ohne gewichtete Aktivitäts-Fokusbereiche kein Score.",
"keys": ["activity_score"],
},
{
"key": "vo2_trend",
"category": "VO₂max-Trend",
"icon": "🫁",
"value": f"{vo2_trend:+.1f}" if vo2_trend is not None else "—",
"sublabel": "28-Tage-Trend (geschätzt)",
"status": v_status,
"verdict": _verdict(v_status) if vo2_trend is not None else "Hinweis",
"hoverTop": "Trend der VO₂max-Schätzung aus Aktivitätsdaten",
"hoverBody": "Wie vo2max_trend_28d im Data Layer.",
"keys": ["vo2max_trend_28d"],
},
]
)
if top_focus:
prog = top_focus.get("progress")
prog_s = f"{prog} %" if prog is not None else "—"
w = top_focus.get("weight")
try:
w_s = f"{float(w):.0f} %" if w is not None else "—"
except (TypeError, ValueError):
w_s = "—"
tiles.append(
{
"key": "top_focus",
"category": "Schwerpunkt-Fokus",
"icon": "🔭",
"value": str(top_focus.get("label") or "—"),
"sublabel": f"Fortschritt {prog_s} · Gewicht {w_s}",
"status": "good",
"verdict": "Gut",
"hoverTop": "Höchstgewichteter Fokusbereich",
"hoverBody": "Aus focus_area_definitions + Nutzer-Gewichtungen.",
"keys": ["top_focus_area_name", "top_focus_area_progress"],
}
)
return tiles

View File

@ -0,0 +1,157 @@
"""
Layer 2b: Fitness-Hub — ein Bundle für die Aktivitäts-/Fitness-UI (Issue #53).
Single Source: activity_metrics + dieselben Hilfsfunktionen wie Chart-Endpunkte A1/A2.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from db import get_db, get_cursor
from data_layer.activity_metrics import (
build_load_monitoring_chart_payload,
build_quality_sessions_chart_payload,
build_training_type_distribution_chart_payload,
build_training_volume_chart_payload,
calculate_activity_score,
calculate_training_minutes_week,
calculate_quality_sessions_pct,
calculate_vo2max_trend_28d,
get_activity_summary_data,
get_training_volume_two_week_delta,
)
from data_layer.fitness_interpretation import (
build_fitness_dashboard_kpi_tiles,
build_fitness_progress_insights,
)
from data_layer.scores import get_top_focus_area
def _iso(d: Any) -> Optional[str]:
if d is None:
return None
if hasattr(d, "isoformat"):
return d.isoformat()[:10]
return str(d)[:10]
def _has_activity_entries(profile_id: str) -> bool:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT 1 FROM activity_log WHERE profile_id=%s LIMIT 1",
(profile_id,),
)
return cur.fetchone() is not None
def _last_activity_date(profile_id: str) -> Optional[str]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT MAX(date) AS d FROM activity_log WHERE profile_id=%s",
(profile_id,),
)
row = cur.fetchone()
if not row or row["d"] is None:
return None
return _iso(row["d"])
def get_activity_last_updated_iso(profile_id: str) -> Optional[str]:
"""
Leichtgewicht: letztes activity_log.date — identisch zu ``last_updated`` im Fitness-Viz-Bundle.
Für History-Header o. Ä. ohne vollständige Aktivitätsliste (Phase A, Issue-53-Pfad).
"""
return _last_activity_date(profile_id)
def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
"""
Bundle für Fitness-Übersicht: KPI-Kacheln + eingebettete Chart-Payloads (Chart.js-Format).
``days``: Analysefenster für Zusammenfassung; >=9999 = lange Historie (max. 3650 Tage).
"""
if not _has_activity_entries(profile_id):
return {
"confidence": "insufficient",
"has_activity_entries": False,
"message": "Noch keine Aktivitätsdaten",
"kpi_tiles": [],
"summary": {},
"progress_insights": [],
"volume_delta": {},
"charts": {},
"meta": {"layer_1": "activity_metrics", "layer_2b": "fitness_viz"},
}
all_history = days >= 9999
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
summary = get_activity_summary_data(profile_id, eff_days)
weeks_vol = max(4, min(52, (min(eff_days, 365) + 6) // 7))
dist_days = min(90, max(7, min(eff_days, 365)))
load_days = min(90, max(14, min(eff_days, 365)))
volume_chart = build_training_volume_chart_payload(profile_id, weeks_vol)
type_chart = build_training_type_distribution_chart_payload(profile_id, dist_days)
quality_chart = build_quality_sessions_chart_payload(profile_id, dist_days)
load_chart = build_load_monitoring_chart_payload(profile_id, load_days)
quality_days = dist_days
quality_pct = calculate_quality_sessions_pct(profile_id, quality_days)
minutes_7d = calculate_training_minutes_week(profile_id)
activity_score = calculate_activity_score(profile_id)
vo2_trend = calculate_vo2max_trend_28d(profile_id)
top_focus = get_top_focus_area(profile_id)
vol_delta = get_training_volume_two_week_delta(profile_id)
kpi_tiles = build_fitness_dashboard_kpi_tiles(
summary,
minutes_7d,
quality_pct,
quality_days,
activity_score,
vo2_trend,
top_focus,
vol_delta,
)
load_meta = load_chart.get("metadata") or {}
if not isinstance(load_meta, dict):
load_meta = {}
progress_insights = build_fitness_progress_insights(vol_delta, load_meta, quality_pct)
conf = summary.get("confidence") or "medium"
if summary.get("activity_count", 0) == 0:
conf = "insufficient"
return {
"confidence": conf,
"has_activity_entries": True,
"days_requested": days,
"effective_window_days": eff_days,
"training_volume_weeks_used": weeks_vol,
"training_type_dist_days_used": dist_days,
"last_updated": _last_activity_date(profile_id),
"summary": summary,
"kpi_tiles": kpi_tiles,
"interpretation_tiles": [],
"progress_insights": progress_insights,
"volume_delta": vol_delta,
"charts": {
"training_volume": volume_chart,
"training_type_distribution": type_chart,
"quality_sessions": quality_chart,
"load_monitoring": load_chart,
},
"load_chart_days_used": load_days,
"meta": {
"layer_1": "activity_metrics",
"layer_2b": "fitness_viz",
"issue": "53-layer-2b-fitness",
},
}

View File

@ -0,0 +1,238 @@
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
from __future__ import annotations
import re
from typing import Any
_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+")
_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"}
MACRO_TO_KEY = {
"kcal": "ENERCC",
"protein_g": "PROT625",
"fat_g": "FAT",
"carbs_g": "CHO",
}
def scale_to_per_100g(value: float, serving_g: float | None) -> float:
if not serving_g or serving_g <= 0 or serving_g == 100:
return float(value)
return float(value) * (100.0 / float(serving_g))
def list_numeric_attributes(
cur, query: str = "", limit: int = 40, keys: list[str] | None = None
) -> list[dict[str, Any]]:
lim = min(max(int(limit or 40), 1), 80)
wanted = [str(k).strip() for k in (keys or []) if str(k).strip()]
if wanted:
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
AND attr_key = ANY(%s)
ORDER BY sort_order, attr_key
""",
(wanted,),
)
return [dict(r) for r in cur.fetchall()]
q = (query or "").strip()
if q:
like = f"%{q}%"
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
AND (
attr_key ILIKE %s OR name_de ILIKE %s
OR COALESCE(name_en, '') ILIKE %s
)
ORDER BY
CASE WHEN attr_key ILIKE %s THEN 0
WHEN name_de ILIKE %s THEN 1
ELSE 2 END,
sort_order, attr_key
LIMIT %s
""",
(like, like, like, q, f"{q}%", lim),
)
else:
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
ORDER BY sort_order, attr_key
LIMIT %s
""",
(lim,),
)
return [dict(r) for r in cur.fetchall()]
def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
if not values:
return 0
written = 0
for raw_key, raw_val in values.items():
key = str(raw_key or "").strip()
if not key or raw_val is None or raw_val == "":
continue
try:
num = float(raw_val)
except (TypeError, ValueError):
continue
cur.execute(
"""
SELECT id FROM food_attributes
WHERE attr_key = %s AND is_active = true AND data_type = 'num_per_100g'
""",
(key,),
)
row = cur.fetchone()
if not row:
continue
cur.execute(
"""
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
VALUES (%s, %s, %s, false)
ON CONFLICT (food_id, attribute_id)
DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
""",
(food_id, row["id"], num),
)
written += 1
return written
def replace_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
cur.execute("DELETE FROM food_attribute_values WHERE food_id = %s", (food_id,))
return write_numeric_attributes(cur, food_id, values)
KEY_TO_MACRO = {v: k for k, v in MACRO_TO_KEY.items()}
def editor_payload_for_food(cur, food: dict[str, Any], *, editable: bool) -> dict[str, Any]:
cur.execute(
"""
SELECT a.attr_key, a.name_de, a.name_en, a.unit, a.category, a.origin, v.value_num
FROM food_attribute_values v
JOIN food_attributes a ON a.id = v.attribute_id
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
AND v.value_num IS NOT NULL AND v.is_trace = false
ORDER BY a.sort_order, a.attr_key
""",
(food["id"],),
)
macros = {"kcal": 0, "protein_g": 0, "fat_g": 0, "carbs_g": 0}
extras = []
for row in cur.fetchall():
rec = dict(row)
field = KEY_TO_MACRO.get(rec["attr_key"])
if field:
macros[field] = float(rec["value_num"])
else:
extras.append({
"attr": {
"attr_key": rec["attr_key"],
"name_de": rec["name_de"],
"name_en": rec["name_en"],
"unit": rec["unit"],
"category": rec["category"],
"origin": rec["origin"],
},
"value": float(rec["value_num"]),
})
return {
"id": str(food["id"]),
"name_de": food.get("name_de") or "",
"name_en": food.get("name_en"),
"catalog_kind": food.get("catalog_kind"),
"editable": editable,
"macros_per_100g": macros,
"attributes": extras,
"serving_g": 100,
}
def macros_and_attributes_to_values(
macros: dict[str, Any] | None,
attributes: dict[str, Any] | None,
serving_g: float | None = None,
) -> dict[str, float]:
out: dict[str, float] = {}
for field, key in MACRO_TO_KEY.items():
if not macros or field not in macros or macros[field] is None or macros[field] == "":
continue
try:
out[key] = scale_to_per_100g(float(macros[field]), serving_g)
except (TypeError, ValueError):
continue
for key, raw in (attributes or {}).items():
if raw is None or raw == "":
continue
try:
out[str(key)] = scale_to_per_100g(float(raw), serving_g)
except (TypeError, ValueError):
continue
return out
def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
cur.execute(
"""
SELECT a.attr_key, v.value_num
FROM food_attribute_values v
JOIN food_attributes a ON a.id = v.attribute_id
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
AND v.value_num IS NOT NULL AND v.is_trace = false
AND NOT (a.attr_key = ANY(%s))
ORDER BY a.sort_order, a.attr_key
""",
(food_id, list(MACRO_TO_KEY.values())),
)
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
def normalize_attr_key(raw: str | None) -> str:
key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_")
return key[:64]
def normalize_attr_unit(raw: str | None) -> str:
return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g")
def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]:
name = (name_de or "").strip()
if not name:
raise ValueError("Name fehlt")
key = normalize_attr_key(attr_key or name)
if not key:
raise ValueError("attr_key fehlt")
unit_n = normalize_attr_unit(unit)
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE attr_key = %s AND is_active = true
""",
(key,),
)
existing = cur.fetchone()
if existing:
return dict(existing)
cur.execute(
"""
INSERT INTO food_attributes
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100)
RETURNING id, attr_key, name_de, name_en, unit, category, origin
""",
(key, name, None, unit_n),
)
return dict(cur.fetchone())

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,121 @@
"""Who may see and map a catalog food.
Scopes:
- personal — ``manual_user`` with ``owner_profile_id`` (only that profile)
- global — ``manual_admin`` (all users; no owner)
- bls — official BLS (all users; no owner)
Name-mappings: a *user* mapping may target any food visible to that user.
A *global* mapping (``profile_id IS NULL``) may only target global/BLS foods,
never another user's private entry.
"""
from __future__ import annotations
from typing import Any
VIS_PERSONAL = "personal"
VIS_GLOBAL = "global"
VIS_BLS = "bls"
SQL_VISIBLE_TO_PROFILE = "(owner_profile_id IS NULL OR owner_profile_id = %s)"
SQL_GLOBAL_ONLY = "owner_profile_id IS NULL"
_KIND_VIS = {
"official_bls": VIS_BLS,
"manual_admin": VIS_GLOBAL,
"manual_user": VIS_PERSONAL,
}
_VIS_RANK = {VIS_PERSONAL: 0, VIS_GLOBAL: 1, VIS_BLS: 2}
def same_id(a, b) -> bool:
return str(a or "").replace("-", "").lower() == str(b or "").replace("-", "").lower()
def visibility_of(food: dict[str, Any] | None) -> str:
if not food:
return VIS_BLS
kind = food.get("catalog_kind")
if kind in _KIND_VIS:
return _KIND_VIS[kind]
if food.get("owner_profile_id"):
return VIS_PERSONAL
return VIS_GLOBAL
def visibility_rank(food: dict[str, Any] | None) -> int:
return _VIS_RANK.get(visibility_of(food), 3)
def is_active(food: dict[str, Any] | None) -> bool:
if not food:
return False
if "is_active" not in food:
return True
return bool(food.get("is_active"))
def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool:
if not is_active(food):
return False
owner = food.get("owner_profile_id")
if owner is None:
return True
return profile_id is not None and same_id(owner, profile_id)
def is_global_mapping_target(food: dict[str, Any] | None) -> bool:
return is_active(food) and food.get("owner_profile_id") is None
def load_catalog_food(cur, food_id: str) -> dict[str, Any] | None:
cur.execute(
"""
SELECT id, name_de, catalog_kind, owner_profile_id, is_active, bls_code
FROM food_catalog
WHERE id = %s
""",
(food_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def require_visible_food(cur, food_id: str, profile_id: str) -> dict[str, Any]:
food = load_catalog_food(cur, food_id)
if not is_visible_to(food, profile_id):
raise LookupError("Lebensmittel nicht gefunden")
return food
def require_global_mapping_target(cur, food_id: str) -> dict[str, Any]:
food = load_catalog_food(cur, food_id)
if not food or not is_active(food):
raise LookupError("Lebensmittel nicht gefunden")
if not is_global_mapping_target(food):
raise PermissionError("Globales Mapping darf nur auf zentrale oder BLS-Lebensmittel zeigen")
return food
def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) -> dict[str, Any]:
if mapping_profile_id:
return require_visible_food(cur, food_id, mapping_profile_id)
return require_global_mapping_target(cur, food_id)
def can_edit_food(food: dict[str, Any] | None, profile_id: str | None, *, is_admin: bool = False) -> bool:
"""Owner ändert persönlich; Admin ändert zentral (und im Admin-Katalog auch fremde Manuals)."""
if not is_active(food):
return False
kind = food.get("catalog_kind")
if kind == "manual_user":
return same_id(food.get("owner_profile_id"), profile_id) or bool(is_admin)
if kind == "manual_admin":
return bool(is_admin)
return False
def attach_visibility(row: dict[str, Any]) -> dict[str, Any]:
row["visibility"] = visibility_of(row)
return row

View File

@ -0,0 +1,251 @@
"""
Layer 2b: Gesamtansicht «Verlauf» — komponiert nur Bundles aus body-, nutrition-, fitness-, recovery_viz.
Issue #53: keine parallele Business-Logik; ein Router-Endpoint liefert diese Zusammenfassung.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from data_layer.body_viz import get_body_history_viz_bundle
from data_layer.correlation_chart_payloads import (
build_lbm_protein_correlation_chart_payload,
build_load_vitals_correlation_chart_payload,
build_recovery_performance_chart_payload,
build_weight_energy_correlation_chart_payload,
)
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
from data_layer.utils import safe_float
def _take_kpis(tiles: Any, max_n: int = 4) -> List[Dict[str, Any]]:
if not isinstance(tiles, list):
return []
out: List[Dict[str, Any]] = []
for t in tiles[:max_n]:
if not isinstance(t, dict):
continue
out.append(
{
"key": t.get("key"),
"category": t.get("category"),
"icon": t.get("icon"),
"value": t.get("value"),
"sublabel": t.get("sublabel"),
"status": t.get("status"),
"verdict": t.get("verdict"),
}
)
return out
def _short_body_interpretation_tiles(tiles: Any, max_n: int = 3) -> List[Dict[str, Any]]:
"""Körper-Interpretationskacheln (keine KPI-Kacheln)."""
if not isinstance(tiles, list):
return []
out: List[Dict[str, Any]] = []
for t in tiles[:max_n]:
if not isinstance(t, dict):
continue
det = str(t.get("detail") or "")
if len(det) > 140:
det = det[:137] + "…"
out.append(
{
"title": t.get("title") or t.get("category") or "Hinweis",
"detail": det,
"status": t.get("status"),
}
)
return out
def _take_insights(items: Any, max_n: int = 2) -> List[Dict[str, Any]]:
if not isinstance(items, list):
return []
out: List[Dict[str, Any]] = []
for it in items[:max_n]:
if not isinstance(it, dict):
continue
out.append(
{
"title": it.get("title") or it.get("title_de"),
"body": it.get("body") or it.get("detail") or it.get("message"),
"tone": it.get("tone") or it.get("status"),
}
)
return out
def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
"""
Kompakte Übersicht für den ersten Reiter «Gesamtansicht»: KPI-Kurzformen + Lag-Korrelationen (C1–C4).
"""
eff = max(7, min(int(days), 9999))
body = get_body_history_viz_bundle(profile_id, eff)
nutr = get_nutrition_history_viz_bundle(profile_id, eff)
fit = get_fitness_dashboard_viz_bundle(profile_id, eff)
rec = get_recovery_dashboard_viz_bundle(profile_id, eff)
c1 = calculate_lag_correlation(profile_id, "energy_balance", "weight", 14)
c2 = calculate_lag_correlation(profile_id, "protein", "lbm", 14)
c3_hrv = calculate_lag_correlation(profile_id, "load", "hrv", 14)
c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14)
c3 = None
if c3_hrv and c3_rhr:
a1 = abs(safe_float(c3_hrv.get("correlation"), 0.0))
a2 = abs(safe_float(c3_rhr.get("correlation"), 0.0))
c3 = c3_hrv if a1 >= a2 else c3_rhr
if c3 is c3_hrv:
c3 = dict(c3)
c3["metric"] = "HRV"
else:
c3 = dict(c3_rhr)
c3["metric"] = "RHR"
elif c3_hrv:
c3 = dict(c3_hrv)
c3["metric"] = "HRV"
elif c3_rhr:
c3 = dict(c3_rhr)
c3["metric"] = "RHR"
drivers = calculate_top_drivers(profile_id)
b_sum = body.get("summary") if isinstance(body.get("summary"), dict) else {}
last_w = b_sum.get("weight_kg")
fs = fit.get("summary") if isinstance(fit.get("summary"), dict) else {}
if fit.get("has_activity_entries"):
ac = int(fs.get("activity_count") or 0)
fitness_line = f"{ac} Trainingseinheiten im gewählten Fenster"
else:
fitness_line = fit.get("message") or "Keine Trainingsdaten"
drv_list = drivers if isinstance(drivers, list) else []
return {
"days_requested": days,
"effective_window_days": eff,
"confidence": _overview_confidence(body, nutr, fit, rec),
"sections": [
{
"id": "body",
"title": "Körper",
"tab_id": "body",
"summary_line": (
f"Letztes Gewicht: {last_w} kg"
if last_w is not None
else "Keine Gewichtsdaten im Fenster"
),
"interpretation_short": _short_body_interpretation_tiles(body.get("interpretation_tiles"), 3),
},
{
"id": "nutrition",
"title": "Ernährung",
"tab_id": "nutrition",
"summary_line": (
f"Ø {round(float((nutr.get('summary') or {}).get('kcal_avg') or 0))} kcal/Tag"
if nutr.get("has_nutrition_entries")
else (nutr.get("message") or "Keine Ernährungsdaten")
),
"kpi_short": _take_kpis(nutr.get("kpi_tiles"), 4),
"heuristic_short": (nutr.get("nutrition_correlation_heuristics") or [])[:2],
},
{
"id": "fitness",
"title": "Fitness",
"tab_id": "activity",
"summary_line": fitness_line,
"kpi_short": _take_kpis(fit.get("kpi_tiles"), 4),
"insights_short": _take_insights(fit.get("progress_insights"), 2),
},
{
"id": "recovery",
"title": "Erholung",
"tab_id": "activity",
"summary_line": "Schlaf & Vitalwerte"
if rec.get("has_recovery_data")
else (rec.get("message") or "Keine Erholungsdaten"),
"kpi_short": _take_kpis(rec.get("kpi_tiles"), 4),
"insights_short": _take_insights(rec.get("progress_insights"), 2),
},
],
"lag_correlations": {
"weight_energy": _compact_lag("C1 Energiebilanz ↔ Gewicht", c1),
"protein_lbm": _compact_lag("C2 Protein ↔ Magermasse", c2),
"load_vitals": _compact_lag(
f"C3 Last ↔ {(c3 or {}).get('metric') or 'Vital'}",
c3,
extra_keys=("metric",),
),
"recovery_performance": {
"label": "C4 Top-Treiber (Einflussfaktoren)",
"drivers": drv_list[:8],
},
},
"chart_payloads": {
"c1_weight_energy": build_weight_energy_correlation_chart_payload(profile_id, 14),
"c2_protein_lbm": build_lbm_protein_correlation_chart_payload(profile_id, 14),
"c3_load_vitals": build_load_vitals_correlation_chart_payload(profile_id, 14),
"c4_recovery_performance": build_recovery_performance_chart_payload(profile_id),
},
"meta": {
"layer_1": "composed_metrics",
"layer_2b": "history_overview_viz",
"issue": "53-history-overview",
"sources": {
"body": "body_viz",
"nutrition": "nutrition_viz",
"fitness": "fitness_viz",
"recovery": "recovery_viz",
"lag": "correlations.calculate_lag_correlation",
"drivers": "correlations.calculate_top_drivers",
},
},
}
def _overview_confidence(b: Dict, n: Dict, f: Dict, r: Dict) -> str:
scores = []
for x in (b, n, f, r):
c = x.get("confidence")
if c == "high":
scores.append(3)
elif c == "medium":
scores.append(2)
elif c == "low":
scores.append(1)
else:
scores.append(0)
s = sum(scores) / max(len(scores), 1)
if s >= 2.5:
return "high"
if s >= 1.5:
return "medium"
return "low"
def _compact_lag(
label: str,
payload: Optional[Dict[str, Any]],
extra_keys: tuple = (),
) -> Dict[str, Any]:
if not payload:
return {"label": label, "available": False}
out: Dict[str, Any] = {
"label": label,
"available": payload.get("correlation") is not None,
"correlation": payload.get("correlation"),
"best_lag_days": payload.get("best_lag_days", payload.get("best_lag")),
"confidence": payload.get("confidence"),
"interpretation": payload.get("interpretation", ""),
"data_points": payload.get("data_points"),
}
for k in extra_keys:
if k in payload:
out[k] = payload[k]
return out

View File

@ -0,0 +1,85 @@
"""
Layer 1 Hilfslogik: Ernährung + Gewicht + Caliper (forward-filled Magermasse).
Genutzt von Layer 2b (nutrition_viz) und vom Router GET /api/nutrition/correlations.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from db import get_db, get_cursor, r2d
from caliper_composition import as_date, compute_lean_fat_kg, nearest_weight_kg_from_map
def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, Any]]:
"""
Pro Kalendertag: Makros aus nutrition_log, Gewicht, forward-filled Caliper (lean_mass, bf%).
Gleiche Semantik wie bisher ``GET /api/nutrition/correlations``.
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,))
nutr: Dict[Any, Dict[str, Any]] = {}
for r in cur.fetchall():
rd = r2d(r)
dk = as_date(rd.get("date"))
if dk is not None:
nutr[dk] = rd
cur.execute("SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,))
wlog: Dict[Any, Any] = {}
for r in cur.fetchall():
rd = r2d(r)
dk = as_date(rd.get("date"))
if dk is not None:
wlog[dk] = rd["weight"]
cur.execute(
"SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",
(profile_id,),
)
cals = [r2d(r) for r in cur.fetchall()]
cals = sorted(
[c for c in cals if as_date(c.get("date")) is not None],
key=lambda x: as_date(x["date"]),
)
# Alle Keys sind datetime.date — vermeidet TypeError bei Vergleichen (str vs date)
all_dates = sorted(set(nutr.keys()) | set(wlog.keys()))
mi = 0
last_cal: Dict[str, Any] = {}
cal_by_date: Dict[Any, Dict[str, Any]] = {}
for d in all_dates:
while mi < len(cals):
cd = as_date(cals[mi].get("date"))
if cd is None:
mi += 1
continue
if cd > d:
break
last_cal = cals[mi]
mi += 1
if last_cal:
cal_by_date[d] = last_cal
result: List[Dict[str, Any]] = []
for d in all_dates:
if d not in nutr and d not in wlog:
continue
row: Dict[str, Any] = {"date": d}
if d in nutr:
for k in ("kcal", "protein_g", "fat_g", "carbs_g"):
v = nutr[d].get(k)
row[k] = float(v) if v is not None else None
if d in wlog:
row["weight"] = float(wlog[d])
if d in cal_by_date:
lm = cal_by_date[d].get("lean_mass")
bf = cal_by_date[d].get("body_fat_pct")
if bf is not None and lm is None:
wkg = nearest_weight_kg_from_map(wlog, d)
if wkg is not None:
lm, _fat = compute_lean_fat_kg(wkg, float(bf))
row["lean_mass"] = float(lm) if lm is not None else None
row["body_fat_pct"] = float(bf) if bf is not None else None
result.append(row)
return result

View File

@ -0,0 +1,404 @@
"""
Chart.js-kompatible Payloads für Ernährungs-Charts (E1, E2, E4).
Gleiche Logik wie ``routers/charts.py`` — hier zentral, damit ``nutrition_viz``
und die API dieselbe Berechnung nutzen (Phase C, Issue 53).
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict
from db import get_db, get_cursor
from data_layer.nutrition_metrics import (
get_energy_balance_data,
get_protein_adequacy_data,
get_protein_targets_data,
)
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
def build_energy_balance_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""E1 Energiebilanz — identisch zu GET /api/charts/energy-balance."""
balance_meta = get_energy_balance_data(profile_id, days)
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cur.execute(
"""SELECT date, SUM(kcal)::float AS kcal
FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
GROUP BY date
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows or len(rows) < 3:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": len(rows) if rows else 0,
"message": "Nicht genug Ernährungsdaten (min. 3 Tage)",
},
}
estimated_tdee = balance_meta.get("estimated_tdee") or 0
if estimated_tdee <= 0:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": len(rows),
"message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)",
},
}
labels = []
daily_values = []
avg_7d = []
avg_14d = []
for i, row in enumerate(rows):
labels.append(row["date"].isoformat())
daily_values.append(safe_float(row["kcal"]))
start_7d = max(0, i - 6)
window_7d = [safe_float(rows[j]["kcal"]) for j in range(start_7d, i + 1)]
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
start_14d = max(0, i - 13)
window_14d = [safe_float(rows[j]["kcal"]) for j in range(start_14d, i + 1)]
avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None)
avg_intake = float(
balance_meta.get("avg_intake")
or (sum(daily_values) / len(daily_values) if daily_values else 0)
)
energy_balance = float(
balance_meta.get("energy_balance") or (avg_intake - estimated_tdee)
)
balance_status = balance_meta.get("status") or (
"deficit"
if energy_balance < -200
else "surplus"
if energy_balance > 200
else "maintenance"
)
datasets = [
{
"label": "Kalorien (täglich)",
"data": daily_values,
"borderColor": "#1D9E7599",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 1.5,
"tension": 0.2,
"fill": False,
"pointRadius": 2,
},
{
"label": "Ø 7 Tage",
"data": avg_7d,
"borderColor": "#1D9E75",
"borderWidth": 2.5,
"tension": 0.3,
"fill": False,
"pointRadius": 0,
},
{
"label": "Ø 14 Tage",
"data": avg_14d,
"borderColor": "#085041",
"borderWidth": 2,
"tension": 0.3,
"fill": False,
"pointRadius": 0,
"borderDash": [6, 3],
},
{
"label": "TDEE (geschätzt)",
"data": [estimated_tdee] * len(labels),
"borderColor": "#888",
"borderWidth": 1,
"borderDash": [5, 5],
"fill": False,
"pointRadius": 0,
},
]
confidence = balance_meta.get("confidence") or "low"
return {
"chart_type": "line",
"data": {"labels": labels, "datasets": datasets},
"metadata": serialize_dates(
{
"confidence": confidence,
"data_points": len(rows),
"avg_kcal": round(avg_intake, 1),
"estimated_tdee": estimated_tdee,
"energy_balance": round(energy_balance, 1),
"balance_status": balance_status,
"first_date": rows[0]["date"],
"last_date": rows[-1]["date"],
}
),
}
def build_protein_adequacy_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""E2 Protein Adequacy — identisch zu GET /api/charts/protein-adequacy."""
targets = get_protein_targets_data(profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cur.execute(
"""SELECT date, SUM(protein_g)::float AS protein_g
FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL
GROUP BY date
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows or len(rows) < 3:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": len(rows) if rows else 0,
"message": "Nicht genug Protein-Daten (min. 3 Tage)",
},
}
labels = []
daily_values = []
avg_7d = []
avg_28d = []
for i, row in enumerate(rows):
labels.append(row["date"].isoformat())
daily_values.append(safe_float(row["protein_g"]))
start_7d = max(0, i - 6)
window_7d = [safe_float(rows[j]["protein_g"]) for j in range(start_7d, i + 1)]
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
start_28d = max(0, i - 27)
window_28d = [safe_float(rows[j]["protein_g"]) for j in range(start_28d, i + 1)]
avg_28d.append(round(sum(window_28d) / len(window_28d), 1) if window_28d else None)
target_low = targets["protein_target_low"]
target_high = targets["protein_target_high"]
datasets = [
{
"label": "Protein (täglich)",
"data": daily_values,
"borderColor": "#1D9E7599",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 1.5,
"tension": 0.2,
"fill": False,
"pointRadius": 2,
},
{
"label": "Ø 7 Tage",
"data": avg_7d,
"borderColor": "#1D9E75",
"borderWidth": 2.5,
"tension": 0.3,
"fill": False,
"pointRadius": 0,
},
{
"label": "Ø 28 Tage",
"data": avg_28d,
"borderColor": "#085041",
"borderWidth": 2,
"tension": 0.3,
"fill": False,
"pointRadius": 0,
"borderDash": [6, 3],
},
{
"label": "Ziel Min",
"data": [target_low] * len(labels),
"borderColor": "#888",
"borderWidth": 1,
"borderDash": [5, 5],
"fill": False,
"pointRadius": 0,
},
]
datasets.append(
{
"label": "Ziel Max",
"data": [target_high] * len(labels),
"borderColor": "#888",
"borderWidth": 1,
"borderDash": [5, 5],
"fill": False,
"pointRadius": 0,
}
)
confidence = calculate_confidence(len(rows), days, "general")
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
return {
"chart_type": "line",
"data": {"labels": labels, "datasets": datasets},
"metadata": serialize_dates(
{
"confidence": confidence,
"data_points": len(rows),
"target_low": round(target_low, 1),
"target_high": round(target_high, 1),
"days_in_target": days_in_target,
"target_compliance_pct": round(
days_in_target / len(daily_values) * 100, 1
)
if daily_values
else 0,
"first_date": rows[0]["date"],
"last_date": rows[-1]["date"],
}
),
}
def build_nutrition_adherence_score_payload(profile_id: str, days: int) -> Dict[str, Any]:
"""E4 Adhärenz — identisch zu GET /api/charts/nutrition-adherence-score."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,))
profile_row = cur.fetchone()
goal_mode = (
profile_row["goal_mode"]
if profile_row and profile_row["goal_mode"]
else "health"
)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cur.execute(
"""WITH daily AS (
SELECT date,
COALESCE(SUM(kcal), 0)::float AS dk,
COALESCE(SUM(protein_g), 0)::float AS dp,
COALESCE(SUM(carbs_g), 0)::float AS dc,
COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
GROUP BY date
)
SELECT COUNT(*)::int AS cnt,
AVG(dk) AS avg_kcal,
STDDEV(dk) AS std_kcal,
AVG(dp) AS avg_protein,
AVG(dc) AS avg_carbs,
AVG(df) AS avg_fat
FROM daily""",
(profile_id, cutoff),
)
stats = cur.fetchone()
if not stats or stats["cnt"] < 7:
return {
"score": 0,
"components": {},
"metadata": {
"confidence": "insufficient",
"message": "Nicht genug Daten (min. 7 Tage)",
},
}
protein_data = get_protein_adequacy_data(profile_id, days)
calorie_adherence = 70.0
protein_adequacy_pct = protein_data.get("adequacy_score", 0)
protein_adherence = min(100, protein_adequacy_pct)
kcal_cv = (
(safe_float(stats["std_kcal"]) / safe_float(stats["avg_kcal"]) * 100)
if safe_float(stats["avg_kcal"]) > 0
else 100
)
intake_consistency = max(0, 100 - kcal_cv)
food_quality = 60.0
if goal_mode == "weight_loss":
weights = {
"calorie": 0.35,
"protein": 0.25,
"consistency": 0.20,
"quality": 0.20,
}
elif goal_mode == "strength":
weights = {
"calorie": 0.25,
"protein": 0.35,
"consistency": 0.20,
"quality": 0.20,
}
elif goal_mode == "endurance":
weights = {
"calorie": 0.30,
"protein": 0.20,
"consistency": 0.20,
"quality": 0.30,
}
else:
weights = {
"calorie": 0.25,
"protein": 0.25,
"consistency": 0.25,
"quality": 0.25,
}
final_score = (
calorie_adherence * weights["calorie"]
+ protein_adherence * weights["protein"]
+ intake_consistency * weights["consistency"]
+ food_quality * weights["quality"]
)
components = {
"calorie_adherence": round(calorie_adherence, 1),
"protein_adherence": round(protein_adherence, 1),
"intake_consistency": round(intake_consistency, 1),
"food_quality": round(food_quality, 1),
}
weak_areas = [k for k, v in components.items() if v < 60]
if weak_areas:
recommendation = f"Verbesserungspotenzial: {', '.join(weak_areas)}"
else:
recommendation = "Gute Adhärenz, weiter so!"
return {
"score": round(final_score, 1),
"components": components,
"goal_mode": goal_mode,
"weights": weights,
"recommendation": recommendation,
"metadata": {
"confidence": calculate_confidence(stats["cnt"], days, "general"),
"data_points": stats["cnt"],
"days_analyzed": days,
},
}

View File

@ -0,0 +1,323 @@
"""
Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf.
Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert
für KpiTilesOverview (keys = related_placeholder_keys).
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _verdict(status: str) -> str:
if status == "good":
return "Gut"
if status == "warn":
return "Hinweis"
return "Achtung"
def build_nutrition_history_kpi_tiles(
navg: Dict[str, Any],
targets: Dict[str, Any],
date_span_label: str,
n_days_with_entries: int,
) -> List[Dict[str, Any]]:
"""
KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln).
"""
kcal_avg = round(float(navg.get("kcal_avg") or 0))
avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10
avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10
avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10
pt_low = round(float(targets.get("protein_target_low") or 0))
pt_high = round(float(targets.get("protein_target_high") or 0))
targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0
protein_ok = targets_ok and avg_protein >= pt_low
total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9
prot_pct = (
round(avg_protein * 4 / total_macro_kcal * 100)
if total_macro_kcal > 0
else 0
)
kh_pct = (
round(avg_carbs * 4 / total_macro_kcal * 100)
if total_macro_kcal > 0
else 0
)
fat_pct = (
round(avg_fat * 9 / total_macro_kcal * 100)
if total_macro_kcal > 0
else 0
)
tiles: List[Dict[str, Any]] = [
{
"key": "kcal",
"category": "Kalorien (Ø)",
"icon": "🔥",
"value": f"{kcal_avg} kcal",
"sublabel": date_span_label,
"status": "good",
"verdict": "Gut",
"hoverTop": "Durchschnittliche tägliche Energie",
"hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.",
"keys": ["nutrition_score"],
},
{
"key": "carbs",
"category": "KH (Ø)",
"icon": "🌾",
"value": f"{avg_carbs} g",
"sublabel": "Kohlenhydrate / Tag",
"status": "good",
"verdict": "Gut",
"hoverTop": "Durchschnittliche Kohlenhydrate",
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
"keys": ["nutrition_summary"],
},
{
"key": "fat",
"category": "Fett (Ø)",
"icon": "🧈",
"value": f"{avg_fat} g",
"sublabel": "Fett / Tag",
"status": "good",
"verdict": "Gut",
"hoverTop": "Durchschnittliches Fett",
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
"keys": ["nutrition_summary"],
},
]
if not targets_ok:
tiles.append(
{
"key": "eval-protein",
"category": "Protein",
"icon": "🥩",
"value": f"{avg_protein}g",
"sublabel": "Referenzgewicht fehlt",
"status": "warn",
"verdict": _verdict("warn"),
"hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.",
"hoverTop": "Protein-Ziel nicht berechenbar",
"hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.",
"keys": ["protein_adequacy"],
}
)
elif not protein_ok:
miss = max(0, pt_low - round(avg_protein))
tiles.append(
{
"key": "eval-protein",
"category": "Protein",
"icon": "🥩",
"value": f"{avg_protein}g",
"sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
"status": "bad",
"verdict": _verdict("bad"),
"hint": (
f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet."
),
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
"hoverBody": (
f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. "
"Konsequenz: Muskelverlust bei Defizit."
),
"keys": ["protein_adequacy", "nutrition_score"],
}
)
else:
tiles.append(
{
"key": "eval-protein",
"category": "Protein",
"icon": "🥩",
"value": f"{avg_protein}g",
"sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
"status": "good",
"verdict": _verdict("good"),
"hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
"hoverBody": "Ausreichend für Muskelerhalt und -aufbau.",
"keys": ["protein_adequacy", "nutrition_score"],
}
)
if prot_pct < 20 and total_macro_kcal > 0:
tiles.append(
{
"key": "eval-macro-pct",
"category": "Makro-Anteil",
"icon": "📊",
"value": f"{prot_pct}%",
"sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
"status": "warn",
"verdict": _verdict("warn"),
"hint": (
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
"Ziel oft 25–35 %."
),
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
"hoverBody": (
f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F"
),
"keys": ["nutrition_summary"],
}
)
return tiles
def build_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning."""
level = str(ea.get("warning_level") or "none").strip().lower()
if level == "none":
return None
triggers: List[str] = list(ea.get("triggers") or [])
msg = str(ea.get("message") or "").strip()
st = "bad" if level == "warning" else "warn"
first = triggers[0] if triggers else msg
if len(first) > 90:
first = first[:87] + "…"
meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {}
note = str(meta.get("note") or "")
hover_lines = [msg] + [f"• {t}" for t in triggers]
if note:
hover_lines.append(note)
return {
"key": "energy-availability-e5",
"category": "Energieverfügbarkeit",
"icon": "⚡",
"value": "Achtung" if level == "warning" else "Hinweis",
"sublabel": first or "Signale prüfen",
"status": st,
"verdict": _verdict(st),
"hint": msg,
"hoverTop": "Energieverfügbarkeit (Heuristik)",
"hoverBody": "\n".join(hover_lines),
"keys": ["nutrition_score"],
}
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
"""Anteile in % der Makro-kcal + Gramm für Legende."""
p = float(navg.get("protein_avg") or 0)
c = float(navg.get("carbs_avg") or 0)
f = float(navg.get("fat_avg") or 0)
pkcal, ckcal, fkcal = p * 4, c * 4, f * 9
tot = pkcal + ckcal + fkcal
if tot <= 0:
return None
return [
{"name": "Protein", "value": round(pkcal / tot * 100), "color": "#4a8f72", "grams": round(p, 1)},
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)},
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)},
]
def build_nutrition_correlation_heuristic_items(
merged_rows: List[Dict[str, Any]],
tdee_kcal: float,
protein_target_low_g: float,
) -> List[Dict[str, Any]]:
"""
Heuristische Kurz-Aussagen (vormals Reiter «Korrelation») — gleiche Logik wie History.jsx,
TDEE aber aus Data-Layer (nutrition_metrics / estimate_tdee), nicht ×1,4 im Frontend.
"""
filtered = [
r
for r in merged_rows
if r.get("kcal") is not None and r.get("weight") is not None
]
if len(filtered) < 5:
return []
td = float(tdee_kcal)
latest_w = float(filtered[-1].get("weight") or 0) or 80.0
pt_low = round(float(protein_target_low_g or 0)) or max(1, round(latest_w * 1.6))
items: List[Dict[str, Any]] = []
if len(filtered) >= 14:
high_k = [d for d in filtered if float(d.get("kcal") or 0) > td + 200]
low_k = [d for d in filtered if float(d.get("kcal") or 0) < td - 200]
if len(high_k) >= 3 and len(low_k) >= 3:
avg_wh = sum(float(d["weight"]) for d in high_k) / len(high_k)
avg_wl = sum(float(d["weight"]) for d in low_k) / len(low_k)
avg_wh_r = round(avg_wh * 10) / 10
avg_wl_r = round(avg_wl * 10) / 10
items.append(
{
"icon": "📊",
"status": "good" if avg_wl < avg_wh else "warn",
"title": (
f"Kalorienreduktion wirkt: Ø {avg_wl_r} kg bei Defizit vs. {avg_wh_r} kg bei Überschuss"
if avg_wl < avg_wh
else "Kein klarer Kalorieneffekt auf Gewicht erkennbar"
),
"detail": (
f"Tage mit Überschuss (>{int(td + 200)} kcal): Ø {avg_wh_r} kg · "
f"Tage mit Defizit (<{int(td - 200)} kcal): Ø {avg_wl_r} kg"
),
}
)
prot_vs_lean = [
d
for d in filtered
if d.get("protein_g") is not None and d.get("lean_mass") is not None
]
if len(prot_vs_lean) >= 3:
high_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) >= pt_low]
low_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) < pt_low]
if len(high_p) >= 2 and len(low_p) >= 2:
avg_lh = sum(float(d["lean_mass"]) for d in high_p) / len(high_p)
avg_ll = sum(float(d["lean_mass"]) for d in low_p) / len(low_p)
avg_lh_r = round(avg_lh * 10) / 10
avg_ll_r = round(avg_ll * 10) / 10
items.append(
{
"icon": "🥩",
"status": "good" if avg_lh >= avg_ll else "warn",
"title": (
f"Hohe Proteinzufuhr (≥{pt_low} g): Ø {avg_lh_r} kg Mager · Niedrig: Ø {avg_ll_r} kg"
),
"detail": (
f"{len(high_p)} Messpunkte mit hoher vs. {len(low_p)} mit niedriger Proteinzufuhr verglichen."
),
}
)
balances = [float(d["kcal"]) - td for d in filtered if d.get("kcal") is not None]
avg_balance = int(round(sum(balances) / len(balances))) if balances else 0
ab_s = f"{avg_balance:+d}" if avg_balance > 0 else str(avg_balance)
if avg_balance < -100:
ic, st = "✅", "good"
elif avg_balance > 200:
ic, st = "⬆️", "warn" if avg_balance > 300 else "good"
else:
ic, st = "➡️", "good"
if avg_balance < -500:
bal_detail = "Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen."
elif avg_balance < -100:
bal_detail = "Moderates Defizit – ideal für Fettabbau bei Muskelerhalt."
elif avg_balance > 300:
bal_detail = "Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich."
else:
bal_detail = "Nahezu ausgeglichen – Gewicht sollte stabil bleiben."
items.append(
{
"icon": ic,
"status": st,
"title": f"Ø Kalorienbilanz: {ab_s} kcal/Tag",
"detail": f"Geschätzter TDEE: {int(round(td))} kcal (Data-Layer, konsistent mit Verlauf). {bal_detail}",
}
)
return items

View File

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

View File

@ -20,6 +20,7 @@ Phase 0c: Multi-Layer Architecture
Version: 1.0
"""
import statistics
from typing import Dict, List, Optional
from datetime import datetime, timedelta, date
from db import get_db, get_cursor, r2d
@ -110,7 +111,9 @@ def _get_profile_goal_mode(profile_id: str) -> str:
def get_nutrition_average_data(
profile_id: str,
days: int = 30
days: int = 30,
*,
all_history: bool = False,
) -> Dict:
"""
Get average nutrition values for all macros.
@ -136,11 +139,18 @@ def get_nutrition_average_data(
"""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
# Mean over calendar days (per-day sums), not over raw log rows.
if cutoff:
inner_where = "WHERE profile_id=%s AND date >= %s"
params = (profile_id, cutoff)
else:
inner_where = "WHERE profile_id=%s"
params = (profile_id,)
cur.execute(
"""SELECT
f"""SELECT
AVG(daily_kcal) AS kcal_avg,
AVG(daily_protein) AS protein_avg,
AVG(daily_carbs) AS carbs_avg,
@ -153,10 +163,10 @@ def get_nutrition_average_data(
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
COALESCE(SUM(fat_g), 0)::float AS daily_fat
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
{inner_where}
GROUP BY date
) AS daily""",
(profile_id, cutoff),
params,
)
row = cur.fetchone()
@ -494,8 +504,6 @@ def get_macro_consistency_data(
"data_points": len(rows)
}
import statistics
protein_pcts = []
carbs_pcts = []
fat_pcts = []
@ -561,6 +569,200 @@ def get_macro_consistency_data(
}
def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict:
"""
Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution.
"""
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT date, protein_g, carbs_g, fat_g, kcal
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
AND fat_g IS NOT NULL AND kcal > 0
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows or len(rows) < 7:
return {
"chart_type": "bar",
"data": {
"labels": [],
"datasets": [],
},
"metadata": {
"confidence": "insufficient",
"data_points": len(rows) if rows else 0,
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)",
},
}
weekly_data: Dict[str, Dict[str, List[float]]] = {}
for row in rows:
date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"]))
iso_week = date_obj.strftime("%Y-W%V")
if iso_week not in weekly_data:
weekly_data[iso_week] = {
"protein": [],
"carbs": [],
"fat": [],
"kcal": [],
}
weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"]))
weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"]))
weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"]))
weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"]))
labels: List[str] = []
protein_pcts: List[float] = []
carbs_pcts: List[float] = []
fat_pcts: List[float] = []
for iso_week in sorted(weekly_data.keys())[-weeks:]:
data = weekly_data[iso_week]
avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0
avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0
avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0
protein_kcal = avg_protein * 4
carbs_kcal = avg_carbs * 4
fat_kcal = avg_fat * 9
total_kcal = protein_kcal + carbs_kcal + fat_kcal
if total_kcal > 0:
labels.append(f"KW {iso_week[-2:]}")
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
protein_cv = (
statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100
if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0
else 0
)
carbs_cv = (
statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100
if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0
else 0
)
fat_cv = (
statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100
if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0
else 0
)
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Protein (%)",
"data": protein_pcts,
"backgroundColor": "#4a8f72",
"stack": "macro",
},
{
"label": "Kohlenhydrate (%)",
"data": carbs_pcts,
"backgroundColor": "#c17d45",
"stack": "macro",
},
{
"label": "Fett (%)",
"data": fat_pcts,
"backgroundColor": "#6e8eb8",
"stack": "macro",
},
],
},
"metadata": {
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
"data_points": len(rows),
"weeks_analyzed": len(labels),
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
"protein_cv": round(protein_cv, 1),
"carbs_cv": round(carbs_cv, 1),
"fat_cv": round(fat_cv, 1),
},
}
def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict:
"""
E5 Energieverfügbarkeit — gleiche Heuristik wie GET /charts/energy-availability-warning.
"""
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
from data_layer.body_metrics import calculate_lbm_28d_change
triggers: List[str] = []
warning_level = "none"
energy_data = get_energy_balance_data(profile_id, days)
if energy_data.get("energy_balance", 0) < -500:
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
try:
recovery_score = calculate_recovery_score_v2(profile_id)
if recovery_score and recovery_score < 50:
triggers.append("Recovery Score niedrig (<50)")
except Exception:
pass
try:
sleep_quality = calculate_sleep_quality_7d(profile_id)
if sleep_quality and sleep_quality < 60:
triggers.append("Schlafqualität reduziert (<60%)")
except Exception:
pass
try:
lbm_change = calculate_lbm_28d_change(profile_id)
if lbm_change and lbm_change < -1.0:
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
except Exception:
pass
if len(triggers) >= 3:
warning_level = "warning"
message = (
"⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. "
"Erwäge Defizit-Anpassung oder Regenerationswoche."
)
elif len(triggers) >= 2:
warning_level = "caution"
message = (
"⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
)
elif len(triggers) >= 1:
warning_level = "caution"
message = "💡 Ein Indikator auffällig. Weiter beobachten."
else:
message = "✅ Energieverfügbarkeit unauffällig."
return {
"warning_level": warning_level,
"triggers": triggers,
"message": message,
"metadata": {
"days_analyzed": days,
"trigger_count": len(triggers),
"note": "Heuristische Einschätzung, keine medizinische Diagnose",
},
}
# ============================================================================
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
# ============================================================================

View File

@ -0,0 +1,393 @@
"""
Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53).
Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional
from db import get_db, get_cursor, r2d
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
from data_layer.nutrition_interpretation import (
build_energy_availability_kpi_tile,
build_macro_donut_from_averages,
build_nutrition_correlation_heuristic_items,
build_nutrition_history_kpi_tiles,
)
from data_layer.nutrition_chart_payloads import (
build_energy_balance_chart_payload,
build_nutrition_adherence_score_payload,
build_protein_adequacy_chart_payload,
)
from data_layer.nutrition_metrics import (
estimate_tdee_kcal_from_latest_weight,
get_energy_availability_warning_payload,
get_energy_balance_data,
get_nutrition_average_data,
get_protein_targets_data,
get_weekly_macro_distribution_chart_data,
)
from data_layer.utils import safe_float
def _cutoff_sql(days: int) -> Optional[str]:
if days >= 9999:
return None
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
def _iso(d: Any) -> Optional[str]:
if d is None:
return None
if hasattr(d, "isoformat"):
return d.isoformat()[:10]
return str(d)[:10]
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for i, d in enumerate(rows):
sl = rows[max(0, i - window + 1) : i + 1]
vals: List[float] = []
for x in sl:
v = safe_float(x.get(key))
if v is not None:
vals.append(v)
if not vals:
out.append({**d, f"{key}_avg": None})
continue
avg = round(sum(vals) / len(vals), 1)
out.append({**d, f"{key}_avg": avg})
return out
def _has_nutrition_entries(profile_id: str) -> bool:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1",
(profile_id,),
)
return cur.fetchone() is not None
def _last_nutrition_date(profile_id: str) -> Optional[str]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s",
(profile_id,),
)
row = cur.fetchone()
if not row or row["d"] is None:
return None
return _iso(row["d"])
def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]:
with get_db() as conn:
cur = get_cursor(conn)
if cutoff:
cur.execute(
"""SELECT date,
COALESCE(SUM(kcal), 0)::float AS kcal,
COALESCE(SUM(protein_g), 0)::float AS protein_g,
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
COALESCE(SUM(fat_g), 0)::float AS fat_g
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
GROUP BY date
ORDER BY date ASC""",
(profile_id, cutoff),
)
else:
cur.execute(
"""SELECT date,
COALESCE(SUM(kcal), 0)::float AS kcal,
COALESCE(SUM(protein_g), 0)::float AS protein_g,
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
COALESCE(SUM(fat_g), 0)::float AS fat_g
FROM nutrition_log
WHERE profile_id=%s
GROUP BY date
ORDER BY date ASC""",
(profile_id,),
)
return [r2d(r) for r in cur.fetchall()]
def _filter_merged_rows_by_cutoff(
merged: List[Dict[str, Any]], cutoff: Optional[str]
) -> List[Dict[str, Any]]:
if not cutoff:
return list(merged)
return [r for r in merged if str(r.get("date"))[:10] >= cutoff]
def _calorie_balance_daily_series(
merged_filtered: List[Dict[str, Any]], tdee: float
) -> List[Dict[str, Any]]:
"""Tagesbilanz (Aufnahme − TDEE) + 7-Tage-Mittel der Bilanz — gleiche TDEE-Quelle wie kcal_vs_weight."""
rows: List[Dict[str, Any]] = []
for r in merged_filtered:
if r.get("kcal") is None:
continue
ds = _iso(r.get("date"))
if not ds:
continue
bal = round(float(r["kcal"]) - float(tdee))
rows.append({"date": ds, "balance_kcal": bal})
rolled = _rolling_avg([dict(x) for x in rows], "balance_kcal", 7)
out: List[Dict[str, Any]] = []
for x in rolled:
out.append(
{
"date": x["date"],
"balance_kcal": x.get("balance_kcal"),
"balance_kcal_avg": x.get("balance_kcal_avg"),
}
)
return out
def _protein_lean_mass_points(merged_filtered: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for r in merged_filtered:
if r.get("protein_g") is None or r.get("lean_mass") is None:
continue
ds = _iso(r.get("date"))
if not ds:
continue
out.append(
{
"date": ds,
"protein_g": round(safe_float(r.get("protein_g")) or 0, 1),
"lean_mass_kg": round(safe_float(r.get("lean_mass")) or 0, 2),
}
)
return out
def _kcal_weight_points_for_window(
profile_id: str, cutoff: Optional[str]
) -> List[Dict[str, Any]]:
"""Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert."""
with get_db() as conn:
cur = get_cursor(conn)
if cutoff:
cur.execute(
"""SELECT date, SUM(kcal)::float AS kcal
FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
GROUP BY date""",
(profile_id, cutoff),
)
else:
cur.execute(
"""SELECT date, SUM(kcal)::float AS kcal
FROM nutrition_log
WHERE profile_id=%s AND kcal IS NOT NULL
GROUP BY date""",
(profile_id,),
)
nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() }
if cutoff:
cur.execute(
"SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date",
(profile_id, cutoff),
)
else:
cur.execute(
"SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date",
(profile_id,),
)
wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None }
common = sorted(set(nk) & set(wk))
raw: List[Dict[str, Any]] = []
for ds in common:
raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]})
rolled = _rolling_avg(raw, "kcal", 7)
out: List[Dict[str, Any]] = []
for r in rolled:
out.append(
{
"date": r["date"],
"kcal": r.get("kcal"),
"weight": r.get("weight"),
"kcal_avg": r.get("kcal_avg"),
}
)
return out
def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
"""
Layer 2b Bundle für Verlauf «Ernährung».
days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen).
"""
if not _has_nutrition_entries(profile_id):
return {
"confidence": "insufficient",
"has_nutrition_entries": False,
"message": "Noch keine Ernährungsdaten",
"kpi_tiles": [],
"summary": {},
"daily_macros": [],
"donut_avg_pct": None,
"kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0},
"weekly_macro_chart": {},
"tdee_reference_kcal": None,
"energy_balance_meta": {},
"interpretation_tiles": [],
"energy_availability_warning": None,
"calorie_balance_daily": [],
"protein_vs_lean_mass": {"points": [], "protein_target_low_g": None},
"nutrition_correlation_heuristics": [],
"chart_payloads": {},
"chart_payloads_days": None,
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
}
all_history = days >= 9999
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
cutoff = _cutoff_sql(days)
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
targets = get_protein_targets_data(profile_id)
energy_days = eff_days if not all_history else min(9999, 3650)
energy_meta = get_energy_balance_data(profile_id, energy_days)
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
if tdee is None:
tdee = safe_float(energy_meta.get("estimated_tdee")) or None
else:
tdee = float(tdee)
daily_rows = _fetch_daily_macro_totals(profile_id, cutoff)
daily_macros: List[Dict[str, Any]] = []
for r in daily_rows:
daily_macros.append(
{
"date": _iso(r["date"]),
"kcal": round(safe_float(r.get("kcal")) or 0),
"Protein": round(safe_float(r.get("protein_g")) or 0),
"KH": round(safe_float(r.get("carbs_g")) or 0),
"Fett": round(safe_float(r.get("fat_g")) or 0),
}
)
date_span_label = ""
if daily_macros:
date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}"
n_days = int(navg.get("data_points") or 0)
kpi_tiles = build_nutrition_history_kpi_tiles(
navg, targets, date_span_label or "—", max(1, n_days)
)
ea_days = min(28, max(7, chart_days_for_pipeline))
ea_payload = get_energy_availability_warning_payload(profile_id, ea_days)
ea_tile = build_energy_availability_kpi_tile(ea_payload)
kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles)
if ea_tile:
kpi_tiles_out.append(ea_tile)
donut = build_macro_donut_from_averages(navg)
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
pt_low = round(float(targets.get("protein_target_low") or 0))
merged_all = build_merged_daily_nutrition_body_rows(profile_id)
merged_win = _filter_merged_rows_by_cutoff(merged_all, cutoff)
tdee_eff = float(tdee) if tdee is not None else float(safe_float(energy_meta.get("estimated_tdee")) or 0)
calorie_balance_daily: List[Dict[str, Any]] = (
_calorie_balance_daily_series(merged_win, tdee_eff) if tdee_eff > 0 else []
)
pl_points = _protein_lean_mass_points(merged_win)
nutrition_correlation_heuristics = (
build_nutrition_correlation_heuristic_items(merged_win, tdee_eff, float(pt_low))
if tdee_eff > 0
else []
)
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
# E1/E2/E4 Chart.js-Payloads — gleiche Funktionen wie /api/charts/* (kein zweiter HTTP-Roundtrip im Verlauf)
days_for_embedded_charts = max(7, min(int(chart_days_for_pipeline), 90))
chart_payloads = {
"energy_balance": build_energy_balance_chart_payload(
profile_id, days_for_embedded_charts
),
"protein_adequacy": build_protein_adequacy_chart_payload(
profile_id, days_for_embedded_charts
),
"nutrition_adherence": build_nutrition_adherence_score_payload(
profile_id, days_for_embedded_charts
),
}
conf = navg.get("confidence") or "medium"
if targets.get("confidence") == "insufficient":
conf = "insufficient"
return {
"confidence": conf,
"has_nutrition_entries": True,
"days_requested": days,
"effective_window_days": eff_days,
"nutrition_charts_days": chart_days_for_pipeline,
"weekly_macro_weeks_used": weeks_for_weekly,
"last_updated": _last_nutrition_date(profile_id),
"summary": {
"kcal_avg": navg.get("kcal_avg"),
"protein_avg": navg.get("protein_avg"),
"carbs_avg": navg.get("carbs_avg"),
"fat_avg": navg.get("fat_avg"),
"data_points": navg.get("data_points"),
"days_analyzed": navg.get("days_analyzed"),
"protein_target_low": targets.get("protein_target_low"),
"protein_target_high": targets.get("protein_target_high"),
"reference_weight_kg": targets.get("current_weight"),
},
"kpi_tiles": kpi_tiles_out,
"interpretation_tiles": [],
"energy_availability_warning": ea_payload,
"daily_macros": daily_macros,
"donut_avg_pct": donut,
"protein_reference_line_g": pt_low,
"kcal_vs_weight": {
"points": kw_points,
"tdee_reference_kcal": tdee,
"common_days_count": len(kw_points),
},
"weekly_macro_chart": weekly_chart,
"tdee_reference_kcal": tdee,
"energy_balance_meta": {
"energy_balance": energy_meta.get("energy_balance"),
"avg_intake": energy_meta.get("avg_intake"),
"estimated_tdee": energy_meta.get("estimated_tdee"),
"status": energy_meta.get("status"),
"confidence": energy_meta.get("confidence"),
"data_points": energy_meta.get("data_points"),
},
"calorie_balance_daily": calorie_balance_daily,
"protein_vs_lean_mass": {
"points": pl_points,
"protein_target_low_g": pt_low if pt_low > 0 else None,
},
"nutrition_correlation_heuristics": nutrition_correlation_heuristics,
"chart_payloads": chart_payloads,
"chart_payloads_days": days_for_embedded_charts,
"meta": {
"layer_1": "nutrition_metrics",
"layer_2b": "nutrition_viz",
"issue": "53-phase-0c",
},
}

View File

@ -0,0 +1,152 @@
"""
Kompakte Zahlen- und JSON-Aufbereitung für KI-Platzhalter (Token sparen).
- Floats: sinnvolle Nachkommastellen je nach Größenordnung (kleine Werte <0,1 mehr Präzision).
- ≥10 meist ganzzahlig; Prozent/Verhältnisse über denselben Mechanismus lesbar.
- Rekursiv auf dict/list-Strukturen vor json.dumps in _safe_json anwendbar.
Hinweis: numpy.float64 und numerische Strings (DB/API) sind keine ``float``-Instanzen —
diese werden explizit mit float() normalisiert.
"""
from __future__ import annotations
import math
import re
from decimal import Decimal
from typing import Any
def compact_float_for_prompt(x: float) -> float | int:
"""
Reduziert unnötige Nachkommastellen; erhält kleine Beträge (<0,1) mit mehr Stellen.
"""
if not math.isfinite(x):
return x
ax = abs(x)
if ax == 0.0:
return 0
if ax >= 100.0:
return int(round(x))
if ax >= 10.0:
return int(round(x))
if ax >= 1.0:
r = round(x, 2)
return int(r) if abs(r - int(round(r))) < 1e-6 else r
if ax >= 0.1:
r = round(x, 2)
return int(r) if abs(r - int(round(r))) < 1e-6 else r
if ax >= 0.01:
return round(x, 3)
return round(x, 4)
def normalize_prompt_number(x: Any) -> Any:
"""int/Decimal/float kompakt; numpy-Scalars; numerische Strings; sonst unverändert."""
if x is None:
return None
if isinstance(x, bool):
return x
if isinstance(x, int) and not isinstance(x, bool):
return x
if isinstance(x, str):
s = x.strip()
if not s:
return x
try:
if re.fullmatch(r"-?\d+", s):
return int(s)
xf = float(s)
except ValueError:
return x
if not math.isfinite(xf):
return x
return compact_float_for_prompt(xf)
if isinstance(x, Decimal):
try:
xf = float(x)
except Exception:
return x
if not math.isfinite(xf):
return x
return compact_float_for_prompt(xf)
if isinstance(x, float):
if not math.isfinite(x):
return x
return compact_float_for_prompt(x)
try:
xf = float(x)
except (TypeError, ValueError):
return x
if not math.isfinite(xf):
return x
return compact_float_for_prompt(xf)
def compact_json_payload_for_prompts(obj: Any) -> Any:
"""
Tiefe Kopie mit kompakten Zahlen (dicts/list/tuples rekursiv).
Strings und dict-Keys werden nicht verändert.
"""
if obj is None:
return None
if isinstance(obj, dict):
return {k: compact_json_payload_for_prompts(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
t = [compact_json_payload_for_prompts(v) for v in obj]
return tuple(t) if isinstance(obj, tuple) else t
return normalize_prompt_number(obj)
def format_scalar_for_prompt_text(x: Any) -> str:
"""
Kurzdarstellung für Text-Platzhalter (activity_detail, Tabellen, …).
Alle Zahlenpfade über normalize_prompt_number; Ausgabe kurz (%g, keine Float-Schweife).
"""
if x is None:
return "—"
if isinstance(x, bool):
return "ja" if x else "nein"
n = normalize_prompt_number(x)
if isinstance(n, bool):
return "ja" if n else "nein"
if isinstance(n, str):
return n
if isinstance(n, int) and not isinstance(n, bool):
return str(n)
if isinstance(n, float):
if not math.isfinite(n):
return str(n)
return "%g" % n
return str(n)
def session_metrics_list_to_key_value_compact(metrics: list[Any] | None) -> dict[str, Any]:
"""
Session-Metriken für KI-JSON: nur key → Wert (keine wiederholten Namen/Beschreibungen).
Semantik: {{training_parameters_glossary_md}} im Prompt ergänzen.
"""
out: dict[str, Any] = {}
for m in metrics or []:
if not isinstance(m, dict):
continue
k = m.get("key")
if not k:
continue
v = m.get("value")
dt = (m.get("data_type") or "").lower()
if v is None:
out[str(k)] = None
continue
if dt == "integer":
try:
out[str(k)] = int(v)
except (TypeError, ValueError):
out[str(k)] = normalize_prompt_number(v)
elif dt == "boolean":
out[str(k)] = bool(v)
elif dt == "string":
out[str(k)] = normalize_prompt_number(v)
else:
out[str(k)] = normalize_prompt_number(v)
return out

View File

@ -0,0 +1,573 @@
"""
Chart.js-Payloads für Recovery (R1–R5) — gemeinsam mit routers/charts und recovery-dashboard-viz.
Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional, Set
from db import get_db, get_cursor
from data_layer.recovery_metrics import (
SLEEP_DEBT_ROLLING_WINDOW_DAYS,
SLEEP_DEBT_TARGET_HOURS_DEFAULT,
calculate_hrv_vs_baseline_pct,
calculate_recovery_score_v2,
calculate_rhr_vs_baseline_pct,
calculate_sleep_debt_hours,
get_sleep_duration_data,
get_sleep_quality_data,
sleep_debt_sum_hours_in_window,
)
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
from data_layer.vital_signs_assessment import build_vital_items_from_rows
def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
if days < 7:
days = 7
if days > 90:
days = 90
current_score = calculate_recovery_score_v2(profile_id)
if current_score is None:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Recovery-Daten vorhanden",
},
}
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT date, resting_hr, hrv
FROM vitals_baseline
WHERE profile_id=%s AND date >= %s
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows:
return {
"chart_type": "line",
"data": {
"labels": [datetime.now().strftime("%Y-%m-%d")],
"datasets": [
{
"label": "Recovery Score",
"data": [current_score],
"borderColor": "#1D9E75",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"fill": True,
}
],
},
"metadata": {
"confidence": "low",
"data_points": 1,
"current_score": current_score,
},
}
labels = [row["date"].isoformat() for row in rows]
values = [min(100, max(0, safe_float(row["hrv"]) if row["hrv"] else 50)) for row in rows]
return {
"chart_type": "line",
"data": {
"labels": labels,
"datasets": [
{
"label": "HRV (ms, auf 0–100 begrenzt) — nicht der KPI Recovery-Score",
"data": values,
"borderColor": "#1D9E75",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"fill": True,
}
],
},
"metadata": serialize_dates(
{
"confidence": calculate_confidence(len(rows), days, "general"),
"data_points": len(rows),
"current_score": current_score,
"chart_series_kind": "hrv_ms_clamped",
"kpi_score_source": "calculate_recovery_score_v2",
"note": "Kurve = HRV-Rohwert (ms) begrenzt auf 0–100, nur Verlaufsorientierung. "
"KPI-Kachel «Recovery-Score» = gewichteter Score (HRV, RHR, Schlaf, …).",
}
),
}
def build_hrv_rhr_baseline_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
if days < 7:
days = 7
if days > 90:
days = 90
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT date, resting_hr, hrv
FROM vitals_baseline
WHERE profile_id=%s AND date >= %s
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Vitalwerte vorhanden",
},
}
labels = [row["date"].isoformat() for row in rows]
hrv_values = [safe_float(row["hrv"]) if row["hrv"] else None for row in rows]
rhr_values = [safe_float(row["resting_hr"]) if row["resting_hr"] else None for row in rows]
hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id)
rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id)
hrv_filtered = [v for v in hrv_values if v is not None]
rhr_filtered = [v for v in rhr_values if v is not None]
avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50
avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60
datasets = [
{
"label": "HRV (ms)",
"data": hrv_values,
"borderColor": "#1D9E75",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"yAxisID": "y1",
"fill": False,
},
{
"label": "RHR (bpm)",
"data": rhr_values,
"borderColor": "#3B82F6",
"backgroundColor": "rgba(59, 130, 246, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"yAxisID": "y2",
"fill": False,
},
]
return {
"chart_type": "line",
"data": {"labels": labels, "datasets": datasets},
"metadata": serialize_dates(
{
"confidence": calculate_confidence(len(rows), days, "general"),
"data_points": len(rows),
"avg_hrv": round(avg_hrv, 1),
"avg_rhr": round(avg_rhr, 1),
"hrv_vs_baseline_pct": hrv_baseline,
"rhr_vs_baseline_pct": rhr_baseline,
}
),
}
def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
if days < 7:
days = 7
if days > 90:
days = 90
duration_data = get_sleep_duration_data(profile_id, days)
quality_data = get_sleep_quality_data(profile_id, days)
if duration_data["confidence"] == "insufficient":
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Schlafdaten vorhanden",
},
}
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT date, duration_minutes
FROM sleep_log
WHERE profile_id=%s AND date >= %s
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Schlafdaten",
},
}
labels = [row["date"].isoformat() for row in rows]
duration_hours = [
safe_float(row["duration_minutes"]) / 60 if row["duration_minutes"] else None for row in rows
]
quality_scores = [(d / 8 * 100) if d else None for d in duration_hours]
datasets = [
{
"label": "Schlafdauer (h)",
"data": duration_hours,
"borderColor": "#3B82F6",
"backgroundColor": "rgba(59, 130, 246, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"yAxisID": "y1",
"fill": True,
},
{
"label": "Qualität (%)",
"data": quality_scores,
"borderColor": "#1D9E75",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"yAxisID": "y2",
"fill": False,
},
]
return {
"chart_type": "line",
"data": {"labels": labels, "datasets": datasets},
"metadata": serialize_dates(
{
"confidence": duration_data["confidence"],
"data_points": len(rows),
"avg_duration_hours": round(duration_data["avg_duration_hours"], 1),
"sleep_quality_score": quality_data.get("quality_score", 0),
}
),
}
def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
if days < 7:
days = 7
if days > 90:
days = 90
current_debt = calculate_sleep_debt_hours(profile_id)
if current_debt is None:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Schlafdaten für Schulden-Berechnung",
},
}
chart_cutoff = (datetime.now() - timedelta(days=days)).date()
# Historie vor dem Chart-Fenster, damit das rollierende 14-Tage-Fenster früh korrekt gefüllt ist
ext_cutoff = (datetime.now() - timedelta(days=days + SLEEP_DEBT_ROLLING_WINDOW_DAYS + 3)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT date, duration_minutes
FROM sleep_log
WHERE profile_id=%s AND date >= %s
AND duration_minutes IS NOT NULL
ORDER BY date ASC""",
(profile_id, ext_cutoff),
)
all_rows = [dict(r) for r in cur.fetchall()]
visible = []
for r in all_rows:
rd = r.get("date")
d = rd.date() if isinstance(rd, datetime) else rd
if d >= chart_cutoff:
visible.append(r)
if not visible:
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Schlafdaten",
},
}
labels: list[str] = []
debt_values: list[float] = []
for r in visible:
rd = r.get("date")
end_d = rd.date() if isinstance(rd, datetime) else rd
if not isinstance(end_d, date):
continue
labels.append(end_d.isoformat())
debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d))
# KPI nutzt immer Fensterende = heute; die Kurve endete bisher am Datum der letzten Schlaf-Zeile
# (z. B. gestern) → anderes 14-Tage-Fenster. Letzter Punkt = exakt KPI-Wert, Datum = heute.
today = datetime.now().date()
if labels and debt_values:
try:
last_d = date.fromisoformat(labels[-1])
except (TypeError, ValueError):
last_d = None
if last_d is not None:
if last_d < today:
labels.append(today.isoformat())
debt_values.append(float(current_debt))
elif last_d == today:
debt_values[-1] = float(current_debt)
return {
"chart_type": "line",
"data": {
"labels": labels,
"datasets": [
{
"label": f"Schlafschuld (h), rollierend {SLEEP_DEBT_ROLLING_WINDOW_DAYS} Tage — wie KPI",
"data": debt_values,
"borderColor": "#EF4444",
"backgroundColor": "rgba(239, 68, 68, 0.1)",
"borderWidth": 2,
"tension": 0.3,
"fill": True,
}
],
},
"metadata": serialize_dates(
{
"confidence": calculate_confidence(len(visible), days, "general"),
"data_points": len(labels),
"current_debt_hours": round(float(current_debt), 1),
"sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT,
"rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS,
"note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. "
f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster. "
"Zwischenpunkte: Fensterende = Datum der jeweiligen Schlaf-Zeile; "
"letzter Punkt ist auf «heute» bzw. KPI-Wert gesetzt, damit Kurve und Kachel übereinstimmen.",
}
),
}
VITAL_BASELINE_KEYS = ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate")
def _vitals_row_has_any_value(row: Any) -> bool:
if not row:
return False
for k in VITAL_BASELINE_KEYS:
if row.get(k) is not None:
return True
return False
def _merge_vitals_baseline_rows(rows: Any) -> tuple[Optional[Dict[str, Any]], Optional[Any]]:
"""
Pro Kennzahl den jeweils neuesten nicht-leeren Wert (Zeilen sortiert: date DESC).
So können KPIs (Aggregation über Zeilen) Daten haben, obwohl die jüngste Zeile leer ist.
"""
if not rows:
return None, None
merged: Dict[str, Any] = {k: None for k in VITAL_BASELINE_KEYS}
for row in rows:
for k in VITAL_BASELINE_KEYS:
if merged[k] is None and row.get(k) is not None:
merged[k] = row[k]
if all(merged[k] is not None for k in VITAL_BASELINE_KEYS):
break
if not _vitals_row_has_any_value(merged):
return None, None
newest_date = rows[0].get("date") if rows else None
return merged, newest_date
def _bp_row_complete(row: Any) -> bool:
return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None)
def _tone_to_bar_value(tone: str) -> float:
return {"good": 88.0, "warn": 52.0, "bad": 22.0, "neutral": 62.0}.get(tone, 55.0)
def build_vital_signs_matrix_chart_payload(
profile_id: str,
days: int,
omit_snapshot_keys: Optional[Set[str]] = None,
) -> Dict[str, Any]:
"""Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1).
omit_snapshot_keys: z. B. {'resting_hr','hrv'} wenn dieselbe Einordnung bereits im Vital-Verlauf steht.
"""
if days < 7:
days = 7
if days > 365:
days = 365
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
bp_row = None
vitals_measured_at = None
bp_measured_at = None
vitals_for_items: Optional[Dict[str, Any]] = None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
FROM vitals_baseline
WHERE profile_id=%s AND date >= %s
ORDER BY date DESC
LIMIT 200""",
(profile_id, cutoff),
)
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
if vitals_merged is None:
cur.execute(
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
FROM vitals_baseline
WHERE profile_id=%s
ORDER BY date DESC
LIMIT 400""",
(profile_id,),
)
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
if vitals_merged is not None:
vitals_for_items = dict(vitals_merged)
if vitals_date is not None:
vitals_measured_at = vitals_date.isoformat() if hasattr(vitals_date, "isoformat") else str(vitals_date)
cur.execute(
"""SELECT measured_at, systolic, diastolic
FROM blood_pressure_log
WHERE profile_id=%s AND measured_at::date >= %s::date
ORDER BY measured_at DESC
LIMIT 1""",
(profile_id, cutoff),
)
bp_row = cur.fetchone()
if bp_row and bp_row.get("measured_at") is not None:
bp_measured_at = bp_row["measured_at"]
if not _bp_row_complete(bp_row):
cur.execute(
"""SELECT measured_at, systolic, diastolic
FROM blood_pressure_log
WHERE profile_id=%s
ORDER BY measured_at DESC
LIMIT 1""",
(profile_id,),
)
bp_row = cur.fetchone()
if bp_row and bp_row.get("measured_at") is not None:
bp_measured_at = bp_row["measured_at"]
bp_for_items = None
if bp_row:
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")}
items = build_vital_items_from_rows(
vitals_for_items, bp_for_items, omit_keys=omit_snapshot_keys
)
if not items and vitals_for_items and omit_snapshot_keys:
items = build_vital_items_from_rows(vitals_for_items, bp_for_items, omit_keys=None)
if not items:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Keine Vitalwerte mit Zahlenwerten — Baseline-Vitals und/oder Blutdruck erfassen.",
"vital_items": [],
"vitals_measured_at": vitals_measured_at,
"blood_pressure_measured_at": bp_measured_at.isoformat() if bp_measured_at and hasattr(bp_measured_at, "isoformat") else None,
},
}
for it in items:
it["bar_value"] = round(_tone_to_bar_value(it["tone"]), 1)
labels_short = [it["label_de"] for it in items]
bar_values = [it["bar_value"] for it in items]
colors = []
for it in items:
t = it["tone"]
if t == "good":
colors.append("#1D9E75")
elif t == "warn":
colors.append("#EF9F27")
elif t == "bad":
colors.append("#D85A30")
else:
colors.append("#6B7280")
return {
"chart_type": "bar",
"data": {
"labels": labels_short,
"datasets": [
{
"label": "Einschätzung (relativ)",
"data": bar_values,
"backgroundColor": colors,
"borderColor": colors,
"borderWidth": 1,
}
],
},
"metadata": serialize_dates(
{
"confidence": "medium",
"data_points": len(items),
"note": "Orientierende Zonen, keine Diagnose. Balken = relative Einordnung (nicht körperliche Einheit).",
"vital_items": items,
"bar_is_relative_score": True,
"vitals_measured_at": vitals_measured_at,
"blood_pressure_measured_at": bp_measured_at.isoformat()
if bp_measured_at and hasattr(bp_measured_at, "isoformat")
else (str(bp_measured_at) if bp_measured_at else None),
"disclaimer_de": "Hinweis: Nur Orientierung; bei Beschwerden oder auffälligen Werten ärztlich abklären.",
}
),
}

View File

@ -0,0 +1,218 @@
"""
KPIs und Kurz-Aussagen für Recovery-Dashboard (Layer 2b).
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _verdict(status: str) -> str:
if status == "good":
return "Gut"
if status == "warn":
return "Hinweis"
return "Achtung"
def _recovery_score_status(score: Optional[int]) -> str:
if score is None:
return "warn"
if score >= 70:
return "good"
if score >= 45:
return "warn"
return "bad"
def _debt_status(hours: Optional[float]) -> str:
if hours is None:
return "warn"
if hours <= 2:
return "good"
if hours <= 8:
return "warn"
return "bad"
def build_recovery_dashboard_kpi_tiles(
recovery_score: Optional[int],
sleep_debt_hours: Optional[float],
avg_sleep_hours: Optional[float],
hrv_vs_baseline_pct: Optional[float],
rhr_vs_baseline_pct: Optional[float],
merge_heart_autonomic_tiles: bool = True,
include_avg_sleep_kpi: bool = True,
) -> List[Dict[str, Any]]:
tiles: List[Dict[str, Any]] = []
rs = _recovery_score_status(recovery_score)
tiles.append(
{
"key": "recovery_score",
"category": "Recovery-Score",
"icon": "💚",
"value": str(recovery_score) if recovery_score is not None else "—",
"sublabel": "Modell aus Schlaf + Vitaldaten",
"status": rs,
"verdict": _verdict(rs),
"hoverTop": "Gesamt-Recovery-Score (0–100)",
"hoverBody": "calculate_recovery_score_v2 — gleiche Quelle wie Platzhalter.",
"keys": ["recovery_score"],
}
)
ds = _debt_status(sleep_debt_hours)
tiles.append(
{
"key": "sleep_debt",
"category": "Schlafschuld",
"icon": "⏳",
"value": f"{sleep_debt_hours:.1f} h".replace(".", ",")
if sleep_debt_hours is not None
else "—",
"sublabel": "Kumuliert (Ziel 8 h/Nacht)",
"status": ds,
"verdict": _verdict(ds),
"hoverTop": "Geschätzte Schlafschuld",
"hoverBody": "calculate_sleep_debt_hours",
"keys": ["sleep_debt_hours"],
}
)
if include_avg_sleep_kpi:
tiles.append(
{
"key": "avg_sleep",
"category": "Ø Schlafdauer",
"icon": "🌙",
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—",
"sublabel": "Im gewählten Fenster",
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
"hoverTop": "Durchschnittliche Schlafdauer",
"hoverBody": "get_sleep_duration_data",
"keys": ["sleep_duration_avg"],
}
)
if merge_heart_autonomic_tiles and (
hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None
):
h_s = (
"good"
if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0
else "warn"
if hrv_vs_baseline_pct is not None
else "warn"
)
parts: List[str] = []
if hrv_vs_baseline_pct is not None:
parts.append(f"HRV {hrv_vs_baseline_pct:+.1f} %".replace(".", ","))
if rhr_vs_baseline_pct is not None:
parts.append(f"RHR {rhr_vs_baseline_pct:+.1f} %".replace(".", ","))
tiles.append(
{
"key": "herz_autonom",
"category": "Herz & autonomes System",
"icon": "❤️‍🩹",
"value": " · ".join(parts) if parts else "—",
"sublabel": "HRV/Ruhepuls vs. Referenz (3-Tage-Mittel vs. ältere Basis)",
"status": h_s,
"verdict": _verdict(h_s),
"hoverTop": "HRV und Ruhepuls relativ zur persönlichen Basis",
"hoverBody": "calculate_hrv_vs_baseline_pct · calculate_rhr_vs_baseline_pct",
"keys": ["hrv_vs_baseline", "rhr_vs_baseline"],
}
)
else:
h_s = (
"good"
if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0
else "warn"
if hrv_vs_baseline_pct is not None
else "warn"
)
tiles.append(
{
"key": "hrv_baseline",
"category": "HRV vs. Basis",
"icon": "〰️",
"value": f"{hrv_vs_baseline_pct:+.1f} %".replace(".", ",")
if hrv_vs_baseline_pct is not None
else "—",
"sublabel": "Letzte 3 Tage vs. ältere Basis",
"status": h_s,
"verdict": _verdict(h_s),
"hoverTop": "Abweichung HRV vom Referenzmittel",
"hoverBody": "calculate_hrv_vs_baseline_pct",
"keys": ["hrv_vs_baseline"],
}
)
tiles.append(
{
"key": "rhr_baseline",
"category": "Ruhepuls vs. Basis",
"icon": "❤️",
"value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",")
if rhr_vs_baseline_pct is not None
else "—",
"sublabel": "Niedriger oft günstiger",
"status": "good",
"verdict": "Gut",
"hoverTop": "Abweichung Ruhepuls",
"hoverBody": "calculate_rhr_vs_baseline_pct",
"keys": ["rhr_vs_baseline"],
}
)
return tiles
def build_recovery_progress_insights(
recovery_score: Optional[int],
sleep_debt_hours: Optional[float],
hrv_vs_baseline_pct: Optional[float],
include_autonomic_hrv_narrative: bool = False,
) -> List[Dict[str, Any]]:
"""HRV-Basistext optional: steckt gebündelt im Vital-Verlauf (consolidated_paragraphs)."""
out: List[Dict[str, Any]] = []
if recovery_score is not None:
tone = "good" if recovery_score >= 65 else "warn" if recovery_score >= 45 else "bad"
out.append(
{
"key": "ins_rec",
"tone": tone,
"title": "Gesamterholung",
"body": f"Der Recovery-Score liegt bei {recovery_score}/100. "
"Er kombiniert Schlaf- und Vital-Signale — ideal für die Einordnung von Trainingstagen.",
}
)
if sleep_debt_hours is not None:
tone = "good" if sleep_debt_hours <= 3 else "warn" if sleep_debt_hours <= 10 else "bad"
out.append(
{
"key": "ins_debt",
"tone": tone,
"title": "Schlaf nachholen",
"body": f"Geschätzte Schlafschuld: {sleep_debt_hours:.1f} h. "
"Hohe Schulden erhöhen Verletzungs- und Ermüdungsrisiko — Priorität Schlafhygiene.",
}
)
if include_autonomic_hrv_narrative and hrv_vs_baseline_pct is not None:
tone = "good" if hrv_vs_baseline_pct >= 0 else "warn"
out.append(
{
"key": "ins_hrv",
"tone": tone,
"title": "Autonomes System",
"body": f"HRV liegt {hrv_vs_baseline_pct:+.1f} % relativ zur Basis. "
"Positive Werte werden oft mit guter Regeneration assoziiert (individuell interpretieren).",
}
)
return out

View File

@ -21,6 +21,11 @@ from datetime import datetime, timedelta, date
from db import get_db, get_cursor
from data_layer.utils import calculate_confidence, safe_float, safe_int
# ── Schlafschuld (KPI + Charts): eine Zielschlafdauer, bis ein Profil-Feld existiert
SLEEP_DEBT_TARGET_HOURS_DEFAULT = 7.5
SLEEP_DEBT_ROLLING_WINDOW_DAYS = 14
SLEEP_DEBT_MIN_NIGHTS_FOR_KPI = 10
def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]:
"""JSONB kann dict/list/str sein; ungültig → None."""
@ -744,34 +749,70 @@ def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]:
return round(avg_hours, 1)
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
"""
Calculate accumulated sleep debt (hours) last 14 days
Assumes 7.5h target per night
"""
target_hours = 7.5
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT duration_minutes
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '14 days'
AND duration_minutes IS NOT NULL
ORDER BY date DESC
""", (profile_id,))
sleep_data = [row['duration_minutes'] for row in cur.fetchall()]
if len(sleep_data) < 10: # Need at least 10 days
def _row_date_as_date(d: Any) -> Optional[date]:
if d is None:
return None
if isinstance(d, datetime):
return d.date()
if isinstance(d, date):
return d
return None
# Calculate cumulative debt
total_debt_min = sum(max(0, (target_hours * 60) - sleep_min) for sleep_min in sleep_data)
debt_hours = total_debt_min / 60
return round(debt_hours, 1)
def sleep_debt_sum_hours_in_window(
night_rows: List[Dict[str, Any]],
window_end: date,
*,
target_hours: float = SLEEP_DEBT_TARGET_HOURS_DEFAULT,
window_days: int = SLEEP_DEBT_ROLLING_WINDOW_DAYS,
min_nights: int = SLEEP_DEBT_MIN_NIGHTS_FOR_KPI,
) -> Optional[float]:
"""
Summe der nächtlichen Defizite (nur Unter-Ziel, kein „Überschuss-Guthaben“) im Fenster
(window_end − window_days … window_end], Kalendertage).
Gleiche Logik wie KPI calculate_sleep_debt_hours für window_end = heute.
"""
start = window_end - timedelta(days=window_days)
tmin = target_hours * 60.0
total_min = 0.0
nights = 0
for row in night_rows:
rd = _row_date_as_date(row.get("date"))
if rd is None or rd < start or rd > window_end:
continue
dm = row.get("duration_minutes")
if dm is None:
continue
nights += 1
total_min += max(0.0, tmin - float(dm))
if nights < min_nights:
return None
return round(total_min / 60.0, 1)
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
"""
Aufsummierte Schlafschuld (h) der letzten 14 Kalendertage bis heute —
Ziel pro Nacht: SLEEP_DEBT_TARGET_HOURS_DEFAULT (aktuell nicht profilkonfigurierbar).
"""
today = datetime.now().date()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT date, duration_minutes
FROM sleep_log
WHERE profile_id = %s
AND date >= %s::date - INTERVAL '14 days'
AND date <= %s::date
AND duration_minutes IS NOT NULL
ORDER BY date DESC
""",
(profile_id, today, today),
)
rows = [dict(r) for r in cur.fetchall()]
return sleep_debt_sum_hours_in_window(rows, today)
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:

View File

@ -0,0 +1,120 @@
"""
Layer 2b: Recovery/Erholung — Bundle für Verlauf unter Fitness (Issue 53).
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from db import get_db, get_cursor
from data_layer.recovery_chart_payloads import (
build_hrv_rhr_baseline_chart_payload,
build_recovery_score_chart_payload,
build_sleep_debt_chart_payload,
build_sleep_duration_quality_chart_payload,
build_vital_signs_matrix_chart_payload,
)
from data_layer.vitals_fitness_insights import build_vitals_history_and_analytics
from data_layer.recovery_interpretation import (
build_recovery_dashboard_kpi_tiles,
build_recovery_progress_insights,
)
from data_layer.recovery_metrics import (
calculate_hrv_vs_baseline_pct,
calculate_recovery_score_v2,
calculate_rhr_vs_baseline_pct,
calculate_sleep_debt_hours,
get_sleep_duration_data,
)
def _has_recovery_sources(profile_id: str) -> bool:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1 FROM sleep_log WHERE profile_id=%s LIMIT 1", (profile_id,))
if cur.fetchone():
return True
cur.execute("SELECT 1 FROM vitals_baseline WHERE profile_id=%s LIMIT 1", (profile_id,))
return cur.fetchone() is not None
def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
"""
Ein Request: KPIs, Insights, Charts R1–R5 (Chart.js-kompatibel).
"""
if not _has_recovery_sources(profile_id):
return {
"confidence": "insufficient",
"has_recovery_data": False,
"message": "Noch keine Schlaf- oder Vitaldaten",
"kpi_tiles": [],
"progress_insights": [],
"charts": {},
"meta": {"layer_1": "recovery_metrics", "layer_2b": "recovery_viz"},
}
all_history = days >= 9999
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
chart_days = min(90, max(7, min(eff_days, 365)))
# Vital-Matrix: längeres Fenster + Fallback im Builder, damit nicht nur „letzte 30 Tage“
vital_days = min(365, max(30, min(eff_days, 365)))
recovery_score_val = calculate_recovery_score_v2(profile_id)
sleep_debt = calculate_sleep_debt_hours(profile_id)
dur = get_sleep_duration_data(profile_id, chart_days)
avg_sleep = None
if dur.get("confidence") != "insufficient":
avg_sleep = float(dur.get("avg_duration_hours") or 0) or None
hrv_dev = calculate_hrv_vs_baseline_pct(profile_id)
rhr_dev = calculate_rhr_vs_baseline_pct(profile_id)
kpi_tiles = build_recovery_dashboard_kpi_tiles(
recovery_score_val,
float(sleep_debt) if sleep_debt is not None else None,
avg_sleep,
float(hrv_dev) if hrv_dev is not None else None,
float(rhr_dev) if rhr_dev is not None else None,
include_avg_sleep_kpi=False,
)
insights = build_recovery_progress_insights(
recovery_score_val,
float(sleep_debt) if sleep_debt is not None else None,
float(hrv_dev) if hrv_dev is not None else None,
)
hrv_f = float(hrv_dev) if hrv_dev is not None else None
rhr_f = float(rhr_dev) if rhr_dev is not None else None
charts = {
"recovery_score": build_recovery_score_chart_payload(profile_id, chart_days),
"hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days),
"sleep_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days),
"sleep_debt": build_sleep_debt_chart_payload(profile_id, chart_days),
"vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days),
"vitals_history": build_vitals_history_and_analytics(
profile_id, vital_days, hrv_vs_baseline_pct=hrv_f, rhr_vs_baseline_pct=rhr_f
),
}
conf = "medium"
if recovery_score_val is None and sleep_debt is None:
conf = "low"
return {
"confidence": conf,
"has_recovery_data": True,
"days_requested": days,
"effective_window_days": eff_days,
"chart_days_used": chart_days,
"vital_matrix_days_used": vital_days,
"kpi_tiles": kpi_tiles,
"progress_insights": insights,
"charts": charts,
"meta": {
"layer_1": "recovery_metrics",
"layer_2b": "recovery_viz",
"issue": "53-layer-2b-recovery",
},
}

View File

@ -9,11 +9,34 @@ Dates are normalized to ISO strings; Decimals to float — suitable for JSON/API
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any, Optional
from db import get_cursor, get_db, r2d
# Spalten des Messwerts (ohne Typ-Metadaten) für Snapshot-Payloads / Platzhalter-JSON
_REFERENCE_ENTRY_KEYS = frozenset(
{
"id",
"profile_id",
"reference_value_type_id",
"effective_date",
"value_numeric",
"value_text",
"unit",
"source",
"confidence",
"method",
"notes",
"extra",
"created_at",
"updated_at",
"type_key",
"type_label",
}
)
def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]:
"""Normalize DB row dict for JSON (dates → ISO, Decimal → float)."""
@ -177,3 +200,173 @@ def get_profile_reference_values_summary(profile_id: str) -> dict[str, Any]:
tiles = build_summary_tiles_from_ranked_rows(raw_rows)
return {"tiles": tiles}
def _entry_dict_from_ranked_row(d: dict[str, Any]) -> dict[str, Any]:
"""Eintragsfelder inkl. type_key/type_label für KI-Kontext."""
out = {k: d[k] for k in _REFERENCE_ENTRY_KEYS if k in d}
return normalize_reference_row(out)
def get_profile_reference_values_current_snapshot(profile_id: str) -> dict[str, Any]:
"""
Layer 1: Alle **aktuellen** Referenzwerte (jüngster Eintrag pro aktivem Typ), Katalog-Sortierung.
Struktur: ``items`` = Liste mit ``type_key``, ``type_label``, ``value_data_type``,
``type_sort_order``, ``latest`` (vollständiger Eintrag).
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
WITH ranked AS (
SELECT
v.id,
v.profile_id,
v.reference_value_type_id,
v.effective_date,
v.value_numeric,
v.value_text,
v.unit,
v.source,
v.confidence,
v.method,
v.notes,
v.extra,
v.created_at,
v.updated_at,
rt.key AS type_key,
rt.label AS type_label,
rt.sort_order AS type_sort_order,
rt.value_data_type,
ROW_NUMBER() OVER (
PARTITION BY v.reference_value_type_id
ORDER BY v.effective_date DESC, v.created_at DESC
) AS rn
FROM profile_reference_values v
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
WHERE v.profile_id = %s AND rt.active = TRUE
)
SELECT * FROM ranked WHERE rn = 1
ORDER BY type_sort_order ASC, type_key ASC
""",
(profile_id,),
)
raw_rows = [r2d(r) for r in cur.fetchall()]
items: list[dict[str, Any]] = []
for row in raw_rows:
row.pop("rn", None)
vdt = (row.get("value_data_type") or "decimal").strip().lower()
latest = _entry_dict_from_ranked_row(row)
items.append(
{
"type_key": row.get("type_key"),
"type_label": row.get("type_label"),
"value_data_type": vdt,
"type_sort_order": int(row.get("type_sort_order") or 0),
"latest": latest,
}
)
return {
"schema": "profile_reference_values_current_v1",
"count": len(items),
"items": items,
}
def get_profile_reference_values_recent_snapshot(
profile_id: str,
*,
limit_per_type: int = 5,
date_from: Optional[date | str] = None,
date_to: Optional[date | str] = None,
) -> dict[str, Any]:
"""
Layer 1: Pro Referenztyp die **letzten N** Einträge (neueste zuerst), optional nach
``effective_date`` gefiltert.
``date_from`` / ``date_to``: inclusive; als ``date`` oder ISO-``YYYY-MM-DD``-String.
"""
lim = max(1, min(int(limit_per_type), 50))
df = date_from
dt = date_to
if isinstance(df, str) and df.strip():
df = date.fromisoformat(df.strip())
elif df is not None and not isinstance(df, date):
df = None
if isinstance(dt, str) and dt.strip():
dt = date.fromisoformat(dt.strip())
elif dt is not None and not isinstance(dt, date):
dt = None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
WITH filtered AS (
SELECT
v.id,
v.profile_id,
v.reference_value_type_id,
v.effective_date,
v.value_numeric,
v.value_text,
v.unit,
v.source,
v.confidence,
v.method,
v.notes,
v.extra,
v.created_at,
v.updated_at,
rt.key AS type_key,
rt.label AS type_label,
rt.sort_order AS type_sort_order,
rt.value_data_type
FROM profile_reference_values v
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
WHERE v.profile_id = %s
AND rt.active = TRUE
AND (%s::date IS NULL OR v.effective_date >= %s::date)
AND (%s::date IS NULL OR v.effective_date <= %s::date)
),
ranked AS (
SELECT
f.*,
ROW_NUMBER() OVER (
PARTITION BY f.reference_value_type_id
ORDER BY f.effective_date DESC, f.created_at DESC
) AS rn
FROM filtered f
)
SELECT * FROM ranked WHERE rn <= %s
ORDER BY type_sort_order ASC, type_key ASC, rn ASC
""",
(profile_id, df, df, dt, dt, lim),
)
raw_rows = [r2d(r) for r in cur.fetchall()]
by_type: dict[str, list[dict[str, Any]]] = {}
type_order: list[str] = []
seen: set[str] = set()
for row in raw_rows:
row.pop("rn", None)
tk = row.get("type_key") or ""
if tk not in seen:
seen.add(tk)
type_order.append(tk)
entry = _entry_dict_from_ranked_row(row)
by_type.setdefault(tk, []).append(entry)
return {
"schema": "profile_reference_values_recent_v1",
"limit_per_type": lim,
"date_from": df.isoformat() if isinstance(df, date) else None,
"date_to": dt.isoformat() if isinstance(dt, date) else None,
"ordered_type_keys": type_order,
"by_type_key": by_type,
}

View File

@ -0,0 +1,120 @@
"""HTTP client for a user's Tandoor instance. Never logs the token."""
from __future__ import annotations
import json
import ssl
import urllib.error
import urllib.request
from typing import Any
from urllib.parse import urljoin, urlparse
class TandoorError(ValueError):
pass
def normalize_base_url(raw: str | None) -> str:
url = (raw or "").strip().rstrip("/")
if not url:
raise TandoorError("Tandoor-URL fehlt")
parsed = urlparse(url)
if parsed.scheme not in ("https", "http") or not parsed.netloc:
raise TandoorError("Tandoor-URL muss mit https:// oder http:// beginnen")
if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
raise TandoorError("Tandoor-URL muss https verwenden")
return url
def token_hint(token: str | None) -> str | None:
t = (token or "").strip()
if len(t) < 4:
return "gesetzt" if t else None
return f"…{t[-4:]}"
def public_settings(row: dict[str, Any] | None) -> dict[str, Any]:
if not row:
return {
"configured": False,
"base_url": "",
"token_set": False,
"token_hint": None,
"last_ok_at": None,
"last_error": None,
}
return {
"configured": True,
"base_url": row.get("base_url") or "",
"token_set": bool((row.get("api_token") or "").strip()),
"token_hint": token_hint(row.get("api_token")),
"last_ok_at": row.get("last_ok_at").isoformat() if row.get("last_ok_at") else None,
"last_error": row.get("last_error"),
}
def _request(base_url: str, token: str, path: str, timeout: float = 10.0) -> tuple[int, str, str]:
url = urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
req = urllib.request.Request(
url,
headers={
"Authorization": f"Token {token.strip()}",
"Accept": "application/json",
"User-Agent": "Mitai-Jinkendo/tandoor-connector",
},
method="GET",
)
ctx = ssl.create_default_context()
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
return None
opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ctx))
try:
with opener.open(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8", errors="replace")
ctype = resp.headers.get("Content-Type", "")
return resp.status, body, ctype
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace") if e.fp else ""
ctype = e.headers.get("Content-Type", "") if e.headers else ""
return e.code, body, ctype
except TimeoutError as e:
raise TandoorError("Tandoor antwortet nicht (Zeitüberschreitung)") from e
except urllib.error.URLError as e:
raise TandoorError(f"Tandoor nicht erreichbar: {e.reason}") from e
def probe_connection(base_url: str, token: str) -> dict[str, Any]:
url = normalize_base_url(base_url)
tok = (token or "").strip()
if not tok:
raise TandoorError("API-Token fehlt")
status, body, ctype = _request(url, tok, "api/user/")
if status in (301, 302, 303, 307, 308) or "text/html" in (ctype or "") and status < 400:
raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen")
if status in (401, 403):
raise TandoorError("Token ungültig oder ohne Rechte")
if status == 404:
status, body, ctype = _request(url, tok, "api/recipe/?page=1&page_size=1")
if status in (401, 403):
raise TandoorError("Token ungültig oder ohne Rechte")
if status >= 400:
raise TandoorError(f"Tandoor antwortete mit HTTP {status}")
if "json" not in (ctype or "") and not (body or "").lstrip().startswith(("{", "[")):
raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen")
try:
data = json.loads(body) if body else {}
except json.JSONDecodeError as e:
raise TandoorError("Tandoor-Antwort war kein JSON") from e
name = None
if isinstance(data, dict):
results = data.get("results")
if isinstance(results, list) and results and isinstance(results[0], dict):
name = results[0].get("display_name") or results[0].get("username") or results[0].get("name")
else:
name = data.get("display_name") or data.get("username") or data.get("name")
count = data.get("count")
else:
count = None
return {"ok": True, "user": name, "recipe_count": count}

View File

@ -0,0 +1,67 @@
"""Persist per-profile Tandoor credentials. Token never returned in full."""
from __future__ import annotations
from typing import Any
from data_layer.tandoor_client import public_settings
def get_settings(cur, profile_id: str) -> dict[str, Any] | None:
cur.execute(
"""
SELECT profile_id, base_url, api_token, last_ok_at, last_error, updated_at
FROM profile_tandoor_settings
WHERE profile_id = %s
""",
(profile_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_public_settings(cur, profile_id: str) -> dict[str, Any]:
return public_settings(get_settings(cur, profile_id))
def upsert_settings(cur, profile_id: str, base_url: str, token: str | None, *, clear_token: bool = False) -> dict[str, Any]:
existing = get_settings(cur, profile_id)
if clear_token:
cur.execute("DELETE FROM profile_tandoor_settings WHERE profile_id = %s", (profile_id,))
return public_settings(None)
stored = (token or "").strip() or ((existing or {}).get("api_token") or "")
if not stored:
raise ValueError("API-Token fehlt")
cur.execute(
"""
INSERT INTO profile_tandoor_settings (profile_id, base_url, api_token, last_error, updated_at)
VALUES (%s, %s, %s, NULL, NOW())
ON CONFLICT (profile_id) DO UPDATE SET
base_url = EXCLUDED.base_url,
api_token = EXCLUDED.api_token,
last_error = NULL,
updated_at = NOW()
""",
(profile_id, base_url, stored),
)
return get_public_settings(cur, profile_id)
def record_probe(cur, profile_id: str, ok: bool, error: str | None = None) -> None:
if ok:
cur.execute(
"""
UPDATE profile_tandoor_settings
SET last_ok_at = NOW(), last_error = NULL, updated_at = NOW()
WHERE profile_id = %s
""",
(profile_id,),
)
else:
cur.execute(
"""
UPDATE profile_tandoor_settings
SET last_error = %s, updated_at = NOW()
WHERE profile_id = %s
""",
((error or "")[:300], profile_id),
)

View File

@ -0,0 +1,156 @@
"""
Orientierende Zonen-Einschätzungen für Vitalwerte (Layer 1, Issue 53).
Keine Diagnose — typische Referenzbereiche für UI/Coaching.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
from data_layer.utils import safe_float
Tone = str # good | warn | bad | neutral
def _item(
key: str,
label_de: str,
value_display: str,
tone: Tone,
zone_label_de: str,
hint_de: str,
sort_order: int,
) -> Dict[str, Any]:
return {
"key": key,
"label_de": label_de,
"value_display": value_display,
"tone": tone,
"zone_label_de": zone_label_de,
"hint_de": hint_de,
"sort_order": sort_order,
}
def assess_resting_hr(bpm: float) -> tuple:
if bpm < 50:
return (
"warn",
"Niedrig",
"Unter 50 bpm kann bei Sportlern normal sein — sonst ärztlich klären, wenn neu oder mit Beschwerden.",
)
if bpm < 60:
return ("good", "Günstig / athletisch", "Häufig bei gut trainierten Personen im unteren Normbereich.")
if bpm <= 100:
return ("good", "Im üblichen Normbereich", "Typischer Ruhepuls bei Erwachsenen oft ca. 60–100 bpm.")
if bpm <= 110:
return ("warn", "Leicht erhöht", "Kann durch Stress, Krankheit, Koffein oder Untrainiertheit erhöht sein — Verlauf beobachten.")
return ("bad", "Deutlich erhöht", "Bei anhaltend hohem Ruhepuls medizinische Abklärung sinnvoll.")
def assess_hrv_ms(ms: float) -> tuple:
_ = ms
return (
"neutral",
"Individuell",
"HRV (ms) ist sehr personenabhängig; Aussagekraft vor allem im Vergleich zu deiner eigenen Basis/Trend.",
)
def assess_blood_pressure(systolic: float, diastolic: float) -> tuple:
sys_, dia = systolic, diastolic
if sys_ >= 180 or dia >= 110:
return ("bad", "Sehr hoch", "Sehr hohe Werte — bei Beschwerden oder neu aufgetreten ärztlich zeitnah abklären.")
if sys_ >= 140 or dia >= 90:
return (
"bad",
"Erhöht",
"Liegt in einem Bereich, der oft als Hypertonie eingestuft wird — Bestätigung und Beratung durch ärztliche Messung.",
)
if sys_ >= 130 or dia >= 85:
return ("warn", "Hochnormal", "Oberer Normal-/hochnormaler Bereich — Lebensstil und Verlauf beachten.")
if sys_ < 120 and dia < 80:
return ("good", "Optimal", "Liegt in einem oft als günstig beschriebenen Bereich (<120/80 mmHg).")
return ("good", "Normal", "Im gängigen Zielbereich für viele Erwachsene.")
def assess_spo2(pct: float) -> tuple:
if pct >= 97:
return ("good", "Günstig", "Sauerstoffsättigung im üblichen Zielbereich.")
if pct >= 95:
return ("good", "Unauffällig", "Häufig noch als normal eingestuft; Verlauf bei Atembeschwerden beobachten.")
if pct >= 90:
return ("warn", "Leicht vermindert", "Unter 95 % kann je nach Kontext relevant sein — bei Symptomen abklären.")
return ("bad", "Niedrig", "Niedrige SpO2 — bei anhaltend unter 90 % oder Beschwerden ärztlich vorstellen.")
def assess_respiratory_rate(rpm: float) -> tuple:
if 12 <= rpm <= 20:
return ("good", "Im üblichen Bereich", "Ruheatmung oft ca. 12–20/min.")
if 10 <= rpm < 12 or 20 < rpm <= 24:
return ("warn", "Grenzbereich", "Leicht außerhalb des häufig zitierten Ruhebereichs — Kontext (Belastung, Stress) beachten.")
return ("bad", "Auffällig", "Deutlich außerhalb typischer Ruhewerte — bei Beschwerden medizinisch abklären.")
def assess_vo2_max(value: float) -> tuple:
_ = value
return (
"neutral",
"Orientativ",
"VO2max hängt stark von Alter, Geschlecht und Messmethode ab; Trends in der App sind aussagekräftiger als Einzelwerte.",
)
def build_vital_items_from_rows(
vitals_row: Optional[Dict[str, Any]],
bp_row: Optional[Dict[str, Any]],
omit_keys: Optional[Set[str]] = None,
) -> List[Dict[str, Any]]:
"""omit_keys: z. B. {'resting_hr','hrv'} wenn Einordnung zentral im Herz-/Autonomie-Block steht."""
skip = omit_keys or set()
items: List[Dict[str, Any]] = []
order = 0
if vitals_row:
rhr = vitals_row.get("resting_hr")
if rhr is not None and "resting_hr" not in skip:
v = safe_float(rhr)
t, z, h = assess_resting_hr(v)
items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order))
order += 1
hrv = vitals_row.get("hrv")
if hrv is not None and "hrv" not in skip:
v = safe_float(hrv)
t, z, h = assess_hrv_ms(v)
items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order))
order += 1
vo2 = vitals_row.get("vo2_max")
if vo2 is not None:
v = safe_float(vo2)
t, z, h = assess_vo2_max(v)
items.append(_item("vo2_max", "VO2max", f"{v:.1f} ml/kg/min", t, z, h, order))
order += 1
spo2 = vitals_row.get("spo2")
if spo2 is not None:
v = safe_float(spo2)
t, z, h = assess_spo2(v)
items.append(_item("spo2", "SpO2", f"{v:.0f} %", t, z, h, order))
order += 1
rr = vitals_row.get("respiratory_rate")
if rr is not None:
v = safe_float(rr)
t, z, h = assess_respiratory_rate(v)
items.append(_item("respiratory_rate", "Atemfrequenz", f"{v:.0f} /min", t, z, h, order))
order += 1
if bp_row and bp_row.get("systolic") is not None and bp_row.get("diastolic") is not None:
sys_v = safe_float(bp_row["systolic"])
dia_v = safe_float(bp_row["diastolic"])
t, z, h = assess_blood_pressure(sys_v, dia_v)
items.append(_item("blood_pressure", "Blutdruck", f"{sys_v:.0f}/{dia_v:.0f} mmHg", t, z, h, order))
return items

View File

@ -0,0 +1,400 @@
"""
Vitalwerte: Zeitreihen + einfache Fitness-/Recovery-Einordnung (Layer 1, Issue 53).
Keine Diagnose — deskriptive Trends, Korrelationen und Varianz-Hinweise.
"""
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence
from db import get_db, get_cursor
from data_layer.utils import safe_float, serialize_dates
SERIES_CONFIG = (
("resting_hr", "Ruhepuls", "bpm", "#3B82F6"),
("hrv", "HRV", "ms", "#1D9E75"),
("vo2_max", "VO2max", "ml/kg/min", "#8B5CF6"),
("spo2", "SpO2", "%", "#0EA5E9"),
("respiratory_rate", "Atemfrequenz", "/min", "#F59E0B"),
)
def _date_to_ord(d: Any) -> float:
if hasattr(d, "toordinal"):
return float(d.toordinal())
if isinstance(d, str):
return float(datetime.fromisoformat(d[:10]).date().toordinal())
return 0.0
def _linear_slope(dates: Sequence[Any], values: Sequence[float]) -> float:
if len(values) < 3 or len(dates) != len(values):
return 0.0
xs = [_date_to_ord(d) for d in dates]
ys = list(values)
n = len(xs)
mx = sum(xs) / n
my = sum(ys) / n
den = sum((x - mx) ** 2 for x in xs)
if den < 1e-9:
return 0.0
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den
def _pearson(xs: Sequence[float], ys: Sequence[float]) -> Optional[float]:
n = len(xs)
if n < 5 or len(ys) != n:
return None
mx = statistics.mean(xs)
my = statistics.mean(ys)
sx = statistics.pstdev(xs) if n > 1 else 0.0
sy = statistics.pstdev(ys) if n > 1 else 0.0
if sx < 1e-9 or sy < 1e-9:
return None
cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / n
return cov / (sx * sy)
def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
"""Summe Trainingsminuten pro Kalendertag als Belastungs-Proxy."""
cur.execute(
"""
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
FROM activity_log
WHERE profile_id = %s AND date >= %s::date AND duration_min IS NOT NULL AND duration_min > 0
GROUP BY date
ORDER BY date
""",
(profile_id, cutoff),
)
rows = cur.fetchall()
return {r["d"]: float(r["minutes"]) for r in rows}
def _trailing_window_means(vals: List[float], window: int = 7) -> List[float]:
"""Gleitender Mittelwert über die letzten bis zu `window` aufeinanderfolgenden Messungen (nicht Kalendertage)."""
out: List[float] = []
for i in range(len(vals)):
chunk = vals[max(0, i - window + 1) : i + 1]
out.append(round(statistics.mean(chunk), 2))
return out
def _de_num(x: float) -> str:
"""Dezimalzahl mit Komma für Fließtext."""
return f"{x:.1f}".replace(".", ",")
def _de_num_signed(x: float) -> str:
"""Wie _de_num, mit explizitem Vorzeichen (für %-Abweichungen)."""
return f"{x:+.1f}".replace(".", ",")
def _ins(
key: str,
section: str,
title_de: str,
body: str,
tone: str = "neutral",
) -> Dict[str, Any]:
"""Ein strukturierter Hinweis für UI-Platzierung (section: heart | vo2)."""
return {"key": key, "section": section, "title_de": title_de, "body": body, "tone": tone}
def _build_section_insights(
series: Dict[str, Any],
hrv_vs_baseline_pct: Optional[float],
rhr_vs_baseline_pct: Optional[float],
r_pearson: Optional[float],
pairs_n: int,
) -> List[Dict[str, Any]]:
"""
Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt.
section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf.
"""
out: List[Dict[str, Any]] = []
basis_bits: List[str] = []
if hrv_vs_baseline_pct is not None:
basis_bits.append(
f"HRV gegenüber älterer Referenz: {_de_num_signed(float(hrv_vs_baseline_pct))} %"
)
if rhr_vs_baseline_pct is not None:
basis_bits.append(
f"Ruhepuls relativ zur Referenz: {_de_num_signed(float(rhr_vs_baseline_pct))} %"
)
if basis_bits:
out.append(
_ins(
"heart_baseline",
"heart",
"Kurzfristiges Mittel vs. ältere Basis",
" ".join(basis_bits)
+ " — Vergleich letzter Tage zum älteren Referenzmittel; individuell interpretieren (keine Diagnose).",
"neutral",
)
)
rhr = series.get("resting_hr")
hrv_s = series.get("hrv")
rhr_short_body = ""
r_short_tone = "neutral"
if rhr and rhr.get("points") and len(rhr["points"]) >= 10:
pts = rhr["points"]
last7 = [p["value"] for p in pts[-7:]]
before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else []
if before:
m7 = statistics.mean(last7)
mb = statistics.mean(before)
diff = m7 - mb
if diff > 3:
rhr_short_body = (
f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — "
"kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen."
)
r_short_tone = "warn"
elif diff < -3:
rhr_short_body = (
"Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder "
"besserer Regeneration vereinbar (individuell)."
)
r_short_tone = "good"
rhr_var_sentence = ""
if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6:
rhr_var_sentence = (
f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen "
"sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
)
hrv_var_sentence = ""
if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6:
hrv_var_sentence = (
f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — "
"Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte."
)
ma_hint = (
"Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert "
"über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)."
)
streuung_parts: List[str] = [ma_hint]
if rhr_var_sentence:
streuung_parts.append(rhr_var_sentence)
if hrv_var_sentence:
streuung_parts.append(hrv_var_sentence)
if rhr or hrv_s:
out.append(
_ins(
"heart_streuung_ma",
"heart",
"Streuung & gleitender Mittelwert",
" ".join(streuung_parts),
"neutral",
)
)
if rhr_short_body:
out.append(_ins("heart_rhr_kurz", "heart", "Ruhepuls: Kurzvergleich", rhr_short_body, r_short_tone))
vo2 = series.get("vo2_max")
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
s = vo2["slope_per_day"]
if s > 0.002:
out.append(
_ins(
"vo2_trend_up",
"vo2",
"VO2max-Verlauf",
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
"besserer Datenlage vereinbar.",
"good",
)
)
elif s < -0.002:
out.append(
_ins(
"vo2_trend_down",
"vo2",
"VO2max-Verlauf",
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
"entstehen; Verlauf beobachten.",
"warn",
)
)
if r_pearson is not None and pairs_n >= 8:
if r_pearson > 0.35:
out.append(
_ins(
"heart_load_rhr",
"heart",
"Training und Folge-Ruhepuls",
(
"An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen "
"Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis). "
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren."
),
"warn",
)
)
elif r_pearson < -0.25:
out.append(
_ins(
"heart_load_rhr_neg",
"heart",
"Training und Folge-Ruhepuls",
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem "
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare.",
"neutral",
)
)
return out
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
cur.execute(
"""
SELECT date::text AS d, resting_hr::float AS rhr
FROM vitals_baseline
WHERE profile_id = %s AND date >= %s::date AND resting_hr IS NOT NULL
ORDER BY date
""",
(profile_id, cutoff),
)
return {r["d"]: float(r["rhr"]) for r in cur.fetchall()}
def build_vitals_history_and_analytics(
profile_id: str,
days: int,
hrv_vs_baseline_pct: Optional[float] = None,
rhr_vs_baseline_pct: Optional[float] = None,
) -> Dict[str, Any]:
"""
Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + zusammengefasste Einordnung.
Optional: Abweichung HRV/Ruhepuls zur älteren Basis — für einen Absatz statt doppelter KPI-Texte.
"""
if days < 7:
days = 7
if days > 365:
days = 365
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
FROM vitals_baseline
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC
""",
(profile_id, cutoff),
)
rows = cur.fetchall()
series: Dict[str, Any] = {}
for key, label_de, unit, color in SERIES_CONFIG:
pts: List[Dict[str, Any]] = []
dates: List[Any] = []
vals: List[float] = []
for r in rows:
v = r.get(key)
if v is None:
continue
fv = safe_float(v)
d = r["date"]
d_iso = d.isoformat() if hasattr(d, "isoformat") else str(d)[:10]
pts.append({"date": d_iso, "value": round(fv, 2)})
dates.append(d)
vals.append(fv)
if pts:
ma_vals = _trailing_window_means(vals, window=7)
points_ma7 = [
{"date": pts[i]["date"], "value": ma_vals[i]} for i in range(len(pts))
]
series[key] = {
"key": key,
"label_de": label_de,
"unit": unit,
"color": color,
"points": pts,
"points_ma7": points_ma7,
"n": len(pts),
"last": vals[-1] if vals else None,
"mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None,
"stdev": round(statistics.pstdev(vals), 2) if len(vals) >= 2 else None,
"slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None,
}
# Belastung (Activity) vs Ruhepuls am Folgetag
with get_db() as conn:
cur = get_cursor(conn)
load_by_d = _daily_training_load(cur, profile_id, cutoff)
rhr_by_d = _rhr_by_date(cur, profile_id, cutoff)
pairs_load: List[float] = []
pairs_rhr: List[float] = []
for d_str, load_min in load_by_d.items():
try:
d0 = datetime.fromisoformat(d_str[:10]).date()
except ValueError:
continue
d1 = (d0 + timedelta(days=1)).isoformat()
if d1 in rhr_by_d and load_min > 0:
pairs_load.append(load_min)
pairs_rhr.append(rhr_by_d[d1])
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
pairs_n = len(pairs_load)
section_insights = _build_section_insights(
series,
hrv_vs_baseline_pct,
rhr_vs_baseline_pct,
r_pearson,
pairs_n,
)
if not series:
return {
"chart_type": "vitals_dashboard",
"window_days": days,
"series": {},
"analytics": {
"bullets": [],
"consolidated_paragraphs": [],
"section_insights": section_insights,
},
"metadata": {
"confidence": "insufficient",
"message": "Keine Vital-Zeitreihen im Fenster",
"load_rhr_pairs_n": pairs_n,
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
},
}
return {
"chart_type": "vitals_dashboard",
"window_days": days,
"series": serialize_dates(series),
"analytics": {
"bullets": [],
"consolidated_paragraphs": [],
"section_insights": section_insights,
},
"metadata": {
"confidence": "medium",
"note": "Deskriptive Auswertung; keine medizinische Diagnose.",
"load_rhr_pairs_n": pairs_n,
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
},
}

View File

@ -29,7 +29,9 @@ def init_pool():
user=os.getenv("DB_USER", "mitai"),
password=os.getenv("DB_PASSWORD", "")
)
print(f"✓ PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})")
print(
f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})"
)
@contextmanager
@ -171,7 +173,7 @@ def init_db():
) as table_exists
""")
if not cur.fetchone()['table_exists']:
print("⚠️ ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
return
# Ensure "pipeline" master prompt exists
@ -189,7 +191,7 @@ def init_db():
)
""")
conn.commit()
print("✓ Pipeline master prompt created")
print("[OK] Pipeline master prompt created")
except Exception as e:
print(f"⚠️ Could not create pipeline prompt: {e}")
print(f"[WARN] Could not create pipeline prompt: {e}")
# Don't fail startup - prompt can be created manually

View File

@ -34,8 +34,12 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat
from routers import workflows # Phase 2 Workflow Engine - Execution
from routers import reference_values # Persönliche Referenzwerte (Profil)
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog
from routers import reports # Strukturierter PDF-Bericht (Profil v1)
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping
from routers import tandoor # per-user Tandoor connection
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -66,7 +70,7 @@ async def startup_event():
try:
init_db()
except Exception as e:
print(f"⚠️ init_db() failed (non-fatal): {e}")
print(f"[WARN] init_db() failed (non-fatal): {e}")
# Don't crash on startup - can be created manually
# Apply v9c migration if needed
@ -74,7 +78,7 @@ async def startup_event():
from apply_v9c_migration import apply_migration
apply_migration()
except Exception as e:
print(f"⚠️ v9c migration failed (non-fatal): {e}")
print(f"[WARN] v9c migration failed (non-fatal): {e}")
# ── Register Routers ──────────────────────────────────────────────────────────
app.include_router(auth.router) # /api/auth/*
@ -126,8 +130,15 @@ app.include_router(workflows.router) # /api/workflows/* (Phase 2 Exec
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
app.include_router(reports.router) # /api/reports/* (Berichtsprofil + PDF)
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
app.include_router(bls.router) # /api/bls/*
app.include_router(admin_bls.router) # /api/admin/bls/*
app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
app.include_router(tandoor.router) # /api/tandoor/*
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,80 @@
-- Migration 054: Activity session metrics (EAV) + attribute profiles
-- Date: 2026-04-14
-- Additive only: safe for production (no data deletion).
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
-- Session interval (nullable; optional backfill later)
ALTER TABLE activity_log
ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_activity_log_profile_started
ON activity_log (profile_id, started_at DESC)
WHERE started_at IS NOT NULL;
COMMENT ON COLUMN activity_log.started_at IS 'Training start (wall clock, TZ-aware); optional; for dedupe/analysis';
COMMENT ON COLUMN activity_log.ended_at IS 'Training end (wall clock, TZ-aware); optional';
-- Which parameters apply to which training category (training_types.category)
CREATE TABLE IF NOT EXISTS training_category_parameter (
id SERIAL PRIMARY KEY,
training_category VARCHAR(50) NOT NULL,
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT false,
ui_group VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_training_category_parameter UNIQUE (training_category, training_parameter_id)
);
CREATE INDEX IF NOT EXISTS idx_tcp_category ON training_category_parameter (training_category);
COMMENT ON TABLE training_category_parameter IS 'EAV schema: parameters enabled per training category';
-- Per training type: extra parameters or overrides (NULL sort/required/ui = inherit from category row if present)
CREATE TABLE IF NOT EXISTS training_type_parameter (
id SERIAL PRIMARY KEY,
training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
sort_order INT,
required BOOLEAN,
ui_group VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_training_type_parameter UNIQUE (training_type_id, training_parameter_id)
);
CREATE INDEX IF NOT EXISTS idx_ttp_type ON training_type_parameter (training_type_id);
COMMENT ON TABLE training_type_parameter IS 'EAV schema: add/override parameters for a concrete training_types row';
-- EAV values per activity session
CREATE TABLE IF NOT EXISTS activity_session_metrics (
id BIGSERIAL PRIMARY KEY,
activity_log_id UUID NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE,
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE RESTRICT,
value_num DOUBLE PRECISION,
value_int BIGINT,
value_text TEXT,
value_bool BOOLEAN,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_activity_session_metric UNIQUE (activity_log_id, training_parameter_id),
CONSTRAINT chk_activity_session_metric_one_value CHECK (
(
(value_num IS NOT NULL)::int
+ (value_int IS NOT NULL)::int
+ (value_text IS NOT NULL)::int
+ (value_bool IS NOT NULL)::int
) = 1
)
);
CREATE INDEX IF NOT EXISTS idx_asm_activity ON activity_session_metrics (activity_log_id);
CREATE INDEX IF NOT EXISTS idx_asm_parameter ON activity_session_metrics (training_parameter_id);
COMMENT ON TABLE activity_session_metrics IS 'EAV: one row per (session, training_parameter); exactly one value_* set';
DO $$
BEGIN
RAISE NOTICE 'Migration 054: activity_session_metrics EAV + attribute profile tables + activity_log timestamps';
END $$;

Some files were not shown because too many files have changed in this diff Show More