Compare commits

...

135 Commits

Author SHA1 Message Date
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
181 changed files with 25248 additions and 4434 deletions

View File

@ -52,9 +52,10 @@ _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` | | 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` | | 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 | | Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` | | Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement | | Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
--- ---
@ -113,6 +114,12 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 | | `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch | | `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste | | `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 12) + **Phasenplan AF** 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) | | `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
--- ---

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.32.4, Phasen DE), `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). **78** 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 35 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 AB** 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 AF
- `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 **Layer1Quelle** (`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 **Layer2a**-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 Layer1-/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 Layer1Berechnungen **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` (A1A8) | 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 fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **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 AF
- `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 AF.
**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 AF** 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

@ -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`). 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: **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). 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 | | 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. | | **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. | | **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). | | **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. 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). 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). 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. 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. **Dashboard-Lab-UI** `frontend/src/pages/DashboardLabPage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht. 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 | | 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`). | | 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. | | 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 Pilot-Komponente) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. | | 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/registerPilotLabWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` `id` **exakt** wie im Katalog. | | 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. | | 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. | | 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`. | | 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 790. **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 790.
### 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**: 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. - **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`. 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 ## 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/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). - `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` | | Layout-Pydantic | `backend/dashboard_layout_schema.py` |
| HTTP | `backend/routers/app_dashboard.py` | | HTTP | `backend/routers/app_dashboard.py` |
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` | | Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
| Pilot/Lab-Registrierung | `frontend/src/widgetSystem/registerPilotLabWidgets.js` | | Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` | | 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` 7365
- `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` | | Admin-Systemvorlagen | `backend/routers/admin_csv_templates.py` |
| Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` | | Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` |
| Vorlagen-Validierung (strukturell + Sample) | `backend/csv_parser/template_validator.py` (`validate_csv_template`) | | 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. **Single Source of Truth** für erlaubte Zielfelder, Typen und Duplikat-Keys ist **`module_registry.py`**. Keine parallele Feldliste in Routern duplizieren.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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:** **Bindend:**
1. **Keine fest codierten Tier-Namen** für Widget-Rechte Tiers und Limits kommen aus der DB. 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. 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). 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). 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

@ -10,8 +10,9 @@
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** | > | **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`** | > | **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`** | > | **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`** |
> | **Agent-Einstieg** | **`.claude/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 ## Claude Code Verantwortlichkeiten
@ -99,6 +100,11 @@ frontend/src/
**Branch:** develop **Branch:** develop
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i **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) ### 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) - **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
@ -115,6 +121,21 @@ frontend/src/
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75 - **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75
- **`nutrition_score`:** Registry in `backend/placeholder_registrations/nutrition_score.py`, Import in `placeholder_registrations/__init__.py`; Legacy-Duplikat unter „Scores“ im Platzhalter-Katalog entfernt. - **`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) ### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **MifflinSt 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). - **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **MifflinSt 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 +896,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions| |Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints| |API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen| |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| |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 > 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 from db import get_db, get_cursor
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
def hash_pin(pin: str) -> str: def hash_pin(pin: str) -> str:
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256.""" """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 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. 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: Usage:
@app.get("/api/photos/{id}") @app.get("/api/photos/{id}")
def get_photo(id: str, session: dict = Depends(require_auth_flexible)): def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
... ...
Call with: ?ssetoken=XXX or Header: X-Auth-Token: XXX
Raises: Raises:
HTTPException 401 if not authenticated HTTPException 401 if not authenticated
""" """
session = get_session(x_auth_token or token) session = get_session(x_auth_token or ssetoken)
if not session: if not session:
raise HTTPException(401, "Nicht eingeloggt") raise HTTPException(401, "Nicht eingeloggt")
return session return session

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) 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]: def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
"""Calculate 28-day thigh circumference change (cm)""" """Calculate 28-day thigh circumference change (cm)"""
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28) delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)

View File

@ -47,6 +47,46 @@ def sniff_delimiter(sample_line: str) -> str:
return best 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]: def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
lines: List[str] = [] lines: List[str] = []
for line in text.splitlines(): for line in text.splitlines():
@ -57,6 +97,18 @@ def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
return lines 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( def parse_csv_sample(
text: str, text: str,
delimiter: str | None = None, delimiter: str | None = None,
@ -85,7 +137,7 @@ def parse_csv_sample(
return [], [], delim return [], [], delim
if has_header: 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] data = rows_raw[1 : 1 + max_data_rows]
else: else:
n = len(rows_raw[0]) n = len(rows_raw[0])
@ -103,7 +155,7 @@ def parse_csv_sample(
def normalize_header_for_signature(name: str) -> str: 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"\s+", "_", s)
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s) s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
return s.strip("_") return s.strip("_")
@ -111,7 +163,9 @@ def normalize_header_for_signature(name: str) -> str:
def column_signature(headers: List[str]) -> List[str]: def column_signature(headers: List[str]) -> List[str]:
"""Sortierte normalisierte Spaltennamen für Signatur-Vergleich.""" """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: 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 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( def iter_csv_dict_rows(
text: str, text: str,
delimiter: str, delimiter: str,
@ -205,4 +253,8 @@ def iter_csv_dict_rows(
continue continue
if not any(v and str(v).strip() for v in row.values()): if not any(v and str(v).strip() for v in row.values()):
continue 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,7 @@ from typing import Any
import logging import logging
from csv_parser.core import iter_csv_dict_rows from csv_parser.core import iter_csv_dict_rows, resolve_effective_csv_delimiter
from csv_parser.import_row_processing import ( from csv_parser.import_row_processing import (
aggregate_mapped_rows, aggregate_mapped_rows,
resolve_import_row_processing, resolve_import_row_processing,
@ -23,14 +23,6 @@ from csv_parser.type_converter import build_row_after_mapping
logger = logging.getLogger(__name__) 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): 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).""" """Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock)."""
@ -105,7 +97,8 @@ def run_universal_csv_import(
if tc is not None and not isinstance(tc, dict): if tc is not None and not isinstance(tc, dict):
tc = None 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) has_header = mapping.get("has_header", True)
if module == "nutrition": if module == "nutrition":
@ -814,6 +807,17 @@ def _import_activity(
error_details: list, error_details: list,
affected_ids: dict, affected_ids: dict,
) -> dict[str, int]: ) -> 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 rows_total = 0
inserted = 0 inserted = 0
updated = 0 updated = 0
@ -885,6 +889,7 @@ def _import_activity(
wtype = str(activity_type).strip() wtype = str(activity_type).strip()
iso = date_d.isoformat() 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. # Pro Zeile: bei SQL-Fehler sonst „current transaction is aborted“ bis Xact-Ende.
cur.execute("SAVEPOINT csv_activity_row") cur.execute("SAVEPOINT csv_activity_row")
@ -892,113 +897,79 @@ def _import_activity(
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity( training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
cur, wtype, profile_id cur, wtype, profile_id
) )
cur.execute( registry_updates = activity_csv_registry_updates_from_mapped(mapped)
""" existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t)
SELECT id FROM activity_log
WHERE profile_id = %s AND date = %s AND start_time = %s
""",
(profile_id, iso, start_key),
)
existing = cur.fetchone()
if existing: if existing_id:
eid = existing["id"] upd = {
cur.execute( "start_time": workout_start_t,
""" "end_time": end_str or None,
UPDATE activity_log "activity_type": wtype,
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,
"duration_min": duration_min, "duration_min": duration_min,
"kcal_active": kcal_a,
"kcal_resting": kcal_r,
"hr_avg": hr_a, "hr_avg": hr_a,
"hr_max": hr_m, "hr_max": hr_m,
"distance_km": dist, "distance_km": dist,
"kcal_active": kcal_a, "training_type_id": training_type_id,
"kcal_resting": kcal_r, "training_category": training_category,
"rpe": None, "training_subcategory": training_subcategory,
"pace_min_per_km": None, "source": "csv",
"cadence": None,
"elevation_gain": None,
} }
_evaluate_and_save_activity(cur, aid, activity_dict, training_type_id, profile_id) upd.update(registry_updates)
except Exception as eval_err: update_activity_columns(cur, profile_id, existing_id, upd)
logger.warning("[csv activity] Auto-Eval fehlgeschlagen: %s", eval_err) 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") cur.execute("RELEASE SAVEPOINT csv_activity_row")
except Exception as e: except Exception as e:
try: try:

View File

@ -37,12 +37,16 @@ def validate_import_row_processing(
module: str, module: str,
spec: Mapping[str, Any], spec: Mapping[str, Any],
field_mappings: Mapping[str, Any], field_mappings: Mapping[str, Any],
cur=None,
) -> None: ) -> None:
"""Wirft ValueError bei ungültiger Konfiguration.""" """Wirft ValueError bei ungültiger Konfiguration."""
mod = get_module_definition(module) mod = get_module_definition(module)
if not mod: if not mod:
raise ValueError(f"Unbekanntes Modul: {module}") raise ValueError(f"Unbekanntes Modul: {module}")
allowed = set(mod.get("fields") or []) 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")} fm_targets = {str(v) for v in field_mappings.values() if v and v not in ("-", "_skip")}
group_by = spec.get("group_by") or [] group_by = spec.get("group_by") or []

View File

@ -127,13 +127,19 @@ def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | No
return None 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, {}) aliases = _MODULE_HEADER_ALIASES.get(module, {})
mod = get_module_definition(module) mod = get_module_definition(module)
if not mod: if not mod:
return None return None
field_order = list(mod["fields"].keys()) order = field_order if field_order is not None else list(mod["fields"].keys())
for db_field in field_order: for db_field in order:
if db_field in used: if db_field in used:
continue continue
tokens = aliases.get(db_field, frozenset()) tokens = aliases.get(db_field, frozenset())
@ -152,6 +158,8 @@ def suggest_field_mappings(
headers: list[str], headers: list[str],
module: str, module: str,
seed_fm: Mapping[str, str] | None = None, seed_fm: Mapping[str, str] | None = None,
*,
effective_fields: Mapping[str, Any] | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
""" """
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'. Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
@ -164,13 +172,16 @@ def suggest_field_mappings(
if not mod: if not mod:
return {h: "-" for h in headers} 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} fm: dict[str, str] = {h: "-" for h in headers}
used: set[str] = set() used: set[str] = set()
if seed_fm: if seed_fm:
for h in headers: for h in headers:
db = _match_seed_to_db_field(h, seed_fm) 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 fm[h] = db
used.add(db) used.add(db)
@ -178,7 +189,7 @@ def suggest_field_mappings(
if fm[h] != "-": if fm[h] != "-":
continue continue
norm = _norm_key(h) norm = _norm_key(h)
db = _alias_suggest(norm, module, used) db = _alias_suggest(norm, module, used, field_order=field_order)
if db: if db:
fm[h] = db fm[h] = db
used.add(db) used.add(db)
@ -190,6 +201,8 @@ def build_type_conversions_for_mapping(
module: str, module: str,
field_mappings: Mapping[str, str], field_mappings: Mapping[str, str],
seed_tc: Mapping[str, Any] | None = None, seed_tc: Mapping[str, Any] | None = None,
*,
effective_fields: Mapping[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults.""" """type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
if module == "sleep": if module == "sleep":
@ -198,6 +211,7 @@ def build_type_conversions_for_mapping(
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {}) defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
out: dict[str, Any] = {} out: dict[str, Any] = {}
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")} 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: if seed_tc:
for k, v in seed_tc.items(): for k, v in seed_tc.items():
@ -208,6 +222,20 @@ def build_type_conversions_for_mapping(
if t not in out and t in defaults: if t not in out and t in defaults:
out[t] = deepcopy(defaults[t]) 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) _apply_energy_kj_hint_from_headers(module, field_mappings, out)
return out return out

View File

@ -34,19 +34,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": { "activity": {
"table": "activity_log", "table": "activity_log",
"fields": { "fields": {
"date": {"type": "date", "required": False}, "date": {"type": "date", "required": False, "label_de": "Datum"},
"start_time": {"type": "datetime", "required": False}, "start_time": {
"end_time": {"type": "datetime", "required": False}, "type": "datetime",
"activity_type": {"type": "string", "required": True}, "required": False,
"duration_min": {"type": "float", "required": False, "min": 0}, "label_de": "Start (Datum/Uhrzeit)",
"kcal_active": {"type": "float", "required": False, "unit": "kcal"}, },
"kcal_resting": {"type": "float", "required": False, "unit": "kcal"}, "end_time": {"type": "datetime", "required": False, "label_de": "Ende (Datum/Uhrzeit)"},
"distance_km": {"type": "float", "required": False, "unit": "km"}, "activity_type": {"type": "string", "required": True, "label_de": "Trainingsart / Workout-Typ"},
"hr_avg": {"type": "float", "required": False, "min": 30, "max": 220}, "duration_min": {"type": "float", "required": False, "min": 0, "label_de": "Dauer (Minuten)"},
"hr_max": {"type": "float", "required": False, "min": 30, "max": 220}, "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 (110)"},
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
}, },
"derive_date_from_datetime_field": "start_time", "derive_date_from_datetime_field": "start_time",
"duplicate_key": ["profile_id", "date", "start_time"], "duplicate_key": ["profile_id", "date", "start_time"],
@ -125,13 +145,16 @@ def list_modules() -> list[str]:
return sorted(MODULE_DEFINITIONS.keys()) 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.""" """Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld."""
mod = get_module_definition(module) mod = get_module_definition(module)
if not mod: if not mod:
raise ValueError(f"Unbekanntes Modul: {module}") raise ValueError(f"Unbekanntes Modul: {module}")
fields = cast(dict, mod["fields"]) fields = cast(dict, mod["fields"])
allowed = set(fields.keys()) 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: if not allowed:
for _csv_col, db_field in field_mappings.items(): for _csv_col, db_field in field_mappings.items():
if db_field not in ("", None, "-", "_skip"): if db_field not in ("", None, "-", "_skip"):

View File

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

View File

@ -14,7 +14,7 @@ from typing import Any, Mapping, Sequence
from dateutil import parser as dateutil_parser 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 from csv_parser.field_units import factor_source_to_canonical
# Alias → strptime (JSON in Kleinbuchstaben) # 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 CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert
(wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung, (wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
dann Abgleich aller Vorlagen-Keys über deren Normalform. 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) v = field_mappings.get(csv_col)
if v: if v:
return v if v not in ("-", "_skip") else None 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(): for k, fv in field_mappings.items():
if normalize_header_for_signature(str(k)) == norm: if normalize_header_for_signature(str(k)) == norm:
return fv if fv not in ("-", "_skip") else None 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 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. Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
""" """
from __future__ import annotations from __future__ import annotations
import copy
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
@ -25,12 +26,13 @@ __all__ = [
"coalesce_effective_layout", "coalesce_effective_layout",
"default_layout_dict", "default_layout_dict",
"lab_default_layout_dict", "lab_default_layout_dict",
"merge_missing_catalog_widgets",
"product_default_layout_dict", "product_default_layout_dict",
] ]
def lab_default_layout_dict() -> dict[str, Any]: 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 on = DEFAULT_LAB_WIDGET_IDS
return { return {
"version": 1, "version": 1,
@ -52,6 +54,25 @@ def default_layout_dict() -> dict[str, Any]:
return product_default_layout_dict() 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): class DashboardWidgetEntry(BaseModel):
id: str = Field(min_length=1, max_length=64) id: str = Field(min_length=1, max_length=64)
enabled: bool = True enabled: bool = True

View File

@ -14,12 +14,18 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"body_overview", "body_overview",
"body_history_viz",
"nutrition_history_viz",
"fitness_history_viz",
"recovery_history_viz",
"history_overview_viz",
"activity_overview", "activity_overview",
"kpi_board", "kpi_board",
"quick_capture", "quick_capture",
"trend_kcal_weight", "trend_kcal_weight",
"nutrition_detail_charts", "nutrition_detail_charts",
"recovery_charts_panel", "recovery_charts_panel",
"report_export",
}) })
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({ _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_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$") _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: def _config_json_size_bytes(config: dict[str, Any]) -> int:
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) 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]: def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
if raw is None: if raw is None:
return {} raw = {}
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein") raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES: 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)") 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 widget_id not in WIDGETS_ALLOWING_CONFIG:
if raw:
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") 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": if widget_id == "body_overview":
return _validate_chart_days_only(raw, label="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": if widget_id == "activity_overview":
return _validate_chart_days_only(raw, label="activity_overview") return _validate_chart_days_only(raw, label="activity_overview")
if widget_id == "kpi_board": 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") return _validate_chart_days_only(raw, label="nutrition_detail_charts")
if widget_id == "recovery_charts_panel": if widget_id == "recovery_charts_panel":
return _validate_chart_days_only(raw, label="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") 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") 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 (C1C3) oder Treiber (C4) muss sichtbar sein"
)
return out
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
allowed = frozenset({"chart_days"}) allowed = frozenset({"chart_days"})
unknown = set(raw) - allowed 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} 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 13 sein")
if not isinstance(v, int):
raise ValueError(f"{label}: capture_scale muss ganze Zahl 13 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_hip_28d_delta',
'calculate_chest_28d_delta', 'calculate_chest_28d_delta',
'calculate_arm_28d_delta', 'calculate_arm_28d_delta',
'calculate_arm_relaxed_28d_delta',
'calculate_thigh_28d_delta', 'calculate_thigh_28d_delta',
'calculate_waist_hip_ratio', 'calculate_waist_hip_ratio',
'calculate_recomposition_quadrant', '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_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden) - get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext - 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. All functions return structured data (dict) without formatting.
Use placeholder_resolver.py for formatted strings for AI. 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 from datetime import datetime, timedelta, date, time
import statistics import statistics
from db import get_db, get_cursor, r2d 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.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( def get_activity_summary_data(
@ -123,7 +129,8 @@ def get_activity_detail_data(
"duration_min": int, "duration_min": int,
"kcal_active": int, "kcal_active": int,
"hr_avg": int | None, "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( cur.execute(
"""SELECT """SELECT
id,
date, date,
activity_type, activity_type,
duration_min, duration_min,
@ -152,7 +160,7 @@ def get_activity_detail_data(
WHERE profile_id=%s AND date >= %s WHERE profile_id=%s AND date >= %s
ORDER BY date DESC ORDER BY date DESC
LIMIT %s""", LIMIT %s""",
(profile_id, cutoff, limit) (profile_id, cutoff, limit),
) )
rows = cur.fetchall() rows = cur.fetchall()
@ -161,19 +169,24 @@ def get_activity_detail_data(
"activities": [], "activities": [],
"total_count": 0, "total_count": 0,
"confidence": "insufficient", "confidence": "insufficient",
"days_analyzed": days "days_analyzed": days,
} }
activities = [] activities = []
for row in rows: for row in rows:
activities.append({ activities.append(
"date": row['date'], {
"activity_type": row['activity_type'], "id": str(row["id"]),
"duration_min": safe_int(row['duration_min']), "date": row["date"],
"kcal_active": safe_int(row['kcal_active']), "activity_type": row["activity_type"],
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None, "duration_min": safe_int(row["duration_min"]),
"training_category": row.get('training_category') "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") confidence = calculate_confidence(len(activities), days, "general")
@ -181,7 +194,7 @@ def get_activity_detail_data(
"activities": activities, "activities": activities,
"total_count": len(activities), "total_count": len(activities),
"confidence": confidence, "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 return int(row['session_count']) if row else None
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]: def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]:
"""Calculate percentage of quality sessions (good or better) last 28 days""" """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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute(""" cur.execute(
"""
SELECT SELECT
COUNT(*) as total, COUNT(*) as total,
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
FROM activity_log FROM activity_log
WHERE profile_id = %s WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '28 days' AND date >= %s
""", (profile_id,)) """,
(profile_id, cutoff),
)
row = cur.fetchone() row = cur.fetchone()
if not row or row['total'] == 0: if not row or row["total"] == 0:
return None return None
pct = (row['quality_count'] / row['total']) * 100 pct = (row["quality_count"] / row["total"]) * 100
return int(pct) return int(pct)
@ -482,11 +501,12 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
# A5: Load Monitoring (Proxy-based) # 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) Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage).
Formula: duration × intensity_factor × quality_factor
""" """
if days < 1:
days = 7
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0} intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
quality_factors = { quality_factors = {
'excellent': 1.15, 'excellent': 1.15,
@ -499,12 +519,15 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute(""" cur.execute(
"""
SELECT duration_min, hr_avg, rpe SELECT duration_min, hr_avg, rpe
FROM activity_log FROM activity_log
WHERE profile_id = %s WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '7 days' AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day')
""", (profile_id,)) """,
(profile_id, days),
)
activities = cur.fetchall() 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) load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
total_load += load 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]: 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). Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4). 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) days = max(weeks * 7, 7)
with get_db() as conn: with get_db() as conn:
@ -1112,6 +1144,7 @@ def get_training_sessions_recent_weeks_data(
(profile_id, cutoff), (profile_id, cutoff),
) )
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
enrich_sessions_with_metrics(cur, rows)
if not rows: if not rows:
return { return {
@ -1121,6 +1154,8 @@ def get_training_sessions_recent_weeks_data(
"days_loaded": days, "days_loaded": days,
"session_count": 0, "session_count": 0,
"confidence": "insufficient", "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 kcal_f = float(kcal) if kcal is not None else None
hr_a = r.get("hr_avg") hr_a = r.get("hr_avg")
hr_m = r.get("hr_max") hr_m = r.get("hr_max")
sm_compact = session_metrics_list_to_key_value_compact(r.get("session_metrics"))
by_week[wk].append( by_week[wk].append(
{ {
"id": str(r["id"]),
"date": d, "date": d,
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None, "start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
"activity_type": r.get("activity_type"), "activity_type": r.get("activity_type"),
"training_category": r.get("training_category"), "training_category": r.get("training_category"),
"training_type_name": r.get("training_type_name"), "training_type_name": r.get("training_type_name"),
"duration_min": dur_f, "duration_min": normalize_prompt_number(dur_f) if dur_f is not None else None,
"kcal_active": kcal_f, "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_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, "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, "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, "days_loaded": days,
"session_count": len(rows), "session_count": len(rows),
"confidence": confidence, "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,%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,524,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_hip', 'Hüfte'),
('c_thigh', 'Oberschenkel'), ('c_thigh', 'Oberschenkel'),
('c_calf', 'Wade'), ('c_calf', 'Wade'),
('c_arm', 'Arm') ('c_arm', 'Oberarm kontrahiert'),
('c_arm_relaxed', 'Oberarm'),
] ]
measurements = [] measurements = []
@ -401,7 +402,7 @@ def get_circumference_summary_data(
}) })
# Calculate confidence based on how many points we have # 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: if not measurements:
return { 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]: 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) 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]: def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
"""Calculate 28-day thigh circumference change (cm)""" """Calculate 28-day thigh circumference change (cm)"""
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28) 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 C1C3 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 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 datetime import datetime, timedelta, date
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
import statistics 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]: 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: C1: Tagesbilanz (kcal TDEE wie ``estimate_tdee_kcal_from_latest_weight``) vs. ΔGewicht [tt+L], L1.
var1: 'energy', 'protein', 'training_load' C2: Protein (g) vs. ΔMager [tt+L] aus ``build_merged_daily_nutrition_body_rows``, L1.
var2: 'weight', 'lbm', 'hrv', 'rhr' C3: Summe ``duration_min`` pro Tag vs. HRV oder Ruhepuls am Tag t+L (L0).
max_lag_days: Maximum lag to test
Returns: 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.
'best_lag': X, # days
'correlation': 0.XX, # -1 to 1
'direction': 'positive'/'negative'/'none',
'confidence': 'high'/'medium'/'low',
'data_points': N
}
""" """
if var1 == 'energy' and var2 == 'weight': v1 = (var1 or "").strip().lower()
return _correlate_energy_weight(profile_id, max_lag_days) if v1 in ("energy", "energy_balance"):
elif var1 == 'protein' and var2 == 'lbm': v1n = "energy"
return _correlate_protein_lbm(profile_id, max_lag_days) elif v1 in ("training_load", "load"):
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']: v1n = "training_load"
return _correlate_load_vitals(profile_id, var2, max_lag_days) 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: else:
return None 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]: def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
""" """
Correlate energy balance with weight change Pearson: Tagesbilanz (kcal TDEE wie nutrition_metrics) vs. Gewichtsdifferenz
Test lags: 0, 3, 7, 10, 14 days vom Tag t zu Tag t+L (L = 0 max_lag). Bestes Lag nach maximalem |r|.
""" """
with get_db() as conn: tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
cur = get_cursor(conn) if tdee is None or float(tdee) <= 0:
# 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:
return { return {
'best_lag': None, "best_lag": None,
'correlation': None, "correlation": None,
'direction': 'none', "direction": "none",
'confidence': 'low', "confidence": "insufficient",
'data_points': len(data), "data_points": 0,
'reason': 'Insufficient data (<30 days)' "interpretation": "Keine TDEE-Schätzung möglich (Gewicht/Demografie).",
"reason": "no_tdee",
} }
# Calculate 7d rolling energy balance tdee_f = float(tdee)
# (Simplified - actual implementation would need TDEE estimation) 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 { return {
'best_lag': 7, "best_lag": None,
'correlation': -0.45, # Placeholder "correlation": None,
'direction': 'negative', # Higher deficit = lower weight (expected) "direction": "none",
'confidence': 'medium', "confidence": "insufficient",
'data_points': len(data) "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]: 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 { return {
'best_lag': 0, "best_lag": None,
'correlation': 0.32, # Placeholder "correlation": None,
'direction': 'positive', "direction": "none",
'confidence': 'medium', "confidence": "insufficient",
'data_points': 28 "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]: def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
""" """
Correlate training load with HRV or RHR Pearson: Tages-Trainingslast (Summe duration_min) vs. Vitals (HRV ms oder Ruhepuls)
Test lags: 1, 2, 3 days am Kalendertag t+Lag (typisch: Belastung am Vortag, Vitalwert am Folgetag bei Lag 1).
""" """
# TODO: Implement full correlation calculation col = "hrv" if vital == "hrv" else "resting_hr"
if vital == 'hrv': 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 { return {
'best_lag': 1, "best_lag": None,
'correlation': -0.38, # Negative = high load reduces HRV (expected) "correlation": None,
'direction': 'negative', "direction": "none",
'confidence': 'medium', "confidence": "insufficient",
'data_points': 25 "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 { return {
'best_lag': 1, "best_lag": lag_b,
'correlation': 0.42, # Positive = high load increases RHR (expected) "correlation": round(r_b, 4),
'direction': 'positive', "direction": direction,
'confidence': 'medium', "confidence": conf,
'data_points': 25 "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,81,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: 150300 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 (0100)",
"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,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 (C1C4).
"""
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,62,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,62,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 2535 %."
),
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
"hoverBody": (
f"Empfehlung oft 2535%. 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

@ -20,6 +20,7 @@ Phase 0c: Multi-Layer Architecture
Version: 1.0 Version: 1.0
""" """
import statistics
from typing import Dict, List, Optional from typing import Dict, List, Optional
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from db import get_db, get_cursor, r2d 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( def get_nutrition_average_data(
profile_id: str, profile_id: str,
days: int = 30 days: int = 30,
*,
all_history: bool = False,
) -> Dict: ) -> Dict:
""" """
Get average nutrition values for all macros. Get average nutrition values for all macros.
@ -136,11 +139,18 @@ def get_nutrition_average_data(
""" """
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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. # 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( cur.execute(
"""SELECT f"""SELECT
AVG(daily_kcal) AS kcal_avg, AVG(daily_kcal) AS kcal_avg,
AVG(daily_protein) AS protein_avg, AVG(daily_protein) AS protein_avg,
AVG(daily_carbs) AS carbs_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(carbs_g), 0)::float AS daily_carbs,
COALESCE(SUM(fat_g), 0)::float AS daily_fat COALESCE(SUM(fat_g), 0)::float AS daily_fat
FROM nutrition_log FROM nutrition_log
WHERE profile_id=%s AND date >= %s {inner_where}
GROUP BY date GROUP BY date
) AS daily""", ) AS daily""",
(profile_id, cutoff), params,
) )
row = cur.fetchone() row = cur.fetchone()
@ -494,8 +504,6 @@ def get_macro_consistency_data(
"data_points": len(rows) "data_points": len(rows)
} }
import statistics
protein_pcts = [] protein_pcts = []
carbs_pcts = [] carbs_pcts = []
fat_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) # 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 (R1R5) 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 0100 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 0100, 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 (0100)",
"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 db import get_db, get_cursor
from data_layer.utils import calculate_confidence, safe_float, safe_int 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]]: def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]:
"""JSONB kann dict/list/str sein; ungültig → None.""" """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) return round(avg_hours, 1)
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]: def _row_date_as_date(d: Any) -> Optional[date]:
""" if d is None:
Calculate accumulated sleep debt (hours) last 14 days return None
Assumes 7.5h target per night if isinstance(d, datetime):
""" return d.date()
target_hours = 7.5 if isinstance(d, date):
return d
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
return None 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]: 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 R1R5 (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 __future__ import annotations
from datetime import date
from decimal import Decimal from decimal import Decimal
from typing import Any, Optional from typing import Any, Optional
from db import get_cursor, get_db, r2d 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]: def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]:
"""Normalize DB row dict for JSON (dates → ISO, Decimal → float).""" """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) tiles = build_summary_tiles_from_ranked_rows(raw_rows)
return {"tiles": tiles} 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,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. 60100 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. 1220/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"), user=os.getenv("DB_USER", "mitai"),
password=os.getenv("DB_PASSWORD", "") 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 @contextmanager
@ -171,7 +173,7 @@ def init_db():
) as table_exists ) as table_exists
""") """)
if not cur.fetchone()['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 return
# Ensure "pipeline" master prompt exists # Ensure "pipeline" master prompt exists
@ -189,7 +191,7 @@ def init_db():
) )
""") """)
conn.commit() conn.commit()
print(" Pipeline master prompt created") print("[OK] Pipeline master prompt created")
except Exception as e: 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 # Don't fail startup - prompt can be created manually

View File

@ -34,8 +34,10 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat
from routers import workflows # Phase 2 Workflow Engine - Execution from routers import workflows # Phase 2 Workflow Engine - Execution
from routers import reference_values # Persönliche Referenzwerte (Profil) from routers import reference_values # Persönliche Referenzwerte (Profil)
from routers import admin_reference_value_types # Admin: Referenzwert-Typen 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 csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
# ── App Configuration ───────────────────────────────────────────────────────── # ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -66,7 +68,7 @@ async def startup_event():
try: try:
init_db() init_db()
except Exception as e: 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 # Don't crash on startup - can be created manually
# Apply v9c migration if needed # Apply v9c migration if needed
@ -74,7 +76,7 @@ async def startup_event():
from apply_v9c_migration import apply_migration from apply_v9c_migration import apply_migration
apply_migration() apply_migration()
except Exception as e: except Exception as e:
print(f"⚠️ v9c migration failed (non-fatal): {e}") print(f"[WARN] v9c migration failed (non-fatal): {e}")
# ── Register Routers ────────────────────────────────────────────────────────── # ── Register Routers ──────────────────────────────────────────────────────────
app.include_router(auth.router) # /api/auth/* app.include_router(auth.router) # /api/auth/*
@ -126,8 +128,11 @@ 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(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(admin_reference_value_types.router) # /api/admin/reference-value-types
app.include_router(app_dashboard.router) # /api/app/dashboard-layout 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(csv_import.router) # /api/csv/* (Issue #21)
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21) app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
# ── Health Check ────────────────────────────────────────────────────────────── # ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/") @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 $$;

View File

@ -0,0 +1,213 @@
-- Migration 055: Seed training_category_parameter (all categories × parameters with activity_log source_field)
-- + idempotent backfill activity_log → activity_session_metrics (EAV)
-- Date: 2026-04-15
-- SAFE: INSERT … ON CONFLICT DO NOTHING only; no DELETE/TRUNCATE on activity_log.
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
--1) Jede in training_types vorkommende Kategorie erhält alle aktiven Parameter mit source_field (Spalte in activity_log).
INSERT INTO training_category_parameter (
training_category,
training_parameter_id,
sort_order,
required,
ui_group
)
SELECT
tc.training_category,
tp.id,
ROW_NUMBER() OVER (
PARTITION BY tc.training_category
ORDER BY tp.category, tp.id
),
false,
NULL
FROM (
SELECT DISTINCT category AS training_category
FROM training_types
WHERE category IS NOT NULL AND trim(category) <> ''
) tc
CROSS JOIN training_parameters tp
WHERE tp.is_active = true
AND tp.source_field IS NOT NULL
AND trim(tp.source_field) <> ''
ON CONFLICT (training_category, training_parameter_id) DO NOTHING;
-- 2) Backfill: activity_log-Spalten → EAV (nur wenn noch keine Zeile existiert)
-- duration_min → integer
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT
a.id,
tp.id,
NULL,
ROUND(a.duration_min::numeric)::bigint,
NULL,
NULL,
NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'duration_min' AND tp.is_active = true
WHERE a.duration_min IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- distance_km → float
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.distance_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'distance_km' AND tp.is_active = true
WHERE a.distance_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- kcal_active
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.kcal_active::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_active' AND tp.is_active = true
WHERE a.kcal_active IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.kcal_resting::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_resting' AND tp.is_active = true
WHERE a.kcal_resting IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- hr_avg / hr_max → keys avg_hr, max_hr
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.hr_avg::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_hr' AND tp.is_active = true
WHERE a.hr_avg IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, ROUND(a.hr_max::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'max_hr' AND tp.is_active = true
WHERE a.hr_max IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- rpe
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.rpe::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'rpe' AND tp.is_active = true
WHERE a.rpe IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- min_hr (Spalte hr_min nach Migration 014)
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true
WHERE a.hr_min IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true
WHERE a.pace_min_per_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true
WHERE a.cadence IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true
WHERE a.avg_power IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true
WHERE a.elevation_gain IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true
WHERE a.temperature_celsius IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true
WHERE a.humidity_percent IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true
WHERE a.avg_hr_percent IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true
WHERE a.kcal_per_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
DO $$
BEGIN
RAISE NOTICE 'Migration 055: category parameter seed + EAV backfill from activity_log (no row deletes)';
END $$;

View File

@ -0,0 +1,35 @@
-- Migration 056: kcal_per_km Trigger — manuelles Leeren bei UPDATE erlauben
-- Problem: calculate_avg_hr_percent (014) setzte bei jedem UPDATE kcal_per_km aus
-- kcal_active/distance_km, sobald beide gesetzt waren — ein bewusst geleertes Feld
-- erschien sofort wieder.
-- Lösung: automatische Ableitung nur noch bei INSERT (wenn kcal_per_km noch NULL ist).
CREATE OR REPLACE FUNCTION calculate_avg_hr_percent()
RETURNS TRIGGER AS $$
DECLARE
user_max_hr INTEGER;
BEGIN
SELECT hf_max INTO user_max_hr
FROM profiles
WHERE id = NEW.profile_id;
IF NEW.hr_avg IS NOT NULL AND user_max_hr IS NOT NULL AND user_max_hr > 0 THEN
NEW.avg_hr_percent := (NEW.hr_avg::float / user_max_hr::float) * 100;
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW.kcal_active IS NOT NULL AND NEW.distance_km IS NOT NULL AND NEW.distance_km > 0 THEN
IF NEW.kcal_per_km IS NULL THEN
NEW.kcal_per_km := NEW.kcal_active::float / NEW.distance_km;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
RAISE NOTICE '✓ Migration 056: kcal_per_km nur noch bei INSERT auto-abgeleitet';
END $$;

View File

@ -0,0 +1,115 @@
-- Migration 057: Kanon EAV-primär für erweiterte Trainingsmetriken
-- Date: 2026-04-15
-- activity_log-Spalten bleiben erhalten (Lesefallback / API); training_parameters.source_field
-- wird für diese Keys entfernt. Idempotenter EAV-Backfill aus Spalten (wie 055), dann source_field NULL.
-- Siehe: backend/data_layer/activity_data_canon.py
-- min_hr (Spalte hr_min)
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true
WHERE a.hr_min IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true
WHERE a.pace_min_per_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true
WHERE a.cadence IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true
WHERE a.avg_power IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true
WHERE a.elevation_gain IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true
WHERE a.temperature_celsius IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true
WHERE a.humidity_percent IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true
WHERE a.avg_hr_percent IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
)
SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true
WHERE a.kcal_per_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
UPDATE training_parameters
SET source_field = NULL
WHERE key IN (
'min_hr',
'pace_min_per_km',
'cadence',
'avg_power',
'elevation_gain',
'temperature_celsius',
'humidity_percent',
'avg_hr_percent',
'kcal_per_km'
);
DO $$
BEGIN
RAISE NOTICE 'Migration 057: EAV-primary canon — backfill + source_field cleared for extended metrics';
END $$;

View File

@ -0,0 +1,4 @@
-- EXIF-Aufnahmezeit (optional); Sortierung / Anzeige
ALTER TABLE photos ADD COLUMN IF NOT EXISTS taken_at TIMESTAMPTZ;
COMMENT ON COLUMN photos.taken_at IS 'Aufnahmezeit aus EXIF (DateTimeOriginal o.ä.), Zeitzone siehe PHOTO_EXIF_TIMEZONE';

View File

@ -0,0 +1,5 @@
-- Zusätzlicher Umfang: Oberarm entspannt (c_arm = historisch / Oberarm kontrahiert)
ALTER TABLE circumference_log ADD COLUMN IF NOT EXISTS c_arm_relaxed NUMERIC(5,2);
COMMENT ON COLUMN circumference_log.c_arm IS 'Oberarmumfang kontrahiert/angespannt (bestehende Daten)';
COMMENT ON COLUMN circumference_log.c_arm_relaxed IS 'Oberarmumfang entspannt';

View File

@ -0,0 +1,11 @@
-- Migration 060: Strukturierter Bericht (Profil JSON pro Nutzerprofil, unabhängig vom Dashboard-Layout)
CREATE TABLE IF NOT EXISTS report_profiles (
profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_report_profiles_updated ON report_profiles(updated_at);
COMMENT ON TABLE report_profiles IS 'Konfigurierbarer PDF-Bericht v1 (Blöcke: section, chart, ai_insight); Rendering serverseitig aus Datenlayer';

View File

@ -0,0 +1,24 @@
-- Migration 061: Mehrere benannte PDF-Berichte pro Nutzerprofil; Daten von report_profiles übernehmen.
CREATE TABLE IF NOT EXISTS report_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Bericht',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
sort_order INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_report_definitions_profile_sort
ON report_definitions (profile_id, sort_order);
COMMENT ON TABLE report_definitions IS 'Mehrere strukturierte PDF-Berichte pro Profil (payload = ReportProfilePayload v1)';
INSERT INTO report_definitions (profile_id, name, payload, sort_order)
SELECT rp.profile_id, 'Standard', rp.payload, 0
FROM report_profiles rp
WHERE NOT EXISTS (
SELECT 1 FROM report_definitions rd WHERE rd.profile_id = rp.profile_id
);
DROP TABLE IF EXISTS report_profiles;

View File

@ -3,8 +3,9 @@ Pydantic Models for Mitai Jinkendo API
Data validation schemas for request/response bodies. Data validation schemas for request/response bodies.
""" """
from typing import Optional from typing import Any, List, Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
# ── Profile Models ──────────────────────────────────────────────────────────── # ── Profile Models ────────────────────────────────────────────────────────────
@ -49,6 +50,7 @@ class CircumferenceEntry(BaseModel):
c_thigh: Optional[float] = None c_thigh: Optional[float] = None
c_calf: Optional[float] = None c_calf: Optional[float] = None
c_arm: Optional[float] = None c_arm: Optional[float] = None
c_arm_relaxed: Optional[float] = None
notes: Optional[str] = None notes: Optional[str] = None
photo_id: Optional[str] = None photo_id: Optional[str] = None
@ -82,8 +84,17 @@ class ActivityEntry(BaseModel):
kcal_resting: Optional[float] = None kcal_resting: Optional[float] = None
hr_avg: Optional[float] = None hr_avg: Optional[float] = None
hr_max: Optional[float] = None hr_max: Optional[float] = None
hr_min: Optional[int] = None # DB-Spalte hr_min (Parameter min_hr)
distance_km: Optional[float] = None distance_km: Optional[float] = None
rpe: Optional[int] = None rpe: Optional[int] = None
pace_min_per_km: Optional[float] = None
cadence: Optional[int] = None
avg_power: Optional[int] = None
elevation_gain: Optional[int] = None
temperature_celsius: Optional[float] = None
humidity_percent: Optional[int] = None
avg_hr_percent: Optional[float] = None
kcal_per_km: Optional[float] = None
source: Optional[str] = 'manual' source: Optional[str] = 'manual'
notes: Optional[str] = None notes: Optional[str] = None
training_type_id: Optional[int] = None # v9d: Training type categorization training_type_id: Optional[int] = None # v9d: Training type categorization
@ -91,6 +102,17 @@ class ActivityEntry(BaseModel):
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
class ActivityMetricValue(BaseModel):
parameter_key: str
value: Any
class ActivityMetricsReplace(BaseModel):
"""Voller Ersatz der EAV-Metriken für eine Session (siehe Agent-Guide)."""
metrics: List[ActivityMetricValue] = Field(default_factory=list)
class NutritionDay(BaseModel): class NutritionDay(BaseModel):
date: str date: str
kcal: Optional[float] = None kcal: Optional[float] = None

103
backend/photo_exif.py Normal file
View File

@ -0,0 +1,103 @@
"""
EXIF-Aufnahmedatum/-zeit aus Bildbytes (JPEG, PNG mit EXIF, ).
EXIF enthält keine Zeitzone; wir interpretieren die Wandzeit in PHOTO_EXIF_TIMEZONE
(Standard Europe/Berlin) und speichern als TIMESTAMPTZ (UTC in PostgreSQL).
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from io import BytesIO
from typing import Optional
from zoneinfo import ZoneInfo
from PIL import Image
EXIF_DATETIME_FMT = "%Y:%m:%d %H:%M:%S"
_EXIF_IFD = 0x8769
_EXIF_DATETIME_TAGS = (36867, 36868) # DateTimeOriginal, DateTimeDigitized
_TAG_DATETIME_MAIN = 306
def extract_taken_at_from_image_bytes(raw: bytes) -> Optional[datetime]:
"""
Liest DateTimeOriginal (o. ä.) aus EXIF und gibt ein timezone-aware datetime zurück,
oder None wenn nicht ermittelbar.
"""
try:
img = Image.open(BytesIO(raw))
except Exception:
return None
try:
naive = _extract_exif_naive_datetime(img)
finally:
try:
img.close()
except Exception:
pass
if naive is None:
return None
tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin")
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Berlin")
return naive.replace(tzinfo=tz)
def _extract_exif_naive_datetime(img: Image.Image) -> Optional[datetime]:
exif = img.getexif()
if not exif:
return None
strings: list[str] = []
try:
exif_ifd = exif.get_ifd(_EXIF_IFD)
except Exception:
exif_ifd = None
if exif_ifd:
for tag in _EXIF_DATETIME_TAGS:
v = exif_ifd.get(tag)
if isinstance(v, str) and v.strip():
strings.append(v)
v = exif.get(_TAG_DATETIME_MAIN)
if isinstance(v, str) and v.strip():
strings.append(v)
for s in strings:
dt = _parse_exif_datetime_str(s)
if dt:
return dt
return None
def _parse_exif_datetime_str(s: str) -> Optional[datetime]:
s = (s or "").strip()
if not s:
return None
try:
return datetime.strptime(s, EXIF_DATETIME_FMT)
except ValueError:
return None
def taken_at_from_file_last_modified_ms(ms_raw: Optional[str]) -> Optional[datetime]:
"""
Browser sendet File.lastModified (ms seit UTC-Epoch), echte Dateirevision auf der Platte.
Wird als echter Zeitpunkt interpretiert und nach PHOTO_EXIF_TIMEZONE für Anzeige gelegt
(konsistent zu EXIF-Wandzeit).
"""
if not ms_raw or not str(ms_raw).strip():
return None
try:
ms = int(str(ms_raw).strip())
except ValueError:
return None
if ms <= 0:
return None
instant_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin")
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("Europe/Berlin")
return instant_utc.astimezone(tz)

View File

@ -19,6 +19,7 @@ from . import profil_zeitraum
from . import phase_0b_meta_scores from . import phase_0b_meta_scores
from . import phase_0b_ziele_fokus from . import phase_0b_ziele_fokus
from . import korrelationen from . import korrelationen
from . import profile_reference_values
__all__ = [ __all__ = [
'nutrition_part_a', 'nutrition_part_a',
@ -35,4 +36,5 @@ __all__ = [
'phase_0b_meta_scores', 'phase_0b_meta_scores',
'phase_0b_ziele_fokus', 'phase_0b_ziele_fokus',
'korrelationen', 'korrelationen',
'profile_reference_values',
] ]

View File

@ -1,7 +1,7 @@
""" """
Activity Metrics Placeholder Registrations Activity Metrics Placeholder Registrations
Registers 17 Aktivitäts-Platzhalter hier; 3 Session-/Erholungs-Keys in activity_session_insights.py (20 gesamt). Registers 17 Aktivitäts-Platzhalter hier; 3 weitere Keys in activity_session_insights.py (**20 gesamt** in PLACEHOLDER_MAP).
Evidence-based metadata with clear tagging of source. Evidence-based metadata with clear tagging of source.
@ -43,7 +43,7 @@ def register_activity_group_1():
category="Aktivität", category="Aktivität",
description="Zusammenfassung der letzten 14 Tage Aktivität", description="Zusammenfassung der letzten 14 Tage Aktivität",
resolver_module="backend/placeholder_resolver.py", resolver_module="backend/placeholder_resolver.py",
resolver_function="_format_activity_summary", resolver_function="get_activity_summary",
data_layer_module=None, data_layer_module=None,
data_layer_function=None, data_layer_function=None,
source_tables=["activity_log", "training_types"], source_tables=["activity_log", "training_types"],
@ -127,16 +127,23 @@ def register_activity_group_1():
activity_detail_metadata = PlaceholderMetadata( activity_detail_metadata = PlaceholderMetadata(
key="activity_detail", key="activity_detail",
category="Aktivität", category="Aktivität",
description="Detaillierte Liste der letzten 14 Tage Aktivität", description=(
"Letzte 14 Tage: pro Session Kopfzeile (activity_log) plus gemergte Profil-Metriken "
"(dynamische Keys je training_category / training_type_id)"
),
resolver_module="backend/placeholder_resolver.py", resolver_module="backend/placeholder_resolver.py",
resolver_function="_format_activity_detail", resolver_function="get_activity_detail",
data_layer_module=None, data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function=None, data_layer_function="get_activity_detail_data",
source_tables=["activity_log", "training_types"], source_tables=["activity_log", "activity_session_metrics", "training_parameters"],
semantic_contract=( semantic_contract=(
"Liefert eine strukturierte Liste aller Trainingseinheiten der letzten 14 Tage. " "Layer 1: get_activity_detail_data lädt Sessions, enrich_sessions_with_metrics fügt "
"Jede Einheit: Datum, Trainingstyp, Dauer (Minuten), optional Notizen. " "session_metrics hinzu — effektive Liste aus merge_column_backed_and_eav_metrics: nur "
"Sortiert chronologisch absteigend (neueste zuerst)." "Parameter aus dem Attributschema (tcp/ttp), sortiert nach key. "
"Leseregel Kanon: activity_log-Spalte (source_field, Registry-Feld, Legacy-Spalte für "
"EAV-primäre Keys) schlägt EAV, wenn beide Werte liefern. "
"Layer 2a: Zeilen mit „| EAV: key=value; …“ nur für nicht-leere session_metrics; "
"die Menge der Keys ist admin-/profilabhängig, kein festes Prompt-Schema."
), ),
business_meaning=( business_meaning=(
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen " "Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
@ -147,7 +154,9 @@ def register_activity_group_1():
time_window="14d", time_window="14d",
output_type=OutputType.LIST, output_type=OutputType.LIST,
placeholder_type=PlaceholderType.RAW_DATA, placeholder_type=PlaceholderType.RAW_DATA,
format_hint="Liste von Strings, eine Zeile pro Einheit: 'YYYY-MM-DD: Typ (Dauer min)'", format_hint=(
"Pro Zeile: Datum, Typ, Dauer, kcal, optional HF, optional „| EAV: …“ aus Session-Metriken"
),
example_output=( example_output=(
"2026-03-28: Krafttraining (45 min)\\n" "2026-03-28: Krafttraining (45 min)\\n"
"2026-03-27: Laufen (30 min)\\n" "2026-03-27: Laufen (30 min)\\n"
@ -163,19 +172,17 @@ def register_activity_group_1():
legacy_display="Keine Aktivitätsdaten" legacy_display="Keine Aktivitätsdaten"
), ),
known_limitations=( known_limitations=(
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. " "Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output "
"Formatierung direkt im Resolver. " "(Hard-Limit Resolver). session_metrics kann leer sein (kein Typ, kein Profil, keine EAV-Zeilen). "
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten (z.B. 0 min) " "Keys und Anzahl Metriken variieren je Instanz/Admin — nicht von festen Platzhaltern in anderen "
"werden gelistet. JOIN mit training_types für Typ-Namen." "Prompts ausgehen. Nur im effektiven Merge erscheinende Parameter; keine verwaisten EAV-Keys "
"außerhalb des Schemas."
), ),
layer_1_decision="NONE - Old resolver pattern (direct SQL in resolver)", layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
layer_2a_decision="Placeholder Resolver (formatting + SQL query)", layer_2a_decision="get_activity_detail (Formatierung)",
layer_2b_reuse_possible=False, layer_2b_reuse_possible=True,
architecture_alignment=( architecture_alignment="Phase 0c Layer 1 + EAV-Anreicherung",
"NOT ALIGNED with Phase 0c Multi-Layer Architecture. " issue_53_alignment="Layer 1"
"Should be refactored to use data_layer function."
),
issue_53_alignment="NOT ALIGNED - no layer separation"
) )
activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED) activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
@ -212,56 +219,47 @@ def register_activity_group_1():
trainingstyp_verteilung_metadata = PlaceholderMetadata( trainingstyp_verteilung_metadata = PlaceholderMetadata(
key="trainingstyp_verteilung", key="trainingstyp_verteilung",
category="Aktivität", category="Aktivität",
description="Trainingstypen-Verteilung der letzten 14 Tage als JSON", description="Verteilung nach training_category (14 Tage): Top 3 als kompakte Prozent-Textzeile",
resolver_module="backend/placeholder_resolver.py", resolver_module="backend/placeholder_resolver.py",
resolver_function="_format_trainingstyp_verteilung", resolver_function="get_trainingstyp_verteilung",
data_layer_module=None, data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function=None, data_layer_function="get_training_type_distribution_data",
source_tables=["activity_log", "training_types"], source_tables=["activity_log"],
semantic_contract=( semantic_contract=(
"Liefert eine JSON-Struktur mit der Verteilung der Trainingstypen über 14 Tage. " "Layer 1: get_training_type_distribution_data — Anteil je training_category am "
"Für jeden Trainingstyp: Anzahl Einheiten, Gesamtdauer (Minuten), " "Gesamt-Session-Count im Fenster (auch unkategorisierte zählen im Nenner). "
"Prozentanteil an Gesamtdauer. Sortiert nach Dauer absteigend." "Layer 2a: Top 3 Kategorien als „Name: p%“ kommagetrennt; bei fehlenden Daten Kurz-Hinweis."
), ),
business_meaning=( business_meaning=(
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. " "Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) " "Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')." "oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
), ),
unit="json", unit="text",
time_window="14d", time_window="14d",
output_type=OutputType.JSON, output_type=OutputType.TEXT_SUMMARY,
placeholder_type=PlaceholderType.INTERPRETED, placeholder_type=PlaceholderType.INTERPRETED,
format_hint="JSON Object mit Trainingstyp als Key, Value: {count, duration_min, percentage}", format_hint="Eine Zeile: bis zu drei „Kategorie: Prozent%“, durch Komma getrennt",
example_output=( example_output="cardio: 45%, strength: 30%, mobility: 15%",
'{"Krafttraining": {"count": 5, "duration_min": 180, "percentage": 57}, '
'"Ausdauer": {"count": 4, "duration_min": 90, "percentage": 29}, '
'"Mobilität": {"count": 3, "duration_min": 45, "percentage": 14}}'
),
minimum_data_requirements=None, minimum_data_requirements=None,
quality_filter_policy=None, quality_filter_policy=None,
confidence_logic="Keine Confidence-Berechnung. Aggregation basiert auf verfügbaren Daten.", confidence_logic="Wie get_training_type_distribution_data (calculate_confidence über categorized_count)",
missing_value_policy=MissingValuePolicy( missing_value_policy=MissingValuePolicy(
available=False, available=False,
value_raw=None, value_raw=None,
missing_reason="no_data", missing_reason="no_data",
legacy_display="{}" legacy_display="Keine kategorisierten Trainings"
), ),
known_limitations=( known_limitations=(
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. " "Nur Sessions mit gesetztem training_category fließen in die Verteilungsliste; "
"Aggregation direkt im Resolver. " "Prozente beziehen sich auf alle Sessions im Fenster (Nenner = total_sessions). "
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten werden aggregiert. " "Keine Qualitätsfilterung der Einheiten. Kein drill-down nach training_type_id in diesem Platzhalter."
"JOIN mit training_types für Typ-Namen. "
"EDGE CASE: Einheiten ohne training_type_id werden ignoriert (LEFT JOIN)."
), ),
layer_1_decision="NONE - Old resolver pattern (direct SQL aggregation in resolver)", layer_1_decision="activity_metrics.get_training_type_distribution_data",
layer_2a_decision="Placeholder Resolver (aggregation + JSON formatting)", layer_2a_decision="get_trainingstyp_verteilung (Top 3 als Text)",
layer_2b_reuse_possible=True, layer_2b_reuse_possible=True,
architecture_alignment=( architecture_alignment="Phase 0c — Layer 1 + Formatierung",
"PARTIALLY ALIGNED: JSON output structure suitable for chart endpoints, " issue_53_alignment="Layer 1"
"but no data layer separation. Should be refactored."
),
issue_53_alignment="PARTIALLY ALIGNED - output format good, layer separation missing"
) )
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED) trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)

View File

@ -130,16 +130,22 @@ def register_activity_session_insights():
key="training_sessions_recent_json", key="training_sessions_recent_json",
category="Aktivität", category="Aktivität",
description=( description=(
"JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie)" "JSON: ISO-Wochen mit Sessions (activity_log-Kopf) plus session_metrics als kompaktes "
"{key: Wert}-Objekt; Zahlen für Prompts gekürzt. Semantik: {{training_parameters_glossary_md}}."
), ),
resolver_module="backend/placeholder_resolver.py", resolver_module="backend/placeholder_resolver.py",
resolver_function="_safe_json", resolver_function="_safe_json",
data_layer_module="backend/data_layer/activity_metrics.py", data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_sessions_recent_weeks_data", data_layer_function="get_training_sessions_recent_weeks_data",
source_tables=["activity_log", "training_types"], source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
semantic_contract=( semantic_contract=(
"Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung. " "Root: weeks[] mit week_iso; sessions[] pro Einheit u. a. id, date, activity_type, "
"Default 4 ISO-Wochen zurück." "duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, "
"session_metrics (Objekt key→Wert, keine wiederholten Labels). "
"Merge wie merge_column_backed_and_eav_metrics; nur Keys aus Attributschema. "
"meta.session_metrics_shape=key_value, meta.metric_semantics_placeholder verweist auf Glossary-Platzhalter. "
"Alle JSON-Platzhalter mit _safe_json: Zahlen rekursiv kompakt gerundet. "
"Default ca. 4 ISO-Wochen (28 Tage Rohdatenfenster)."
), ),
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI", business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
unit="JSON string", unit="JSON string",
@ -158,7 +164,12 @@ def register_activity_session_insights():
legacy_display="{}", legacy_display="{}",
), ),
known_limitations=( known_limitations=(
"Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id." "Token-Länge bei vielen Sessions. training_type_name nur bei gesetztem training_type_id. "
"session_metrics oft [] (kein Typ, kein Profil, keine gespeicherten Werte). "
"Anzahl und Namen der Metrik-Keys sind instanz-/adminabhängig — JSON nicht als festes Schema "
"für Downstream-Parsing harter Logik verwenden. "
"Pflicht für Metrik-Bedeutung: {{training_parameters_glossary_md}} (Katalog); im JSON keine Namen/Beschreibungen pro Session. "
"Composite-Parameter (JSON in EAV) noch nicht im MVP expandiert; ggf. Roh-value_text in späterer Phase."
), ),
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data", layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
layer_2a_decision="_safe_json('training_sessions_recent_json')", layer_2a_decision="_safe_json('training_sessions_recent_json')",
@ -180,5 +191,61 @@ def register_activity_session_insights():
_ev(pj, "known_limitations", EvidenceType.MIXED) _ev(pj, "known_limitations", EvidenceType.MIXED)
register_placeholder(pj) register_placeholder(pj)
md_gloss = PlaceholderMetadata(
key="training_parameters_glossary_md",
category="Aktivität",
description=(
"Markdown-Tabelle: alle aktiven training_parameters (key, DE/EN, Beschreibungen, Typ, Einheit, Kategorie). "
"Ergänzung zu training_sessions_recent_json für KI (Bedeutung dynamischer Metrik-Keys)."
),
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_training_parameters_glossary_md",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_parameters_ki_glossary_data",
source_tables=["training_parameters"],
semantic_contract=(
"SELECT auf training_parameters WHERE is_active; sortiert category, key. "
"profile_id-Parameter im Resolver reserviert, aktuell globaler Katalog."
),
business_meaning="KI: Legende zu session_metrics-Keys und Custom-Parametern",
unit="Markdown",
time_window="n/a (Katalog-Snapshot)",
output_type=OutputType.TEXT_SUMMARY,
placeholder_type=PlaceholderType.INTERPRETED,
format_hint="GitHub-Flavored Markdown-Tabelle",
example_output="| Feld (key) | DE | EN | Beschreibung DE | … |",
minimum_data_requirements="Optional leer → Kurztext statt Tabelle",
quality_filter_policy=None,
confidence_logic="Immer verfügbar wenn DB erreichbar",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="no_data",
legacy_display="Keine aktiven Trainingsparameter im Katalog.",
),
known_limitations=(
"Keine profil-spezifische Einschränkung auf tatsächlich genutzte Keys (V2). "
"Tabellen können bei großem Katalog lang werden."
),
layer_1_decision="activity_metrics.get_training_parameters_ki_glossary_data",
layer_2a_decision="get_training_parameters_glossary_md",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 2a",
evidence={},
)
for f in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
"example_output", "minimum_data_requirements", "confidence_logic",
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
):
_ev(md_gloss, f)
_ev(md_gloss, "business_meaning", EvidenceType.DRAFT_DERIVED)
_ev(md_gloss, "known_limitations", EvidenceType.MIXED)
register_placeholder(md_gloss)
register_activity_session_insights() register_activity_session_insights()

View File

@ -18,9 +18,10 @@ Body Composition (5):
- waist_hip_ratio - waist_hip_ratio
- recomposition_quadrant - recomposition_quadrant
Circumference Deltas (5): Circumference Deltas (6):
- waist_28d_delta - waist_28d_delta
- arm_28d_delta - arm_28d_delta (Oberarm kontrahiert, c_arm)
- arm_relaxed_28d_delta (Oberarm entspannt, c_arm_relaxed)
- chest_28d_delta - chest_28d_delta
- hip_28d_delta - hip_28d_delta
- thigh_28d_delta - thigh_28d_delta
@ -1033,14 +1034,14 @@ def register_body_metrics():
arm_28d_delta_metadata = PlaceholderMetadata( arm_28d_delta_metadata = PlaceholderMetadata(
key="arm_28d_delta", key="arm_28d_delta",
description="Armumfang Änderung 28d (cm)", description="Oberarm kontrahiert (c_arm): Umfangs-Änderung 28d (cm)",
resolver_function="_safe_float('arm_28d_delta', decimals=1)", resolver_function="_safe_float('arm_28d_delta', decimals=1)",
data_layer_function="calculate_arm_28d_delta", data_layer_function="calculate_arm_28d_delta",
semantic_contract=( semantic_contract=(
"Liefert die Veränderung des Armumfangs in Zentimetern über 28 Tage. " "Veränderung des kontrahierten/angespannten Oberarmumfangs (Spalte c_arm) in cm über 28 Tage. "
"Positive Werte bedeuten Zunahme, negative Werte Reduktion." "Entspricht historischen Einträgen „Arm“ vor Einführung des zweiten Messpunkts."
), ),
business_meaning="Ergänzender Umfangsindikator für detaillierte Körperentwicklungsanalysen", business_meaning="Arm-Umfang unter Anspannung (z. B. leicht gebeugter Arm, Bizeps leicht aktiv)",
unit="cm", unit="cm",
example_output="+0.6", example_output="+0.6",
**circumference_delta_common **circumference_delta_common
@ -1054,6 +1055,30 @@ def register_body_metrics():
arm_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED) arm_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED)
register_placeholder(arm_28d_delta_metadata) register_placeholder(arm_28d_delta_metadata)
# ── arm_relaxed_28d_delta ────────────────────────────────────────────────
arm_relaxed_28d_delta_metadata = PlaceholderMetadata(
key="arm_relaxed_28d_delta",
description="Oberarm entspannt (c_arm_relaxed): Umfangs-Änderung 28d (cm)",
resolver_function="_safe_float('arm_relaxed_28d_delta', decimals=1)",
data_layer_function="calculate_arm_relaxed_28d_delta",
semantic_contract=(
"Veränderung des entspannten Oberarmumfangs (Spalte c_arm_relaxed) in cm über 28 Tage."
),
business_meaning="Arm-Umfang bei locker hängendem Arm ohne zusätzliche Muskelanspannung",
unit="cm",
example_output="+0.3",
**circumference_delta_common
)
arm_relaxed_28d_delta_metadata.evidence.update(circ_delta_evidence)
arm_relaxed_28d_delta_metadata.set_evidence("resolver_function", EvidenceType.CODE_DERIVED)
arm_relaxed_28d_delta_metadata.set_evidence("data_layer_function", EvidenceType.CODE_DERIVED)
arm_relaxed_28d_delta_metadata.set_evidence("semantic_contract", EvidenceType.DRAFT_DERIVED)
arm_relaxed_28d_delta_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
arm_relaxed_28d_delta_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED)
arm_relaxed_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED)
register_placeholder(arm_relaxed_28d_delta_metadata)
# ── chest_28d_delta ────────────────────────────────────────────────────── # ── chest_28d_delta ──────────────────────────────────────────────────────
chest_28d_delta_metadata = PlaceholderMetadata( chest_28d_delta_metadata = PlaceholderMetadata(

View File

@ -0,0 +1,68 @@
"""
Registry: Persönliche Referenzwerte (Profil) Layer 1 reference_values, JSON-Platzhalter 2a.
"""
from placeholder_registry import (
PlaceholderMetadata,
MissingValuePolicy,
OutputType,
PlaceholderType,
register_placeholder,
)
from ._evidence import tag_standard_evidence
CAT = "Referenzwerte"
MVP = lambda reason, disp: MissingValuePolicy(
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
)
def register_profile_reference_values():
for key, dl_fn, desc, sem in [
(
"reference_values_current_json",
"get_profile_reference_values_current_snapshot",
"JSON: aktuelle Referenzwerte (jüngster Eintrag pro Typ, Katalog-Reihenfolge)",
"reference_values.get_profile_reference_values_current_snapshot(profile_id)",
),
(
"reference_values_recent_json",
"get_profile_reference_values_recent_snapshot",
"JSON: Verlauf — bis zu 5 Einträge pro Referenztyp (neueste zuerst), optional Datumsfilter in Layer-1-API",
"reference_values.get_profile_reference_values_recent_snapshot(profile_id, limit_per_type=5)",
),
]:
m = PlaceholderMetadata(
key=key,
category=CAT,
description=desc,
resolver_module="backend/placeholder_resolver.py",
resolver_function="_safe_json",
data_layer_module="backend/data_layer/reference_values.py",
data_layer_function=dl_fn,
source_tables=["profile_reference_values", "reference_value_types"],
semantic_contract=sem,
business_meaning="Persönliche Referenzkennwerte für KI-Kontext (Messmethode, Vertrauen, Historie)",
unit="JSON",
time_window="aktuell bzw. letzte N Einträge pro Typ",
output_type=OutputType.JSON,
placeholder_type=PlaceholderType.RAW_DATA,
format_hint="JSON-String (schema *_v1)",
example_output='{"count":0,"items":[]}',
minimum_data_requirements="Keine Pflicht — leere Listen möglich",
quality_filter_policy=None,
confidence_logic="Rohdaten aus Erfassung (confidence-Feld pro Eintrag)",
missing_value_policy=MVP("optional_module", "{}"),
known_limitations="recent_json: fest 5 pro Typ im Platzhalter; Datumsfilter nur über API/Layer-1-Parameter",
layer_1_decision="data_layer.reference_values",
layer_2a_decision="_safe_json + compact_json_payload_for_prompts",
layer_2b_reuse_possible=True,
architecture_alignment="Issue 53 / Phase 0c Layer 1",
issue_53_alignment="Strukturierte L1-Daten für Prompts",
evidence={},
)
tag_standard_evidence(m)
register_placeholder(m)
register_profile_reference_values()

View File

@ -28,6 +28,8 @@ from data_layer.nutrition_metrics import (
get_nutrition_days_data, get_nutrition_days_data,
get_protein_targets_data get_protein_targets_data
) )
from data_layer.prompt_output_compact import format_scalar_for_prompt_text
from data_layer.activity_metrics import ( from data_layer.activity_metrics import (
get_activity_summary_data, get_activity_summary_data,
get_activity_detail_data, get_activity_detail_data,
@ -35,6 +37,7 @@ from data_layer.activity_metrics import (
get_training_frequency_by_type_data, get_training_frequency_by_type_data,
get_training_inter_session_gap_data, get_training_inter_session_gap_data,
get_training_sessions_recent_weeks_data, get_training_sessions_recent_weeks_data,
get_training_parameters_ki_glossary_data,
) )
from data_layer.recovery_metrics import ( from data_layer.recovery_metrics import (
get_sleep_duration_data, get_sleep_duration_data,
@ -47,6 +50,12 @@ from data_layer.health_metrics import (
get_vo2_max_data get_vo2_max_data
) )
from data_layer.prompt_output_compact import compact_json_payload_for_prompts
from data_layer.reference_values import (
get_profile_reference_values_current_snapshot,
get_profile_reference_values_recent_snapshot,
)
from placeholder_registry import build_ai_placeholder_caption, get_registry from placeholder_registry import build_ai_placeholder_caption, get_registry
# {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry) # {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry)
@ -347,7 +356,11 @@ def get_activity_summary(profile_id: str, days: int = 14) -> str:
if data['confidence'] == 'insufficient': if data['confidence'] == 'insufficient':
return f"Keine Aktivitäten in den letzten {days} Tagen" return f"Keine Aktivitäten in den letzten {days} Tagen"
return f"{data['activity_count']} Einheiten in {days} Tagen (Ø {data['avg_duration_min']} min/Einheit, {data['total_kcal']} kcal gesamt)" return (
f"{data['activity_count']} Einheiten in {days} Tagen (Ø "
f"{format_scalar_for_prompt_text(data['avg_duration_min'])} min/Einheit, "
f"{format_scalar_for_prompt_text(data['total_kcal'])} kcal gesamt)"
)
def calculate_age(dob) -> str: def calculate_age(dob) -> str:
@ -419,14 +432,27 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str:
# Format as readable list (max 20 entries to avoid token bloat) # Format as readable list (max 20 entries to avoid token bloat)
lines = [] lines = []
for activity in data['activities'][:20]: for activity in data["activities"][:20]:
hr_str = f" HF={activity['hr_avg']}" if activity['hr_avg'] else "" hr_str = (
f", HF={format_scalar_for_prompt_text(activity['hr_avg'])}"
if activity.get("hr_avg") is not None
else ""
)
eav_parts = []
for m in activity.get("session_metrics") or []:
k, v = m.get("key"), m.get("value")
if k is None or v is None:
continue
label = m.get("name_de") or m.get("name_en") or k
eav_parts.append(f"{label} ({k})={format_scalar_for_prompt_text(v)}")
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
lines.append( lines.append(
f"{activity['date']}: {activity['activity_type']} " f"{activity['date']}: {activity['activity_type']} "
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str})" f"({format_scalar_for_prompt_text(activity['duration_min'])}min, "
f"{format_scalar_for_prompt_text(activity['kcal_active'])}kcal{hr_str}{eav_str})"
) )
return '\n'.join(lines) return "\n".join(lines)
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str: def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
@ -449,6 +475,45 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
return ", ".join(parts) return ", ".join(parts)
def get_training_parameters_glossary_md(profile_id: str) -> str:
"""
Markdown-Tabelle: alle aktiven training_parameters (key, Namen, Beschreibungen, Typ, Einheit).
Für KI neben session_metrics / training_sessions_recent_json.
"""
data = get_training_parameters_ki_glossary_data(profile_id)
params = data.get("parameters") or []
if not params:
return "Keine aktiven Trainingsparameter im Katalog."
def cell(x: object) -> str:
if x is None:
return ""
return str(x).replace("|", "·").replace("\n", " ").strip()[:400]
lines = [
"| Feld (key) | DE | EN | Beschreibung DE | Beschreibung EN | Typ | Einheit | Kategorie |",
"|---|---|---|---|---|---|---|---|",
]
for p in params:
lines.append(
"| "
+ " | ".join(
[
cell(p.get("key")),
cell(p.get("name_de")),
cell(p.get("name_en")),
cell(p.get("description_de")),
cell(p.get("description_en")),
cell(p.get("data_type")),
cell(p.get("unit")),
cell(p.get("category")),
]
)
+ " |"
)
return "\n".join(lines)
def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str: def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str:
""" """
Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min. Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min.
@ -688,7 +753,8 @@ _SAFE_FLOAT_NONE_REASON: Dict[str, str] = {
"waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)", "waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)",
"hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar", "hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar",
"chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar", "chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar",
"arm_28d_delta": "Arm-Delta 28 Tage nicht berechenbar", "arm_28d_delta": "Oberarm kontrahiert: Delta 28 Tage nicht berechenbar",
"arm_relaxed_28d_delta": "Oberarm entspannt: Delta 28 Tage nicht berechenbar",
"thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar", "thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar",
"waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar", "waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar",
"energy_balance_7d": ( "energy_balance_7d": (
@ -748,6 +814,8 @@ _SAFE_JSON_NONE_REASON: Dict[str, str] = {
"active_goals_json": "Aktive Ziele als JSON nicht ermittelbar", "active_goals_json": "Aktive Ziele als JSON nicht ermittelbar",
"focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar", "focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar",
"focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar", "focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar",
"reference_values_current_json": "Referenzwerte (aktuell) nicht ermittelbar",
"reference_values_recent_json": "Referenzwerte (Verlauf) nicht ermittelbar",
} }
@ -852,6 +920,7 @@ def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str:
'hip_28d_delta': body_metrics.calculate_hip_28d_delta, 'hip_28d_delta': body_metrics.calculate_hip_28d_delta,
'chest_28d_delta': body_metrics.calculate_chest_28d_delta, 'chest_28d_delta': body_metrics.calculate_chest_28d_delta,
'arm_28d_delta': body_metrics.calculate_arm_28d_delta, 'arm_28d_delta': body_metrics.calculate_arm_28d_delta,
'arm_relaxed_28d_delta': body_metrics.calculate_arm_relaxed_28d_delta,
'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta, 'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta,
'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio, 'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio,
'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d, 'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d,
@ -962,6 +1031,8 @@ def _safe_json(func_name: str, profile_id: str) -> str:
'active_goals_json': lambda pid: _get_active_goals_json(pid), 'active_goals_json': lambda pid: _get_active_goals_json(pid),
'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid), 'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid),
'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False), 'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False),
'reference_values_current_json': get_profile_reference_values_current_snapshot,
'reference_values_recent_json': get_profile_reference_values_recent_snapshot,
} }
func = func_map.get(func_name) func = func_map.get(func_name)
@ -980,8 +1051,8 @@ def _safe_json(func_name: str, profile_id: str) -> str:
# If already string, return it; otherwise convert to JSON # If already string, return it; otherwise convert to JSON
if isinstance(result, str): if isinstance(result, str):
return result return result
else: compacted = compact_json_payload_for_prompts(result)
return json.dumps(result, ensure_ascii=False, default=str) return json.dumps(compacted, ensure_ascii=False, default=str)
except Exception as e: except Exception as e:
print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}") print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}")
traceback.print_exc() traceback.print_exc()
@ -1495,6 +1566,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{hip_28d_delta}}': lambda pid: _safe_float('hip_28d_delta', pid), '{{hip_28d_delta}}': lambda pid: _safe_float('hip_28d_delta', pid),
'{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid), '{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid),
'{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid), '{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid),
'{{arm_relaxed_28d_delta}}': lambda pid: _safe_float('arm_relaxed_28d_delta', pid),
'{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_28d_delta', pid), '{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_28d_delta', pid),
'{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3), '{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3),
'{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid), '{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid),
@ -1517,7 +1589,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid), '{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid), '{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
# Training / Aktivität (17 Registry-Keys — gebündelt; activity_score hier, nicht unter Meta Scores) # Training / Aktivität (20 Keys: 17 activity_metrics + 3 activity_session_insights; activity_score hier, nicht unter Meta Scores)
'{{activity_summary}}': get_activity_summary, '{{activity_summary}}': get_activity_summary,
'{{activity_detail}}': get_activity_detail, '{{activity_detail}}': get_activity_detail,
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung, '{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
@ -1538,6 +1610,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{training_frequency_by_type_md}}': get_training_frequency_by_type_md, '{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
'{{training_inter_session_gap_md}}': get_training_inter_session_gap_md, '{{training_inter_session_gap_md}}': get_training_inter_session_gap_md,
'{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid), '{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid),
'{{training_parameters_glossary_md}}': get_training_parameters_glossary_md,
# Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores) # Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
'{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7), '{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
@ -1609,6 +1682,8 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid), '{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid),
'{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid), '{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid),
'{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid), '{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid),
'{{reference_values_current_json}}': lambda pid: _safe_json('reference_values_current_json', pid),
'{{reference_values_recent_json}}': lambda pid: _safe_json('reference_values_recent_json', pid),
'{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid), '{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid),
'{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid), '{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid),
'{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid), '{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid),
@ -1723,7 +1798,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
'{{weight_7d_median}}', '{{weight_28d_slope}}', '{{weight_90d_slope}}', '{{weight_7d_median}}', '{{weight_28d_slope}}', '{{weight_90d_slope}}',
'{{fm_28d_change}}', '{{lbm_28d_change}}', '{{fm_28d_change}}', '{{lbm_28d_change}}',
'{{waist_28d_delta}}', '{{hip_28d_delta}}', '{{chest_28d_delta}}', '{{waist_28d_delta}}', '{{hip_28d_delta}}', '{{chest_28d_delta}}',
'{{arm_28d_delta}}', '{{thigh_28d_delta}}', '{{arm_28d_delta}}', '{{arm_relaxed_28d_delta}}', '{{thigh_28d_delta}}',
'{{waist_hip_ratio}}', '{{recomposition_quadrant}}', '{{waist_hip_ratio}}', '{{recomposition_quadrant}}',
'{{body_progress_score}}', '{{body_progress_score}}',
], ],
@ -1742,6 +1817,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}', '{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}', '{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}', '{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
'{{training_parameters_glossary_md}}',
], ],
'schlaf': [ 'schlaf': [
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}', '{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
@ -1757,6 +1833,10 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
'zeitraum': [ 'zeitraum': [
'{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}' '{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
], ],
'referenzwerte': [
'{{reference_values_current_json}}',
'{{reference_values_recent_json}}',
],
'phase0b_meta': [ 'phase0b_meta': [
'{{goal_progress_score}}', '{{data_quality_score}}', '{{goal_progress_score}}', '{{data_quality_score}}',
], ],

View File

@ -0,0 +1,139 @@
"""
Chart-Daten für Berichts-PDF: dieselbe Logik wie /api/charts/* (Data Layer), ohne HTTP.
"""
from __future__ import annotations
from typing import Any, Callable
from data_layer.activity_metrics import (
build_training_type_distribution_chart_payload,
build_training_volume_chart_payload,
)
from data_layer.body_metrics import get_weight_trend_data
from data_layer.nutrition_chart_payloads import build_energy_balance_chart_payload
from data_layer.nutrition_metrics import get_nutrition_average_data
from data_layer.utils import serialize_dates
def _weight_trend_payload(profile_id: str, days: int) -> dict[str, Any]:
d = min(max(days, 7), 365)
trend_data = get_weight_trend_data(profile_id, d)
if trend_data["confidence"] == "insufficient":
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Nicht genug Daten für Trend-Analyse",
},
}
series = trend_data.get("series") or []
labels = [
pt["date"].isoformat() if hasattr(pt["date"], "isoformat") else str(pt["date"]) for pt in series
]
values = [pt["weight"] for pt in series]
return {
"chart_type": "line",
"data": {
"labels": labels,
"datasets": [
{
"label": "Gewicht",
"data": values,
"borderColor": "#1D9E75",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 2,
"tension": 0.4,
"fill": True,
"pointRadius": 2,
}
],
},
"metadata": serialize_dates(
{
"confidence": trend_data["confidence"],
"data_points": trend_data["data_points"],
"first_value": trend_data["first_value"],
"last_value": trend_data["last_value"],
"delta": trend_data["delta"],
"direction": trend_data["direction"],
}
),
}
def _macro_distribution_payload(profile_id: str, days: int) -> dict[str, Any]:
d = min(max(days, 7), 90)
macro_data = get_nutrition_average_data(profile_id, d)
if macro_data["confidence"] == "insufficient":
return {
"chart_type": "pie",
"data": {"labels": [], "datasets": []},
"metadata": {"confidence": "insufficient", "message": "Keine Ernährungsdaten vorhanden"},
}
protein_kcal = macro_data["protein_avg"] * 4
carbs_kcal = macro_data["carbs_avg"] * 4
fat_kcal = macro_data["fat_avg"] * 9
total_kcal = protein_kcal + carbs_kcal + fat_kcal
if total_kcal == 0:
return {
"chart_type": "pie",
"data": {"labels": [], "datasets": []},
"metadata": {"confidence": "insufficient", "message": "Keine Makronährstoff-Daten"},
}
protein_pct = protein_kcal / total_kcal * 100
carbs_pct = carbs_kcal / total_kcal * 100
fat_pct = fat_kcal / total_kcal * 100
return {
"chart_type": "pie",
"data": {
"labels": ["Protein", "Kohlenhydrate", "Fett"],
"datasets": [
{
"data": [round(protein_pct, 1), round(carbs_pct, 1), round(fat_pct, 1)],
"backgroundColor": ["#1D9E75", "#F59E0B", "#EF4444"],
"borderWidth": 2,
"borderColor": "#fff",
}
],
},
"metadata": {"confidence": macro_data.get("confidence", "high")},
}
def _training_volume_payload(profile_id: str, window_days: int) -> dict[str, Any]:
w = max(4, min(52, window_days // 7))
return build_training_volume_chart_payload(profile_id, w)
_CHART_FETCHERS: dict[str, Callable[[str, int], dict[str, Any]]] = {
"weight_trend": _weight_trend_payload,
"energy_balance": lambda pid, d: build_energy_balance_chart_payload(pid, min(max(d, 7), 90)),
"macro_distribution": _macro_distribution_payload,
"training_volume": _training_volume_payload,
"training_type_distribution": lambda pid, d: build_training_type_distribution_chart_payload(
pid, min(max(d, 7), 90)
),
}
def fetch_chart_payload(chart_id: str, profile_id: str, window_days: int) -> dict[str, Any]:
fn = _CHART_FETCHERS.get(chart_id)
if not fn:
raise ValueError(f"Unbekanntes chart_id: {chart_id}")
return fn(profile_id, window_days)
CHART_CATALOG_FOR_API: list[dict[str, Any]] = [
{"id": "weight_trend", "title": "Gewichtstrend", "default_window_days": 90, "window_max": 365},
{"id": "energy_balance", "title": "Energiebilanz", "default_window_days": 28, "window_max": 90},
{"id": "macro_distribution", "title": "Makroverteilung (Ø)", "default_window_days": 28, "window_max": 90},
{"id": "training_volume", "title": "Trainingsvolumen (Wochen)", "default_window_days": 84, "window_max": 365},
{
"id": "training_type_distribution",
"title": "Trainingsart-Verteilung",
"default_window_days": 28,
"window_max": 90,
},
]

View File

@ -0,0 +1,91 @@
"""Chart.js-ähnliche Payloads → PNG (Matplotlib). Von PDF- und Bundle-Rendering gemeinsam genutzt."""
from __future__ import annotations
import io
from typing import Any
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]:
s = (hex_or_rgba or "#333333").strip()
if s.startswith("#") and len(s) >= 7:
try:
r = int(s[1:3], 16) / 255.0
g = int(s[3:5], 16) / 255.0
b = int(s[5:7], 16) / 255.0
return (r, g, b)
except ValueError:
pass
return (0.12, 0.62, 0.46)
def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes:
chart_type = payload.get("chart_type") or "line"
data = payload.get("data") or {}
labels = data.get("labels") or []
datasets = data.get("datasets") or []
fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120)
ax.set_facecolor("#fafaf9")
fig.patch.set_facecolor("#ffffff")
if chart_type == "pie" and datasets:
ds0 = datasets[0]
values = ds0.get("data") or []
colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"]
if labels and values and len(labels) == len(values):
ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90)
ax.axis("equal")
else:
ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes)
elif chart_type in ("line", "bar", "scatter") and datasets:
x = range(len(labels)) if labels else []
for i, ds in enumerate(datasets):
y = ds.get("data") or []
if not y:
continue
lab = ds.get("label") or f"Serie {i + 1}"
col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75"))
if chart_type == "bar":
yv = y[: len(labels)] if labels else y
bg = ds.get("backgroundColor")
if isinstance(bg, list):
cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]]
else:
cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv)
ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88)
else:
ax.plot(
list(x)[: len(y)],
y,
label=lab,
color=col,
linewidth=1.6,
marker="o",
markersize=2,
)
if labels and chart_type != "bar":
step = max(1, len(labels) // 8)
ax.set_xticks(list(x)[::step])
ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7)
elif labels and chart_type == "bar":
ax.set_xticks(list(x))
ax.set_xticklabels(labels, rotation=30, fontsize=7)
ax.legend(loc="upper right", fontsize=7)
ax.grid(True, alpha=0.25)
ax.set_xmargin(0.02)
else:
ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor())
plt.close(fig)
buf.seek(0)
return buf.read()

View File

@ -0,0 +1,130 @@
"""
PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads.
"""
from __future__ import annotations
import io
import logging
from typing import Any
from xml.sax.saxutils import escape
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Image as RLImage
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
from db import get_cursor, get_db
from report_chart_fetch import fetch_chart_payload
from report_chart_plotting import chart_payload_to_png
from report_profile_schema import (
AiInsightBlock,
ChartBlock,
ReportProfilePayload,
SectionBlock,
VizBundleBlock,
)
from report_viz_bundle_pdf import append_viz_bundle_to_story
logger = logging.getLogger(__name__)
_CONTENT_TRUNCATE = 12000
def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
"""Returns (heading, body_text)."""
if not insight_id:
return (
"KI-Auswertung",
"(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse "
"verknüpfen.)",
)
try:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s",
(insight_id, profile_id),
)
row = cur.fetchone()
if not row:
return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.")
scope = row.get("scope") or "Analyse"
content = row.get("content") or ""
if len(content) > _CONTENT_TRUNCATE:
content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]"
created = row.get("created")
sub = f"{scope}" + (f" · {created}" if created else "")
return (sub, content)
except Exception as e:
logger.warning("report pdf insight load failed: %s", e)
return ("KI-Auswertung", "Fehler beim Laden des Eintrags.")
def build_structured_report_pdf(
*,
profile_id: str,
profile_name: str,
payload: ReportProfilePayload,
) -> bytes:
"""Vollständiges PDF als Bytes (A4)."""
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=16 * mm,
bottomMargin=16 * mm,
)
styles = getSampleStyleSheet()
story: list[Any] = []
title = (payload.document_title or "").strip() or f"{profile_name} Bericht"
story.append(Paragraph(escape(title), styles["Title"]))
story.append(Spacer(1, 6 * mm))
for block in payload.blocks:
if isinstance(block, SectionBlock):
story.append(Spacer(1, 4 * mm))
story.append(Paragraph(escape(block.title), styles["Heading2"]))
story.append(Spacer(1, 2 * mm))
elif isinstance(block, VizBundleBlock):
append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config)
elif isinstance(block, ChartBlock):
try:
chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days)
except Exception as e:
logger.warning("chart fetch %s: %s", block.chart_id, e)
story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"]))
continue
meta = chart.get("metadata") or {}
if meta.get("confidence") == "insufficient":
msg = meta.get("message") or "Nicht genug Daten"
story.append(Paragraph(f"<i>{block.chart_id}</i>: {msg}", styles["Normal"]))
story.append(Spacer(1, 3 * mm))
continue
try:
png = chart_payload_to_png(chart)
img_buf = io.BytesIO(png)
iw = 170 * mm
ih = 85 * mm
story.append(RLImage(img_buf, width=iw, height=ih))
except Exception as e:
logger.warning("chart render %s: %s", block.chart_id, e)
story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"]))
story.append(Spacer(1, 4 * mm))
elif isinstance(block, AiInsightBlock):
heading, body = _insight_text(profile_id, block.insight_id)
if block.title.strip():
story.append(Paragraph(escape(block.title), styles["Heading3"]))
else:
story.append(Paragraph(escape(heading), styles["Heading3"]))
for para in body.split("\n\n"):
p = (para or "").strip()
if p:
story.append(Paragraph(escape(p), styles["BodyText"]))
story.append(Spacer(1, 4 * mm))
doc.build(story)
return buf.getvalue()

View File

@ -0,0 +1,126 @@
"""
Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout).
Block-Typen:
- section: Überschrift
- viz_bundle: Layer-2b-Ver bundles (KPIs, Text, Charts) gleiche Config wie Dashboard
- chart: diagramm via report_chart_fetch (chart_id + window_days)
- ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl
"""
from __future__ import annotations
from typing import Any, Literal, Union
from pydantic import BaseModel, Field, model_validator
from dashboard_widget_config import validate_widget_entry_config
ALLOWED_CHART_IDS: frozenset[str] = frozenset(
{
"weight_trend",
"energy_balance",
"macro_distribution",
"training_volume",
"training_type_distribution",
}
)
_MAX_BLOCKS = 32
ALLOWED_VIZ_BUNDLE_IDS: frozenset[str] = frozenset(
{
"body_history_viz",
"nutrition_history_viz",
"fitness_history_viz",
"recovery_history_viz",
"history_overview_viz",
}
)
class SectionBlock(BaseModel):
type: Literal["section"] = "section"
title: str = Field(min_length=1, max_length=200)
class ChartBlock(BaseModel):
type: Literal["chart"] = "chart"
chart_id: str = Field(min_length=1, max_length=64)
window_days: int = Field(default=28, ge=7, le=365)
@model_validator(mode="after")
def _chart_known(self) -> ChartBlock:
if self.chart_id not in ALLOWED_CHART_IDS:
raise ValueError(f"Unbekanntes chart_id: {self.chart_id!r} (erlaubt: {sorted(ALLOWED_CHART_IDS)})")
return self
class AiInsightBlock(BaseModel):
type: Literal["ai_insight"] = "ai_insight"
title: str = Field(default="", max_length=200)
insight_id: str | None = Field(default=None, max_length=48)
class VizBundleBlock(BaseModel):
"""Gleiche Layer-2b-Bundles wie im Dashboard; config wie validate_widget_entry_config."""
type: Literal["viz_bundle"] = "viz_bundle"
bundle_id: str = Field(min_length=1, max_length=64)
config: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def _bundle_config(self) -> VizBundleBlock:
if self.bundle_id not in ALLOWED_VIZ_BUNDLE_IDS:
raise ValueError(
f"Unbekanntes bundle_id: {self.bundle_id!r} (erlaubt: {sorted(ALLOWED_VIZ_BUNDLE_IDS)})"
)
self.config = validate_widget_entry_config(self.bundle_id, self.config)
return self
class ReportProfilePayload(BaseModel):
version: Literal[1] = 1
document_title: str = Field(default="", max_length=120)
blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock, VizBundleBlock]]
@model_validator(mode="after")
def _blocks_limit(self) -> ReportProfilePayload:
if len(self.blocks) > _MAX_BLOCKS:
raise ValueError(f"Maximal {_MAX_BLOCKS} Blöcke erlaubt")
if not self.blocks:
raise ValueError("Mindestens ein Block erforderlich")
return self
def to_stored_dict(self) -> dict:
return {
"version": self.version,
"document_title": self.document_title,
"blocks": [b.model_dump(mode="json") for b in self.blocks],
}
def default_report_profile_dict() -> dict:
"""Standard-Bericht beim ersten Zugriff (ohne DB-Zeile)."""
p = ReportProfilePayload(
document_title="",
blocks=[
SectionBlock(title="Verlauf — Körper"),
VizBundleBlock(bundle_id="body_history_viz", config={"chart_days": 90}),
SectionBlock(title="Verlauf — Ernährung"),
VizBundleBlock(bundle_id="nutrition_history_viz", config={"chart_days": 90}),
SectionBlock(title="Verlauf — Fitness"),
VizBundleBlock(bundle_id="fitness_history_viz", config={"chart_days": 90}),
SectionBlock(title="Verlauf — Erholung"),
VizBundleBlock(bundle_id="recovery_history_viz", config={"chart_days": 90}),
SectionBlock(title="Gesamtübersicht"),
VizBundleBlock(bundle_id="history_overview_viz", config={"chart_days": 90}),
],
)
return p.to_stored_dict()
def parse_report_profile(raw: dict | None) -> ReportProfilePayload:
if raw is None or raw == {}:
return ReportProfilePayload.model_validate(default_report_profile_dict())
return ReportProfilePayload.model_validate(raw)

View File

@ -0,0 +1,386 @@
"""
Layer-2b Verlauf-Bundles PDF-Abschnitte (KPIs + eingebettete Chart-Payloads).
Gleiche Datenquellen und Config-Validierung wie Dashboard-Widgets (dashboard_widget_config).
"""
from __future__ import annotations
import io
import logging
from typing import Any
from reportlab.lib.units import mm
from reportlab.platypus import Image as RLImage
from reportlab.platypus import Paragraph, Spacer
from xml.sax.saxutils import escape
from dashboard_widget_config import validate_widget_entry_config
from data_layer.body_viz import get_body_history_viz_bundle
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
from data_layer.history_overview_viz import get_history_overview_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
from report_chart_plotting import chart_payload_to_png
logger = logging.getLogger(__name__)
BUNDLE_HEADINGS: dict[str, str] = {
"body_history_viz": "Körper — Kennwerte & Verlauf",
"nutrition_history_viz": "Ernährung — Kennwerte & Charts",
"fitness_history_viz": "Fitness / Training",
"recovery_history_viz": "Erholung & Vitalwerte",
"history_overview_viz": "Gesamtübersicht & Korrelationen",
}
def _add_chart_to_story(story: list, styles: dict, payload: dict[str, Any], caption: str | None = None) -> None:
meta = payload.get("metadata") or {}
if meta.get("confidence") == "insufficient":
msg = escape(meta.get("message") or "Keine Daten")
story.append(Paragraph(f"<i>{msg}</i>", styles["Normal"]))
story.append(Spacer(1, 2 * mm))
return
if caption:
story.append(Paragraph(f"<b>{escape(caption)}</b>", styles["Normal"]))
try:
png = chart_payload_to_png(payload)
story.append(RLImage(io.BytesIO(png), width=170 * mm, height=85 * mm))
except Exception as e:
logger.warning("bundle chart png: %s", e)
story.append(Paragraph("Diagramm konnte nicht gerendert werden.", styles["Normal"]))
story.append(Spacer(1, 4 * mm))
def _append_interpretation_tiles(story: list, styles: dict, tiles: list[dict[str, Any]]) -> None:
if not tiles:
return
story.append(Paragraph("<b>Einschätzungen</b>", styles["Heading4"]))
for t in tiles:
cat = escape(str(t.get("category") or t.get("title") or ""))
title = t.get("title")
detail = t.get("detail")
val = t.get("value")
parts = [f"<b>{cat}</b>"]
if title and str(title) != str(cat):
parts.append(escape(str(title)))
if val is not None and val != "":
parts.append(f"({escape(str(val))})")
story.append(Paragraph("".join(parts), styles["Normal"]))
if detail:
story.append(Paragraph(escape(str(detail)[:500]), styles["BodyText"]))
story.append(Spacer(1, 3 * mm))
def _append_kpi_tiles_fitness_nutreco(story: list, styles: dict, tiles: list[dict[str, Any]], compact: bool) -> None:
if not tiles:
return
use = tiles[:4] if compact else tiles
story.append(Paragraph("<b>KPI-Kacheln</b>", styles["Heading4"]))
for t in use:
cat = escape(str(t.get("category") or t.get("title") or ""))
val = escape(str(t.get("value") or ""))
sub = t.get("sublabel") or t.get("body")
line = f"• <b>{cat}</b>: {val}"
if sub:
line += f"{escape(str(sub)[:180])}"
story.append(Paragraph(line, styles["Normal"]))
story.append(Spacer(1, 3 * mm))
def _append_insights_lines(story: list, styles: dict, insights: list[dict[str, Any]], label: str) -> None:
if not insights:
return
story.append(Paragraph(f"<b>{escape(label)}</b>", styles["Heading4"]))
for item in insights:
title = item.get("title") or item.get("heading")
body = item.get("body") or item.get("text")
if title:
story.append(Paragraph(escape(str(title)), styles["Normal"]))
if body:
story.append(Paragraph(escape(str(body)[:600]), styles["BodyText"]))
story.append(Spacer(1, 2 * mm))
def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | None:
series = bundle_weight.get("series") or []
if len(series) < 2:
return None
labels = [str(p.get("date") or "") for p in series]
datasets: list[dict[str, Any]] = [
{
"label": "Gewicht (kg)",
"data": [safe_float(p.get("weight")) for p in series],
"borderColor": "#1D9E75",
}
]
if any(p.get("avg7") is not None for p in series):
datasets.append(
{
"label": "Ø 7T",
"data": [safe_float(p.get("avg7")) for p in series],
"borderColor": "#378ADD",
}
)
return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}}
def _line_payload_from_points(
points: list[dict[str, Any]],
x_key: str,
y_key: str,
label: str,
) -> dict[str, Any] | None:
if len(points) < 2:
return None
labels = [str(p.get(x_key) or "") for p in points]
ys = [safe_float(p.get(y_key)) for p in points]
return {
"chart_type": "line",
"data": {
"labels": labels,
"datasets": [{"label": label, "data": ys, "borderColor": "#1D9E75"}],
},
"metadata": {"confidence": "high"},
}
def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
days = int(cfg.get("chart_days") or 30)
bundle = get_body_history_viz_bundle(profile_id, days)
story.append(Paragraph(escape(BUNDLE_HEADINGS["body_history_viz"]), styles["Heading2"]))
if bundle.get("confidence") == "insufficient":
story.append(Paragraph(escape(bundle.get("message") or "Keine Körperdaten"), styles["Normal"]))
story.append(Spacer(1, 4 * mm))
return
summ = bundle.get("summary") or {}
if summ:
w = summ.get("weight_kg")
bf = summ.get("body_fat_pct")
parts = []
if w is not None:
parts.append(f"Gewicht: {w} kg")
if bf is not None:
parts.append(f"KF%: {bf}")
if parts:
story.append(Paragraph(escape(" · ".join(parts)), styles["Normal"]))
story.append(Spacer(1, 2 * mm))
if cfg.get("show_kpis", True):
_append_interpretation_tiles(story, styles, bundle.get("interpretation_tiles") or [])
w = bundle.get("weight") or {}
if cfg.get("show_weight_chart", True):
pl = _weight_series_payload(w)
if pl:
_add_chart_to_story(story, styles, pl, "Gewicht")
cal = bundle.get("caliper") or {}
if cfg.get("show_body_fat_chart", False):
ser = cal.get("series") or []
pts = [{"date": p.get("date"), "y": p.get("body_fat_pct")} for p in ser if p.get("body_fat_pct") is not None]
pl = _line_payload_from_points(pts, "date", "y", "KF %")
if pl:
_add_chart_to_story(story, styles, pl, "Körperfett (Caliper)")
circ = bundle.get("circumference") or {}
if cfg.get("show_proportion_chart", False):
prop = circ.get("proportion_series") or []
pts = [{"date": p.get("date"), "y": p.get("v_taper_cm")} for p in prop if p.get("v_taper_cm") is not None]
pl = _line_payload_from_points(pts, "date", "y", "V-Taper (cm)")
if pl:
_add_chart_to_story(story, styles, pl, "Proportion (BrustTaille)")
if cfg.get("show_circumference_index_chart", False):
idx = circ.get("index_series") or []
if len(idx) >= 2:
labels = [str(p.get("date") or "") for p in idx]
ds: list[dict[str, Any]] = []
for key, lab, col in (
("waist_idx", "Taille-Index", "#D85A30"),
("chest_idx", "Brust-Index", "#1D9E75"),
("belly_idx", "Bauch-Index", "#378ADD"),
):
ys = [safe_float(p.get(key)) for p in idx]
if any(v is not None for v in ys):
ds.append({"label": lab, "data": ys, "borderColor": col})
if ds:
pl = {"chart_type": "line", "data": {"labels": labels, "datasets": ds}, "metadata": {"confidence": "high"}}
_add_chart_to_story(story, styles, pl, "Umfang-Indizes")
story.append(Spacer(1, 2 * mm))
def _append_nutrition_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
days = int(cfg.get("chart_days") or 30)
bundle = get_nutrition_history_viz_bundle(profile_id, days)
story.append(Paragraph(escape(BUNDLE_HEADINGS["nutrition_history_viz"]), styles["Heading2"]))
if not bundle.get("has_nutrition_entries"):
story.append(Paragraph(escape(bundle.get("message") or "Keine Ernährungsdaten"), styles["Normal"]))
story.append(Spacer(1, 4 * mm))
return
compact = cfg.get("kpi_detail") == "compact"
if cfg.get("show_kpis", True):
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
if cfg.get("show_heuristics", False):
h = bundle.get("nutrition_correlation_heuristics") or []
for item in h:
t = item.get("text") or item.get("title")
if t:
story.append(Paragraph(f"{escape(str(t))}", styles["Normal"]))
story.append(Spacer(1, 2 * mm))
charts = bundle.get("chart_payloads") or {}
if cfg.get("show_calorie_balance_chart", False) or cfg.get("show_energy_protein_charts", False):
pl = charts.get("energy_balance")
if pl:
_add_chart_to_story(story, styles, pl, "Energiebilanz")
if cfg.get("show_energy_protein_charts", False) or cfg.get("show_protein_lean_chart", False):
pl = charts.get("protein_adequacy")
if pl:
_add_chart_to_story(story, styles, pl, "Protein-Adäquanz")
pl2 = charts.get("nutrition_adherence")
if pl2:
_add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence")
if cfg.get("show_macro_distribution_pair", False) or cfg.get("show_macro_daily_bars", False):
wm = bundle.get("weekly_macro_chart")
if isinstance(wm, dict) and wm.get("chart_type"):
_add_chart_to_story(story, styles, wm, "Makros (wöchentlich)")
kw = bundle.get("kcal_vs_weight") or {}
if cfg.get("show_kcal_vs_weight", False) and kw.get("points"):
pts = kw["points"]
if pts:
pl = _line_payload_from_points(pts, "date", "kcal", "kcal")
if pl:
_add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit")
story.append(Spacer(1, 2 * mm))
def _append_fitness_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
days = int(cfg.get("chart_days") or 30)
bundle = get_fitness_dashboard_viz_bundle(profile_id, days)
story.append(Paragraph(escape(BUNDLE_HEADINGS["fitness_history_viz"]), styles["Heading2"]))
if not bundle.get("has_activity_entries"):
story.append(Paragraph(escape(bundle.get("message") or "Keine Aktivitätsdaten"), styles["Normal"]))
story.append(Spacer(1, 4 * mm))
return
compact = cfg.get("kpi_detail") == "compact"
if cfg.get("show_kpis", True):
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
if cfg.get("show_progress_insights", False):
_append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
charts = bundle.get("charts") or {}
if cfg.get("show_chart_training_volume", True) and charts.get("training_volume"):
_add_chart_to_story(story, styles, charts["training_volume"], "Trainingsvolumen")
if cfg.get("show_chart_training_type_distribution", True) and charts.get("training_type_distribution"):
_add_chart_to_story(story, styles, charts["training_type_distribution"], "Trainingsarten")
if cfg.get("show_chart_quality_sessions", False) and charts.get("quality_sessions"):
_add_chart_to_story(story, styles, charts["quality_sessions"], "Qualitätssessions")
if cfg.get("show_chart_load_monitoring", False) and charts.get("load_monitoring"):
_add_chart_to_story(story, styles, charts["load_monitoring"], "Last / ACWR")
story.append(Spacer(1, 2 * mm))
def _append_recovery_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
days = int(cfg.get("chart_days") or 30)
bundle = get_recovery_dashboard_viz_bundle(profile_id, days)
story.append(Paragraph(escape(BUNDLE_HEADINGS["recovery_history_viz"]), styles["Heading2"]))
if not bundle.get("has_recovery_data"):
story.append(Paragraph(escape(bundle.get("message") or "Keine Erholungsdaten"), styles["Normal"]))
story.append(Spacer(1, 4 * mm))
return
compact = cfg.get("kpi_detail") == "compact"
if cfg.get("show_kpis", True):
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
if cfg.get("show_progress_insights", False):
_append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
charts = bundle.get("charts") or {}
if cfg.get("show_chart_recovery_score", True) and charts.get("recovery_score"):
_add_chart_to_story(story, styles, charts["recovery_score"], "Recovery-Score")
if cfg.get("show_chart_hrv_rhr", True) and charts.get("hrv_rhr"):
_add_chart_to_story(story, styles, charts["hrv_rhr"], "HRV / RHR")
if cfg.get("show_chart_sleep_quality", True) and charts.get("sleep_duration_quality"):
_add_chart_to_story(story, styles, charts["sleep_duration_quality"], "Schlaf Dauer & Qualität")
if cfg.get("show_chart_sleep_debt", False) and charts.get("sleep_debt"):
_add_chart_to_story(story, styles, charts["sleep_debt"], "Schlafschuld")
if cfg.get("show_vitals_extra_trends", False):
if charts.get("vital_signs_matrix"):
_add_chart_to_story(story, styles, charts["vital_signs_matrix"], "Vital-Matrix")
if charts.get("vitals_history"):
_add_chart_to_story(story, styles, charts["vitals_history"], "Vital-Trends")
story.append(Spacer(1, 2 * mm))
def _append_history_overview_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
days = int(cfg.get("chart_days") or 30)
bundle = get_history_overview_viz_bundle(profile_id, days)
story.append(Paragraph(escape(BUNDLE_HEADINGS["history_overview_viz"]), styles["Heading2"]))
sect_keys = {
"body": cfg.get("show_section_body", True),
"nutrition": cfg.get("show_section_nutrition", True),
"fitness": cfg.get("show_section_fitness", True),
"recovery": cfg.get("show_section_recovery", True),
}
for sec in bundle.get("sections") or []:
sid = sec.get("id")
if not sect_keys.get(str(sid), True):
continue
title = escape(str(sec.get("title") or sid))
line = escape(str(sec.get("summary_line") or ""))
story.append(Paragraph(f"<b>{title}</b>: {line}", styles["Normal"]))
for it in sec.get("interpretation_short") or []:
t = it.get("title") if isinstance(it, dict) else None
if t:
story.append(Paragraph(f"{escape(str(t))}", styles["BodyText"]))
for k in sec.get("kpi_short") or []:
if isinstance(k, dict):
cat = k.get("category") or k.get("title")
val = k.get("value")
if cat:
story.append(Paragraph(f"{escape(str(cat))}: {escape(str(val or ''))}", styles["BodyText"]))
story.append(Spacer(1, 2 * mm))
if cfg.get("show_correlation_c1_c3", True) or cfg.get("show_drivers_c4", True):
lag = bundle.get("lag_correlations") or {}
we = lag.get("weight_energy") or {}
if we.get("available") and (we.get("interpretation") or we.get("label")):
lab = escape(str(we.get("label") or "C1"))
interp = escape(str(we.get("interpretation") or "").strip())
if interp:
story.append(Paragraph(f"{lab}: {interp}", styles["Normal"]))
charts = bundle.get("chart_payloads") or {}
if cfg.get("show_correlation_c1_c3", True):
for key, cap in (
("c1_weight_energy", "Korrelation Gewicht / Energie"),
("c2_protein_lbm", "Protein / Magermasse"),
("c3_load_vitals", "Last / Vitalwerte"),
):
pl = charts.get(key)
if pl:
_add_chart_to_story(story, styles, pl, cap)
if cfg.get("show_drivers_c4", True):
pl = charts.get("c4_recovery_performance")
if pl:
_add_chart_to_story(story, styles, pl, "Top-Treiber")
drv = (bundle.get("lag_correlations") or {}).get("recovery_performance") or {}
for d in (drv.get("drivers") or [])[:12]:
if isinstance(d, dict):
lab = d.get("label") or d.get("factor")
val = d.get("impact") or d.get("score")
if lab:
story.append(Paragraph(f"{escape(str(lab))}: {escape(str(val or ''))}", styles["Normal"]))
story.append(Spacer(1, 2 * mm))
def append_viz_bundle_to_story(
story: list,
styles: dict,
profile_id: str,
bundle_id: str,
raw_config: dict[str, Any],
) -> None:
cfg = validate_widget_entry_config(bundle_id, raw_config)
if bundle_id == "body_history_viz":
_append_body_bundle(story, styles, profile_id, cfg)
elif bundle_id == "nutrition_history_viz":
_append_nutrition_bundle(story, styles, profile_id, cfg)
elif bundle_id == "fitness_history_viz":
_append_fitness_bundle(story, styles, profile_id, cfg)
elif bundle_id == "recovery_history_viz":
_append_recovery_bundle(story, styles, profile_id, cfg)
elif bundle_id == "history_overview_viz":
_append_history_overview_bundle(story, styles, profile_id, cfg)
else:
story.append(Paragraph(escape(f"Unbekanntes Bundle: {bundle_id}"), styles["Normal"]))

View File

@ -9,3 +9,6 @@ bcrypt==4.1.3
slowapi==0.1.9 slowapi==0.1.9
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dateutil==2.9.0 python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
matplotlib==3.8.4
reportlab==4.2.0

View File

@ -7,16 +7,76 @@ import csv
import io import io
import uuid import uuid
import logging import logging
import re
import calendar
from datetime import date
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from models import ActivityEntry from models import ActivityEntry, ActivityMetricsReplace
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage from feature_logger import log_feature_usage
from quality_filter import get_quality_filter_sql from quality_filter import get_quality_filter_sql
from data_layer.activity_persistence_orchestrator import (
get_mappable_activity_field_catalog,
insert_activity_from_entry,
run_activity_post_write_hooks,
update_activity_from_entry,
find_activity_duplicate_id,
update_activity_columns,
insert_activity_csv_minimal,
run_activity_post_write_hooks_import,
new_activity_id,
)
from data_layer.activity_time_normalize import normalize_activity_start
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
_MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
def _month_date_bounds(ym: str) -> tuple[date, date]:
m = _MONTH_RE.match((ym or "").strip())
if not m:
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein")
y, mo = int(m.group(1)), int(m.group(2))
if mo < 1 or mo > 12:
raise HTTPException(status_code=400, detail="Ungültiger Monat")
last = calendar.monthrange(y, mo)[1]
return date(y, mo, 1), date(y, mo, last)
_ACTIVITY_DEDUP_WINDOW = """
PARTITION BY al.profile_id, al.date,
COALESCE(al.activity_type, ''),
COALESCE(al.start_time::text, ''),
COALESCE(ROUND(al.duration_min::numeric, 1), '-999999'::numeric),
COALESCE(ROUND(al.kcal_active::numeric, 1), '-999999'::numeric)
ORDER BY al.created DESC NULLS LAST, al.id DESC
"""
def _activity_rows_after_list_query(cur):
rows = []
for r in cur.fetchall():
d = r2d(r)
if not d:
continue
d.pop("_dup_rn", None)
rows.append(d)
return rows
def _return_activity_list_rows(cur, rows: list) -> list:
"""Layer-1: gemergte session_metrics wie Detail-Pfad (Batch)."""
enrich_sessions_with_metrics(cur, rows)
return rows
# Evaluation import with error handling (Phase 1.2) # Evaluation import with error handling (Phase 1.2)
try: try:
@ -27,51 +87,143 @@ except Exception as e:
EVALUATION_AVAILABLE = False EVALUATION_AVAILABLE = False
evaluate_and_save_activity = None evaluate_and_save_activity = None
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
@router.get("") @router.get("")
def list_activity( def list_activity(
limit: int = Query(200, ge=1, le=50_000), limit: int = Query(200, ge=1, le=50_000),
offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"),
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE days (Kalendertage)"), days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE days (Kalendertage)"),
x_profile_id: Optional[str] = Header(default=None), month: Optional[str] = Query(
None,
description='Kalendermonat "YYYY-MM" (ganzer Monat; schließt days und offset aus)',
),
skip_quality_filter: bool = Query(
False,
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.",
),
collapse_duplicate_sessions: bool = Query(
False,
description="True = Sessions mit gleichem Datum/Typ/Startzeit/Dauer/Kcal falten (neueste Zeile behalten).",
),
session: dict = Depends(require_auth), session: dict = Depends(require_auth),
): ):
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*).""" """Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
pid = get_pid(x_profile_id) # Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet).
pid = str(session["profile_id"])
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Issue #31: Apply global quality filter (profile from DB = saved level) # Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter)
if skip_quality_filter:
quality_filter = ""
quality_filter_al = ""
else:
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone()) profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile) quality_filter = get_quality_filter_sql(profile or {}, "")
quality_filter_al = get_quality_filter_sql(profile or {}, "al.")
if month:
if days is not None:
raise HTTPException(status_code=400, detail="month und days schließen sich aus")
if offset != 0:
raise HTTPException(status_code=400, detail="month und offset schließen sich aus")
d0, d1 = _month_date_bounds(month)
if collapse_duplicate_sessions:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
{quality_filter_al}
AND al.date >= %s AND al.date <= %s
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT %s
""",
(pid, d0, d1, limit),
)
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
AND date >= %s AND date <= %s
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s
""",
(pid, d0, d1, limit),
)
return _return_activity_list_rows(
cur, [r2d(r) for r in cur.fetchall()]
)
if days is not None: if days is not None:
if collapse_duplicate_sessions:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
{quality_filter_al}
AND al.date >= (CURRENT_DATE - %s::integer)
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT %s OFFSET %s
""",
(pid, days, limit, offset),
)
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
cur.execute( cur.execute(
f""" f"""
SELECT * FROM activity_log SELECT * FROM activity_log
WHERE profile_id=%s WHERE profile_id=%s
{quality_filter} {quality_filter}
AND date >= (CURRENT_DATE - %s::integer) AND date >= (CURRENT_DATE - %s::integer)
ORDER BY date DESC, start_time DESC ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s LIMIT %s OFFSET %s
""", """,
(pid, days, limit), (pid, days, limit, offset),
) )
else: else:
if collapse_duplicate_sessions:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
{quality_filter_al}
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT %s OFFSET %s
""",
(pid, limit, offset),
)
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
cur.execute( cur.execute(
f""" f"""
SELECT * FROM activity_log SELECT * FROM activity_log
WHERE profile_id=%s WHERE profile_id=%s
{quality_filter} {quality_filter}
ORDER BY date DESC, start_time DESC ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s LIMIT %s OFFSET %s
""", """,
(pid, limit), (pid, limit, offset),
) )
return [r2d(r) for r in cur.fetchall()] return _return_activity_list_rows(cur, [r2d(r) for r in cur.fetchall()])
@router.post("") @router.post("")
@ -95,37 +247,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
) )
eid = str(uuid.uuid4()) eid = str(uuid.uuid4())
d = e.model_dump()
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("""INSERT INTO activity_log insert_activity_from_entry(cur, pid, eid, e)
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, run_activity_post_write_hooks(cur, pid, eid)
hr_avg,hr_max,distance_km,rpe,source,notes,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(eid,pid,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['distance_km'],
d['rpe'],d['source'],d['notes']))
# Phase 1.2: Auto-evaluation after INSERT
if EVALUATION_AVAILABLE:
# Load the activity data to evaluate
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, pid)
logger.info(f"[AUTO-EVAL] Evaluated activity {eid} on INSERT")
except Exception as eval_error:
logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}")
# Phase 2: Increment usage counter (always for new entries) # Phase 2: Increment usage counter (always for new entries)
increment_feature_usage(pid, 'activity_entries') increment_feature_usage(pid, 'activity_entries')
@ -133,36 +258,146 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
return {"id":eid,"date":e.date} return {"id":eid,"date":e.date}
@router.get("/stats")
def activity_stats(
skip_quality_filter: bool = Query(
False,
description="True = Statistik-Kacheln ohne Profil-Qualitätsfilter (passend zur /activity-Liste).",
),
session: dict = Depends(require_auth),
):
"""Get activity statistics (last 30 entries)."""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
if skip_quality_filter:
quality_filter = ""
else:
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {}, "")
cur.execute(
f"SELECT COUNT(*)::bigint AS c FROM activity_log WHERE profile_id=%s {quality_filter}",
(pid,),
)
total_in_profile = int(cur.fetchone()["c"])
if skip_quality_filter:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT 30
""",
(pid,),
)
rows = _activity_rows_after_list_query(cur)
else:
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s {quality_filter}
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT 30
""",
(pid,),
)
rows = [r2d(r) for r in cur.fetchall()]
if not rows:
return {
"count": 0,
"sample_size": 0,
"total_in_profile": total_in_profile,
"total_kcal": 0,
"total_min": 0,
"by_type": {},
}
total_kcal = sum(float(r.get("kcal_active") or 0) for r in rows)
total_min = sum(float(r.get("duration_min") or 0) for r in rows)
by_type = {}
for r in rows:
t = r["activity_type"]
by_type.setdefault(t, {"count": 0, "kcal": 0, "min": 0})
by_type[t]["count"] += 1
by_type[t]["kcal"] += float(r.get("kcal_active") or 0)
by_type[t]["min"] += float(r.get("duration_min") or 0)
return {
"count": len(rows),
"sample_size": len(rows),
"total_in_profile": total_in_profile,
"total_kcal": round(total_kcal),
"total_min": round(total_min),
"by_type": by_type,
}
@router.get("/uncategorized")
def list_uncategorized_activities(
session: dict = Depends(require_auth),
):
"""Get activities without assigned training type, grouped by activity_type."""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT activity_type, COUNT(*) as count,
MIN(date) as first_date, MAX(date) as last_date
FROM activity_log
WHERE profile_id=%s AND training_type_id IS NULL
GROUP BY activity_type
ORDER BY count DESC
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/mappable-fields")
def get_activity_mappable_fields(session: dict = Depends(require_auth)):
"""
Vollständiger Katalog für Import-Mappings (activity_log-Kernfelder + alle aktiven training_parameters).
Werte für Keys ohne Schema zur konkreten Session werden beim Import ignoriert.
"""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
return get_mappable_activity_field_catalog(cur, pid)
@router.get("/attribute-schema")
def get_activity_attribute_schema(
training_category: Optional[str] = Query(None),
training_type_id: Optional[int] = Query(None),
session: dict = Depends(require_auth),
):
"""
Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session
gleiche Logik wie resolve_activity_attribute_schema.
"""
from data_layer.activity_session_metrics import resolve_activity_attribute_schema
cat = (training_category or "").strip() or None
with get_db() as conn:
cur = get_cursor(conn)
schema = resolve_activity_attribute_schema(cur, cat, training_type_id)
return {"schema": schema}
@router.put("/{eid}") @router.put("/{eid}")
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Update existing activity entry.""" """Update existing activity entry."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
with get_db() as conn: with get_db() as conn:
d = e.model_dump()
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", update_activity_from_entry(cur, pid, eid, e)
list(d.values())+[eid,pid]) run_activity_post_write_hooks(cur, pid, eid)
# Phase 1.2: Auto-evaluation after UPDATE
if EVALUATION_AVAILABLE:
# Load the updated activity data to evaluate
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, pid)
logger.info(f"[AUTO-EVAL] Re-evaluated activity {eid} on UPDATE")
except Exception as eval_error:
logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}")
return {"id":eid} return {"id":eid}
@ -177,25 +412,66 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None),
return {"ok":True} return {"ok":True}
@router.get("/stats") @router.put("/{eid}/metrics")
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def replace_activity_metrics(
"""Get activity statistics (last 30 entries).""" eid: str,
pid = get_pid(x_profile_id) body: ActivityMetricsReplace,
session: dict = Depends(require_auth),
):
"""
Voller Ersatz der EAV-Session-Metriken (siehe ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md).
"""
from data_layer.activity_session_metrics import (
ActivitySessionMetricsError,
replace_activity_session_metrics,
)
pid = str(session["profile_id"])
payload = [m.model_dump() for m in body.metrics]
try:
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( metrics = replace_activity_session_metrics(cur, pid, eid, payload)
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) conn.commit()
rows = [r2d(r) for r in cur.fetchall()] except ActivitySessionMetricsError as err:
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}} raise HTTPException(err.status_code, err.detail) from err
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows) return {"id": eid, "metrics": metrics}
total_min=sum(float(r.get('duration_min') or 0) for r in rows)
by_type={}
for r in rows: @router.get("/{eid}")
t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0}) def get_activity_session(
by_type[t]['count']+=1 eid: str,
by_type[t]['kcal']+=float(r.get('kcal_active') or 0) use_form_schema: bool = Query(
by_type[t]['min']+=float(r.get('duration_min') or 0) False,
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type} description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile",
),
training_category: Optional[str] = Query(None),
training_type_id: Optional[int] = Query(None),
session: dict = Depends(require_auth),
):
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
from data_layer.activity_session_metrics import (
ActivitySessionMetricsError,
get_activity_session_logical_unit,
)
from data_layer.utils import serialize_dates
pid = str(session["profile_id"])
try:
with get_db() as conn:
cur = get_cursor(conn)
unit = get_activity_session_logical_unit(
cur,
pid,
eid,
use_form_training_context=use_form_schema,
form_training_category=training_category,
form_training_type_id=training_type_id,
)
except ActivitySessionMetricsError as err:
raise HTTPException(err.status_code, err.detail) from err
unit["header"] = serialize_dates(unit["header"])
return unit
def get_training_type_for_activity_with_cursor(cur, activity_type: str, profile_id: str | None = None): def get_training_type_for_activity_with_cursor(cur, activity_type: str, profile_id: str | None = None):
@ -251,23 +527,6 @@ def get_training_type_for_activity(activity_type: str, profile_id: str = None):
return get_training_type_for_activity_with_cursor(cur, activity_type, profile_id) return get_training_type_for_activity_with_cursor(cur, activity_type, profile_id)
@router.get("/uncategorized")
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get activities without assigned training type, grouped by activity_type."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT activity_type, COUNT(*) as count,
MIN(date) as first_date, MAX(date) as last_date
FROM activity_log
WHERE profile_id=%s AND training_type_id IS NULL
GROUP BY activity_type
ORDER BY count DESC
""", (pid,))
return [r2d(r) for r in cur.fetchall()]
@router.post("/bulk-categorize") @router.post("/bulk-categorize")
def bulk_categorize_activities( def bulk_categorize_activities(
data: dict, data: dict,
@ -353,7 +612,10 @@ def bulk_categorize_activities(
@router.post("/import-csv") @router.post("/import-csv")
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Import Apple Health workout CSV with automatic training type mapping.""" """
Legacy-Upload (Apple Health Workout-CSV-Spaltennamen).
Persistenz läuft über activity_persistence_orchestrator gleiche Schicht wie Universal-CSV.
"""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
raw = await file.read() raw = await file.read()
try: text = raw.decode('utf-8') try: text = raw.decode('utf-8')
@ -367,9 +629,11 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
for row in reader: for row in reader:
wtype = row.get('Workout Type','').strip() wtype = row.get('Workout Type','').strip()
start = row.get('Start','').strip() start = row.get('Start','').strip()
if not wtype or not start: continue if not wtype or not start:
try: date = start[:10] continue
except: continue workout_date, workout_start_t = normalize_activity_start(start)
if not workout_date:
continue
dur = row.get('Duration','').strip() dur = row.get('Duration','').strip()
duration_min = None duration_min = None
if dur: if dur:
@ -386,106 +650,82 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
# Map activity_type to training_type_id using database mappings # Map activity_type to training_type_id using database mappings
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid) training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
kcal_a = kj(row.get("Aktive Energie (kJ)", ""))
kcal_r = kj(row.get("Ruheeinträge (kJ)", ""))
hr_av = tf(row.get("Durchschn. Herzfrequenz (count/min)", ""))
hr_mx = tf(row.get("Max. Herzfrequenz (count/min)", ""))
dist_km = tf(row.get("Distanz (km)", ""))
try: try:
# Check if entry already exists (duplicate detection by date + start_time) existing_id = find_activity_duplicate_id(cur, pid, workout_date, workout_start_t)
cur.execute(""" if existing_id:
SELECT id FROM activity_log update_activity_columns(
WHERE profile_id = %s AND date = %s AND start_time = %s cur,
""", (pid, date, start)) pid,
existing = cur.fetchone() str(existing_id),
{
if existing: "start_time": workout_start_t,
# Update existing entry (e.g., to add training type mapping) "end_time": row.get("End", "") or None,
existing_id = existing['id'] "activity_type": wtype,
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
WHERE id = %s
""", (
row.get('End',''), wtype, duration_min,
kj(row.get('Aktive Energie (kJ)','')),
kj(row.get('Ruheeinträge (kJ)','')),
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
tf(row.get('Max. Herzfrequenz (count/min)','')),
tf(row.get('Distanz (km)','')),
training_type_id, training_category, training_subcategory,
existing_id
))
skipped += 1 # Count as skipped (not newly inserted)
# Phase 1.2: Auto-evaluation after CSV import UPDATE
if EVALUATION_AVAILABLE and training_type_id:
try:
# Build activity dict for evaluation
activity_dict = {
"id": existing_id,
"profile_id": pid,
"date": date,
"training_type_id": training_type_id,
"duration_min": duration_min, "duration_min": duration_min,
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), "kcal_active": kcal_a,
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')), "kcal_resting": kcal_r,
"distance_km": tf(row.get('Distanz (km)','')), "hr_avg": hr_av,
"kcal_active": kj(row.get('Aktive Energie (kJ)','')), "hr_max": hr_mx,
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')), "distance_km": dist_km,
"rpe": None, "training_type_id": training_type_id,
"pace_min_per_km": None, "training_category": training_category,
"cadence": None, "training_subcategory": training_subcategory,
"elevation_gain": None },
} )
evaluate_and_save_activity(cur, existing_id, activity_dict, training_type_id, pid) skipped += 1
logger.debug(f"[AUTO-EVAL] Re-evaluated updated activity {existing_id}") run_activity_post_write_hooks_import(
except Exception as eval_error: cur,
logger.warning(f"[AUTO-EVAL] Failed to re-evaluate updated activity {existing_id}: {eval_error}") pid,
str(existing_id),
workout_date=workout_date,
training_type_id=training_type_id,
duration_min=duration_min,
hr_avg=hr_av,
hr_max=hr_mx,
distance_km=dist_km,
kcal_active=kcal_a,
kcal_resting=kcal_r,
)
else: else:
# Insert new entry new_id = new_activity_id()
new_id = str(uuid.uuid4()) insert_activity_csv_minimal(
cur.execute("""INSERT INTO activity_log cur,
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, pid,
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created) new_id,
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""", date_iso=workout_date,
(new_id,pid,date,start,row.get('End',''),wtype,duration_min, start_time=workout_start_t,
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), end_time=row.get("End", "") or None,
tf(row.get('Durchschn. Herzfrequenz (count/min)','')), activity_type=wtype,
tf(row.get('Max. Herzfrequenz (count/min)','')), duration_min=duration_min,
tf(row.get('Distanz (km)','')), kcal_active=kcal_a,
training_type_id,training_category,training_subcategory)) kcal_resting=kcal_r,
hr_avg=hr_av,
hr_max=hr_mx,
distance_km=dist_km,
training_type_id=training_type_id,
training_category=training_category,
training_subcategory=training_subcategory,
source="apple_health",
)
inserted += 1 inserted += 1
run_activity_post_write_hooks_import(
# Phase 1.2: Auto-evaluation after CSV import INSERT cur,
if EVALUATION_AVAILABLE and training_type_id: pid,
try: new_id,
# Build activity dict for evaluation workout_date=workout_date,
activity_dict = { training_type_id=training_type_id,
"id": new_id, duration_min=duration_min,
"profile_id": pid, hr_avg=hr_av,
"date": date, hr_max=hr_mx,
"training_type_id": training_type_id, distance_km=dist_km,
"duration_min": duration_min, kcal_active=kcal_a,
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), kcal_resting=kcal_r,
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')), )
"distance_km": tf(row.get('Distanz (km)','')),
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
"rpe": None,
"pace_min_per_km": None,
"cadence": None,
"elevation_gain": None
}
evaluate_and_save_activity(cur, new_id, activity_dict, training_type_id, pid)
logger.debug(f"[AUTO-EVAL] Evaluated imported activity {new_id}")
except Exception as eval_error:
logger.warning(f"[AUTO-EVAL] Failed to evaluate imported activity {new_id}: {eval_error}")
except Exception as e: except Exception as e:
logger.warning(f"Import row failed: {e}") logger.warning(f"Import row failed: {e}")
skipped+=1 skipped+=1

View File

@ -14,7 +14,12 @@ from fastapi import APIRouter, HTTPException, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_admin, hash_pin from auth import require_admin, hash_pin
from models import AdminProfileUpdate from models import AdminProfileUpdate
from dashboard_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict from dashboard_layout_schema import (
ALLOWED_WIDGET_IDS,
DashboardLayoutPayload,
merge_missing_catalog_widgets,
product_default_layout_dict,
)
from dashboard_widget_entitlements import widgets_catalog_admin_payload from dashboard_widget_entitlements import widgets_catalog_admin_payload
from widget_catalog import WIDGET_CATALOG from widget_catalog import WIDGET_CATALOG
from widget_feature_requirements_db import ( from widget_feature_requirements_db import (
@ -184,7 +189,7 @@ def admin_get_dashboard_product_default(session: dict = Depends(require_admin)):
"""Aktueller Produkt-Dashboard-Standard (DB oder Code).""" """Aktueller Produkt-Dashboard-Standard (DB oder Code)."""
_ = session _ = session
with get_db() as conn: with get_db() as conn:
layout = get_product_default_base_dict(conn) layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
from_database = get_stored_product_default_validated(conn) is not None from_database = get_stored_product_default_validated(conn) is not None
code_ref = product_default_layout_dict() code_ref = product_default_layout_dict()
return { return {
@ -217,7 +222,7 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin
_ = session _ = session
with get_db() as conn: with get_db() as conn:
delete_product_default_override(conn) delete_product_default_override(conn)
layout = get_product_default_base_dict(conn) layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
return {"ok": True, "layout": layout, "from_database": False} return {"ok": True, "layout": layout, "from_database": False}

View File

@ -0,0 +1,266 @@
"""
Admin: training_category_parameter + training_type_parameter (attribute profiles).
Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from auth import require_admin
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin", tags=["admin", "activity-attribute-profiles"])
class CategoryParameterCreate(BaseModel):
training_category: str = Field(..., min_length=1, max_length=50)
training_parameter_id: int
sort_order: int = 0
required: bool = False
ui_group: Optional[str] = Field(None, max_length=50)
class TypeParameterCreate(BaseModel):
training_type_id: int
training_parameter_id: int
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
class CategoryParameterUpdate(BaseModel):
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
class TypeParameterUpdate(BaseModel):
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
@router.get("/training-category-parameters")
def admin_list_category_parameters(
category: Optional[str] = Query(None, description="Filter: training_types.category"),
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
if category:
cur.execute(
"""
SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
FROM training_category_parameter tcp
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
WHERE tcp.training_category = %s
ORDER BY tcp.sort_order, tp.key
""",
(category.strip(),),
)
else:
cur.execute(
"""
SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
FROM training_category_parameter tcp
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
ORDER BY tcp.training_category, tcp.sort_order, tp.key
"""
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/training-category-parameters")
def admin_add_category_parameter(
body: CategoryParameterCreate,
session: dict = Depends(require_admin),
):
cat = body.training_category.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,))
if not cur.fetchone():
raise HTTPException(404, "training_parameter_id unbekannt")
try:
cur.execute(
"""
INSERT INTO training_category_parameter (
training_category, training_parameter_id, sort_order, required, ui_group
) VALUES (%s,%s,%s,%s,%s)
RETURNING id
""",
(cat, body.training_parameter_id, body.sort_order, body.required, body.ui_group),
)
new_id = cur.fetchone()["id"]
conn.commit()
except Exception as e:
conn.rollback()
if "uq_training_category_parameter" in str(e).lower() or "unique" in str(e).lower():
raise HTTPException(409, "Zuordnung existiert bereits") from e
raise HTTPException(400, str(e)) from e
return {"id": new_id}
@router.put("/training-category-parameters/{link_id}")
def admin_update_category_parameter(
link_id: int,
body: CategoryParameterUpdate,
session: dict = Depends(require_admin),
):
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
cols: list[str] = []
vals: list = []
if "sort_order" in patch:
cols.append("sort_order = %s")
vals.append(patch["sort_order"])
if "required" in patch:
cols.append("required = %s")
vals.append(patch["required"])
if "ui_group" in patch:
cols.append("ui_group = %s")
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(link_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_category_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True, "id": link_id}
@router.delete("/training-category-parameters/{link_id}")
def admin_delete_category_parameter(
link_id: int,
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM training_category_parameter WHERE id = %s RETURNING id",
(link_id,),
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True}
@router.get("/training-type-parameters")
def admin_list_type_parameters(
training_type_id: int = Query(..., ge=1),
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT ttp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
FROM training_type_parameter ttp
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
WHERE ttp.training_type_id = %s
ORDER BY ttp.sort_order NULLS LAST, tp.key
""",
(training_type_id,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/training-type-parameters")
def admin_add_type_parameter(
body: TypeParameterCreate,
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM training_types WHERE id = %s", (body.training_type_id,))
if not cur.fetchone():
raise HTTPException(404, "training_type_id unbekannt")
cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,))
if not cur.fetchone():
raise HTTPException(404, "training_parameter_id unbekannt")
try:
cur.execute(
"""
INSERT INTO training_type_parameter (
training_type_id, training_parameter_id, sort_order, required, ui_group
) VALUES (%s,%s,%s,%s,%s)
RETURNING id
""",
(
body.training_type_id,
body.training_parameter_id,
body.sort_order,
body.required,
body.ui_group,
),
)
new_id = cur.fetchone()["id"]
conn.commit()
except Exception as e:
conn.rollback()
if "uq_training_type_parameter" in str(e).lower() or "unique" in str(e).lower():
raise HTTPException(409, "Zuordnung existiert bereits") from e
raise HTTPException(400, str(e)) from e
return {"id": new_id}
@router.put("/training-type-parameters/{link_id}")
def admin_update_type_parameter(
link_id: int,
body: TypeParameterUpdate,
session: dict = Depends(require_admin),
):
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
cols: list[str] = []
vals: list = []
if "sort_order" in patch:
cols.append("sort_order = %s")
vals.append(patch["sort_order"])
if "required" in patch:
cols.append("required = %s")
vals.append(patch["required"])
if "ui_group" in patch:
cols.append("ui_group = %s")
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(link_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_type_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True, "id": link_id}
@router.delete("/training-type-parameters/{link_id}")
def admin_delete_type_parameter(
link_id: int,
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM training_type_parameter WHERE id = %s RETURNING id",
(link_id,),
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True}

View File

@ -25,6 +25,7 @@ from csv_parser.import_row_processing import (
) )
from csv_parser.module_registry import get_module_definition from csv_parser.module_registry import get_module_definition
from csv_parser.template_validator import validate_csv_template from csv_parser.template_validator import validate_csv_template
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"]) router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"])
@ -181,6 +182,8 @@ async def admin_analyze_csv_for_template(
sig = column_signature(headers) sig = column_signature(headers)
seed_row: dict | None = None seed_row: dict | None = None
field_mappings: dict = {}
type_conversions: dict = {}
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if seed_template_id is not None: if seed_template_id is not None:
@ -215,6 +218,11 @@ async def admin_analyze_csv_for_template(
if best and best_key[0] > 0: if best and best_key[0] > 0:
seed_row = best seed_row = best
mod_def = get_module_definition(module) or {}
eff_fields = dict(mod_def.get("fields") or {})
if module == "activity":
eff_fields = merge_activity_csv_module_fields(cur, eff_fields)
seed_fm = (seed_row or {}).get("field_mappings") or {} seed_fm = (seed_row or {}).get("field_mappings") or {}
if isinstance(seed_fm, str): if isinstance(seed_fm, str):
seed_fm = {} seed_fm = {}
@ -222,8 +230,18 @@ async def admin_analyze_csv_for_template(
if not isinstance(seed_tc, dict): if not isinstance(seed_tc, dict):
seed_tc = {} seed_tc = {}
field_mappings = suggest_field_mappings(headers, module, seed_fm if seed_fm else None) field_mappings = suggest_field_mappings(
type_conversions = build_type_conversions_for_mapping(module, field_mappings, seed_tc if seed_tc else None) headers,
module,
seed_fm if seed_fm else None,
effective_fields=eff_fields,
)
type_conversions = build_type_conversions_for_mapping(
module,
field_mappings,
seed_tc if seed_tc else None,
effective_fields=eff_fields,
)
seed_meta = None seed_meta = None
if seed_row: if seed_row:
@ -270,12 +288,15 @@ def validate_system_template_dry_run(body: CsvTemplateValidateBody, session: dic
""" """
if not get_module_definition(body.module): if not get_module_definition(body.module):
raise HTTPException(400, f"Unbekanntes Modul: {body.module}") raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
with get_db() as conn:
cur = get_cursor(conn)
return validate_csv_template( return validate_csv_template(
body.module, body.module,
body.field_mappings, body.field_mappings,
body.type_conversions, body.type_conversions,
body.import_row_processing, body.import_row_processing,
body.column_signature, body.column_signature,
cur=cur,
) )
@ -297,18 +318,19 @@ def get_system_template(template_id: int, session: dict = Depends(require_admin)
def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)): def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)):
if not get_module_definition(body.module): if not get_module_definition(body.module):
raise HTTPException(400, f"Unbekanntes Modul: {body.module}") raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
with get_db() as conn:
cur = get_cursor(conn)
report = validate_csv_template( report = validate_csv_template(
body.module, body.module,
body.field_mappings, body.field_mappings,
body.type_conversions, body.type_conversions,
body.import_row_processing, body.import_row_processing,
body.column_signature, body.column_signature,
cur=cur,
) )
if not report["valid"]: if not report["valid"]:
raise HTTPException(status_code=422, detail=report) raise HTTPException(status_code=422, detail=report)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute( cur.execute(
""" """
INSERT INTO csv_field_mappings ( INSERT INTO csv_field_mappings (
@ -367,6 +389,7 @@ def update_system_template(
tc_eff, tc_eff,
irp_eff, irp_eff,
col_eff if isinstance(col_eff, list) else None, col_eff if isinstance(col_eff, list) else None,
cur=cur,
) )
if not report["valid"]: if not report["valid"]:
raise HTTPException(status_code=422, detail=report) raise HTTPException(status_code=422, detail=report)

View File

@ -0,0 +1,215 @@
"""
Admin: training_parameters catalog (EAV keys for activity session metrics).
Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
"""
import re
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2 import errors as pg_errors
from psycopg2.extras import Json
from auth import require_admin
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin/training-parameters", tags=["admin", "training-parameters"])
KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
PARAM_CATEGORY = {"physical", "physiological", "subjective", "environmental", "performance"}
DATA_TYPES = {"integer", "float", "string", "boolean"}
class TrainingParameterCreate(BaseModel):
key: str = Field(..., min_length=1, max_length=50)
name_de: str = Field(..., min_length=1, max_length=100)
name_en: str = Field(..., min_length=1, max_length=100)
category: str = Field(..., max_length=50)
data_type: str = Field(..., max_length=20)
unit: Optional[str] = Field(None, max_length=20)
description_de: Optional[str] = None
description_en: Optional[str] = None
source_field: Optional[str] = Field(None, max_length=100)
validation_rules: Optional[dict] = None
is_active: bool = True
class TrainingParameterUpdate(BaseModel):
name_de: Optional[str] = Field(None, min_length=1, max_length=100)
name_en: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, max_length=50)
data_type: Optional[str] = Field(None, max_length=20)
unit: Optional[str] = Field(None, max_length=20)
description_de: Optional[str] = None
description_en: Optional[str] = None
source_field: Optional[str] = Field(None, max_length=100)
validation_rules: Optional[dict] = None
is_active: Optional[bool] = None
def _norm_key(key: str) -> str:
k = key.strip().lower()
if not KEY_PATTERN.match(k):
raise HTTPException(
400,
"Ungültiger key: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.",
)
return k
def _validate_category(cat: str) -> str:
c = cat.strip()
if c not in PARAM_CATEGORY:
raise HTTPException(400, f"category muss einer von {sorted(PARAM_CATEGORY)} sein")
return c
def _validate_data_type(dt: str) -> str:
d = dt.strip().lower()
if d not in DATA_TYPES:
raise HTTPException(400, f"data_type muss einer von {sorted(DATA_TYPES)} sein")
return d
@router.get("")
def admin_list_training_parameters(
include_inactive: bool = Query(False),
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
if include_inactive:
cur.execute(
"""
SELECT * FROM training_parameters
ORDER BY category, key
"""
)
else:
cur.execute(
"""
SELECT * FROM training_parameters
WHERE is_active = true
ORDER BY category, key
"""
)
return [r2d(r) for r in cur.fetchall()]
@router.post("")
def admin_create_training_parameter(
body: TrainingParameterCreate,
session: dict = Depends(require_admin),
):
key = _norm_key(body.key)
cat = _validate_category(body.category)
dt = _validate_data_type(body.data_type)
rules = body.validation_rules if body.validation_rules is not None else {}
with get_db() as conn:
cur = get_cursor(conn)
try:
cur.execute(
"""
INSERT INTO training_parameters (
key, name_de, name_en, category, data_type, unit,
description_de, description_en, source_field, validation_rules, is_active
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
key,
body.name_de.strip(),
body.name_en.strip(),
cat,
dt,
body.unit.strip() if body.unit else None,
body.description_de,
body.description_en,
body.source_field.strip() if body.source_field else None,
Json(rules),
body.is_active,
),
)
new_id = cur.fetchone()["id"]
conn.commit()
except pg_errors.UniqueViolation:
conn.rollback()
raise HTTPException(409, "Parameter-key existiert bereits") from None
return {"id": new_id, "key": key}
@router.put("/{param_id}")
def admin_update_training_parameter(
param_id: int,
body: TrainingParameterUpdate,
session: dict = Depends(require_admin),
):
cols: list[str] = []
vals: list[Any] = []
if body.name_de is not None:
cols.append("name_de = %s")
vals.append(body.name_de.strip())
if body.name_en is not None:
cols.append("name_en = %s")
vals.append(body.name_en.strip())
if body.category is not None:
cols.append("category = %s")
vals.append(_validate_category(body.category))
if body.data_type is not None:
cols.append("data_type = %s")
vals.append(_validate_data_type(body.data_type))
if body.unit is not None:
cols.append("unit = %s")
vals.append(body.unit.strip() or None)
if body.description_de is not None:
cols.append("description_de = %s")
vals.append(body.description_de)
if body.description_en is not None:
cols.append("description_en = %s")
vals.append(body.description_en)
if body.source_field is not None:
cols.append("source_field = %s")
vals.append(body.source_field.strip() or None)
if body.validation_rules is not None:
cols.append("validation_rules = %s")
vals.append(Json(body.validation_rules))
if body.is_active is not None:
cols.append("is_active = %s")
vals.append(body.is_active)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(param_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_parameters SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Parameter nicht gefunden")
conn.commit()
return {"ok": True, "id": param_id}
@router.delete("/{param_id}")
def admin_deactivate_training_parameter(
param_id: int,
session: dict = Depends(require_admin),
):
"""Soft-delete: is_active = false (FK von session_metrics verhindert hartes Löschen)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"UPDATE training_parameters SET is_active = false WHERE id = %s RETURNING id",
(param_id,),
)
if not cur.fetchone():
raise HTTPException(404, "Parameter nicht gefunden")
conn.commit()
return {"ok": True, "id": param_id}

View File

@ -1,5 +1,5 @@
""" """
Geschützter App-Bereich: Dashboard-Lab Layout (kein Produktiv-Dashboard). Geschützter App-Bereich: Dashboard-Layout und Widget-Katalog.
/api/app/dashboard-layout nur mit Session + aktivem Profil (X-Profile-Id). /api/app/dashboard-layout nur mit Session + aktivem Profil (X-Profile-Id).
""" """
@ -13,13 +13,14 @@ from dashboard_layout_schema import (
DashboardLayoutPayload, DashboardLayoutPayload,
coalesce_effective_layout, coalesce_effective_layout,
lab_default_layout_dict, lab_default_layout_dict,
merge_missing_catalog_widgets,
) )
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
from db import get_cursor, get_db from db import get_cursor, get_db
from routers.profiles import get_pid from routers.profiles import get_pid
from system_dashboard_product_default import get_product_default_base_dict from system_dashboard_product_default import get_product_default_base_dict
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"]) router = APIRouter(prefix="/api/app", tags=["app-dashboard"])
@router.get("/widgets/catalog") @router.get("/widgets/catalog")
@ -51,9 +52,11 @@ def get_dashboard_layout(
raw = row["dashboard_layout"] if row else None raw = row["dashboard_layout"] if row else None
custom, effective = coalesce_effective_layout(raw) custom, effective = coalesce_effective_layout(raw)
with get_db() as conn: with get_db() as conn:
base_product = get_product_default_base_dict(conn) base_product = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
if not custom: if not custom:
effective = base_product effective = base_product
else:
effective = merge_missing_catalog_widgets(effective)
effective = apply_entitlements_to_layout_dict(effective, pid, conn) effective = apply_entitlements_to_layout_dict(effective, pid, conn)
product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn) product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn)
lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn) lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn)

File diff suppressed because it is too large Load Diff

View File

@ -67,10 +67,10 @@ def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(defaul
# INSERT new entry # INSERT new entry
eid = str(uuid.uuid4()) eid = str(uuid.uuid4())
cur.execute("""INSERT INTO circumference_log cur.execute("""INSERT INTO circumference_log
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created) (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,c_arm_relaxed,notes,photo_id,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'], (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id'])) d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d.get('c_arm_relaxed'),d['notes'],d['photo_id']))
# Phase 2: Increment usage counter (only for new entries) # Phase 2: Increment usage counter (only for new entries)
increment_feature_usage(pid, 'circumference_entries') increment_feature_usage(pid, 'circumference_entries')

View File

@ -29,11 +29,13 @@ from csv_parser.core import (
iter_csv_dict_rows, iter_csv_dict_rows,
normalize_header_for_signature, normalize_header_for_signature,
parse_csv_sample, parse_csv_sample,
resolve_effective_csv_delimiter,
) )
from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapping from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapping
from csv_parser.field_units import source_unit_choices_for_field from csv_parser.field_units import source_unit_choices_for_field
from csv_parser.import_errors import enrich_row_error from csv_parser.import_errors import enrich_row_error
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
router = APIRouter(prefix="/api/csv", tags=["csv-import"]) router = APIRouter(prefix="/api/csv", tags=["csv-import"])
@ -66,11 +68,16 @@ def _mapping_to_summary(m: dict) -> dict:
def csv_modules(session: dict = Depends(require_auth)): def csv_modules(session: dict = Depends(require_auth)):
"""Unterstützte Import-Module und Felddefinitionen.""" """Unterstützte Import-Module und Felddefinitionen."""
out = [] out = []
with get_db() as conn:
cur = get_cursor(conn)
for mid in list_modules(): for mid in list_modules():
d = get_module_definition(mid) d = get_module_definition(mid)
if d: if d:
field_src = dict(d.get("fields") or {})
if mid == "activity":
field_src = merge_activity_csv_module_fields(cur, field_src)
fields_out = {} fields_out = {}
for fname, finfo in (d.get("fields") or {}).items(): for fname, finfo in field_src.items():
fd = dict(finfo) fd = dict(finfo)
opts = source_unit_choices_for_field(mid, fname) opts = source_unit_choices_for_field(mid, fname)
if opts: if opts:
@ -257,6 +264,10 @@ async def analyze_csv(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if module == "activity" and mod_def:
available_fields = merge_activity_csv_module_fields(
cur, dict(mod_def.get("fields") or {})
)
if module: if module:
cur.execute( cur.execute(
""" """
@ -383,7 +394,8 @@ async def csv_import_diagnose(
tc = m.get("type_conversions") tc = m.get("type_conversions")
if not isinstance(tc, dict): if not isinstance(tc, dict):
tc = {} tc = {}
delim = str(m.get("delimiter") or ",") tpl_delim = str(m.get("delimiter") or ",").strip() or ","
delim = resolve_effective_csv_delimiter(text, tpl_delim)
exec_module = str(m["module"]) exec_module = str(m["module"])
rows_out: list[dict[str, Any]] = [] rows_out: list[dict[str, Any]] = []
@ -408,6 +420,7 @@ async def csv_import_diagnose(
"mapping_id": mapping_id, "mapping_id": mapping_id,
"mapping_name": m.get("mapping_name"), "mapping_name": m.get("mapping_name"),
"module": exec_module, "module": exec_module,
"delimiter_template": tpl_delim,
"delimiter_used": delim, "delimiter_used": delim,
"has_header": bool(m.get("has_header", True)), "has_header": bool(m.get("has_header", True)),
"rows_diagnosed": len(rows_out), "rows_diagnosed": len(rows_out),

View File

@ -22,6 +22,9 @@ from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage from feature_logger import log_feature_usage
from caliper_composition import enrich_caliper_row_for_response, load_weight_rows from caliper_composition import enrich_caliper_row_for_response, load_weight_rows
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
from data_layer.utils import serialize_dates
from routers.photos import resolve_photo_path
router = APIRouter(prefix="/api/export", tags=["export"]) router = APIRouter(prefix="/api/export", tags=["export"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -90,10 +93,23 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
for r in cur.fetchall(): for r in cur.fetchall():
writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"]) writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
# Activity # Activity (Layer-1: gemergte session_metrics in Details)
cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute(
for r in cur.fetchall(): "SELECT id, date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date",
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) (pid,),
)
act_rows = [r2d(r) for r in cur.fetchall()]
enrich_sessions_with_metrics(cur, act_rows)
for r in act_rows:
base = f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"
eav_parts = []
for m in r.get("session_metrics") or []:
k, v = m.get("key"), m.get("value")
if k is None or v is None:
continue
eav_parts.append(f"{k}={v}")
details = base + (" | " + "; ".join(eav_parts) if eav_parts else "")
writer.writerow(["Training", r["date"], r["activity_type"], details])
output.seek(0) output.seek(0)
@ -148,7 +164,9 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
data['nutrition'] = [r2d(r) for r in cur.fetchall()] data['nutrition'] = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
data['activity'] = [r2d(r) for r in cur.fetchall()] data["activity"] = [r2d(r) for r in cur.fetchall()]
enrich_sessions_with_metrics(cur, data["activity"])
data["activity"] = serialize_dates(data["activity"])
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
data['insights'] = [r2d(r) for r in cur.fetchall()] data['insights'] = [r2d(r) for r in cur.fetchall()]
@ -243,6 +261,9 @@ Dieser Export kann in Mitai Jinkendo unter
Einstellungen Import "Mitai Backup importieren" Einstellungen Import "Mitai Backup importieren"
wieder eingespielt werden. wieder eingespielt werden.
activity.csv (optional): Spalte session_metrics_json (JSON-Array, Layer-1-merge)
wird beim Standard-Import ignoriert; für Vollständigkeit/externe Tools.
Format-Version 2 (ab v9b): Format-Version 2 (ab v9b):
Alle CSV-Dateien sind UTF-8 mit BOM kodiert. Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
Trennzeichen: Semikolon (;) Trennzeichen: Semikolon (;)
@ -291,14 +312,38 @@ Datumsformat: YYYY-MM-DD
cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created']) write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created'])
cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute(
"SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_arm_relaxed, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date",
(pid,),
)
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
for r in rows: for r in rows:
r['waist'] = r.pop('c_waist', None); r['hip'] = r.pop('c_hip', None) r['waist'] = r.pop('c_waist', None); r['hip'] = r.pop('c_hip', None)
r['chest'] = r.pop('c_chest', None); r['neck'] = r.pop('c_neck', None) r['chest'] = r.pop('c_chest', None); r['neck'] = r.pop('c_neck', None)
r['upper_arm'] = r.pop('c_arm', None); r['thigh'] = r.pop('c_thigh', None) r['upper_arm_contracted'] = r.pop('c_arm', None)
r['upper_arm_relaxed'] = r.pop('c_arm_relaxed', None)
r['thigh'] = r.pop('c_thigh', None)
r['calf'] = r.pop('c_calf', None); r['forearm'] = None; r['note'] = r.pop('notes', None) r['calf'] = r.pop('c_calf', None); r['forearm'] = None; r['note'] = r.pop('notes', None)
write_csv(zf, "circumferences.csv", rows, ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created']) write_csv(
zf,
"circumferences.csv",
rows,
[
'id',
'date',
'waist',
'hip',
'chest',
'neck',
'upper_arm_contracted',
'upper_arm_relaxed',
'thigh',
'calf',
'forearm',
'note',
'created',
],
)
cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
@ -318,13 +363,41 @@ Datumsformat: YYYY-MM-DD
r['fiber'] = None; r['note'] = '' r['fiber'] = None; r['note'] = ''
write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created']) write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute(
"SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date",
(pid,),
)
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
enrich_sessions_with_metrics(cur, rows)
for r in rows: for r in rows:
r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None) sm = r.pop("session_metrics", None) or []
r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None) r["session_metrics_json"] = json.dumps(sm, ensure_ascii=False, default=str)
r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None) r["name"] = r["activity_type"]
write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created']) r["type"] = r.pop("activity_type", None)
r["kcal"] = r.pop("kcal_active", None)
r["heart_rate_avg"] = r.pop("hr_avg", None)
r["heart_rate_max"] = r.pop("hr_max", None)
r["note"] = r.pop("notes", None)
write_csv(
zf,
"activity.csv",
rows,
[
"id",
"date",
"name",
"type",
"duration_min",
"kcal",
"heart_rate_avg",
"heart_rate_max",
"distance_km",
"note",
"source",
"created",
"session_metrics_json",
],
)
# 8. insights/ai_insights.json # 8. insights/ai_insights.json
cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
@ -344,8 +417,8 @@ Datumsformat: YYYY-MM-DD
cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,)) cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
photos = [r2d(r) for r in cur.fetchall()] photos = [r2d(r) for r in cur.fetchall()]
for i, photo in enumerate(photos): for i, photo in enumerate(photos):
photo_path = Path(PHOTOS_DIR) / photo['path'] photo_path = resolve_photo_path(photo.get('path'))
if photo_path.exists(): if photo_path and photo_path.exists():
filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}" filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
zf.write(photo_path, f"photos/{filename}") zf.write(photo_path, f"photos/{filename}")

View File

@ -112,12 +112,17 @@ async def import_zip(
csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig') csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig')
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
for row in reader: for row in reader:
_ua_contr = (
row.get('upper_arm_contracted')
or row.get('upper_arm')
)
_ua_rel = row.get('upper_arm_relaxed')
cur.execute(""" cur.execute("""
INSERT INTO circumference_log ( INSERT INTO circumference_log (
profile_id, date, c_waist, c_hip, c_chest, c_neck, profile_id, date, c_waist, c_hip, c_chest, c_neck,
c_arm, c_thigh, c_calf, notes, created c_arm, c_arm_relaxed, c_thigh, c_calf, notes, created
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (profile_id, date) DO NOTHING ON CONFLICT (profile_id, date) DO NOTHING
""", ( """, (
pid, pid,
@ -126,7 +131,8 @@ async def import_zip(
float(row['hip']) if row.get('hip') else None, float(row['hip']) if row.get('hip') else None,
float(row['chest']) if row.get('chest') else None, float(row['chest']) if row.get('chest') else None,
float(row['neck']) if row.get('neck') else None, float(row['neck']) if row.get('neck') else None,
float(row['upper_arm']) if row.get('upper_arm') else None, float(_ua_contr) if _ua_contr not in (None, '') else None,
float(_ua_rel) if _ua_rel not in (None, '') else None,
float(row['thigh']) if row.get('thigh') else None, float(row['thigh']) if row.get('thigh') else None,
float(row['calf']) if row.get('calf') else None, float(row['calf']) if row.get('calf') else None,
row.get('note', ''), row.get('note', ''),

View File

@ -16,7 +16,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage from feature_logger import log_feature_usage
from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -179,38 +179,9 @@ def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=
@router.get("/correlations") @router.get("/correlations")
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition data correlated with weight and body fat.""" """Get nutrition data correlated with weight and body fat (Layer 1 Merge, siehe nutrition_body_merge)."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
with get_db() as conn: return build_merged_daily_nutrition_body_rows(pid)
cur = get_cursor(conn)
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,))
nutr={r['date']:r2d(r) for r in cur.fetchall()}
cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,))
wlog={r['date']:r['weight'] for r in cur.fetchall()}
cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,))
cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date'])
all_dates=sorted(set(list(nutr)+list(wlog)))
mi,last_cal,cal_by_date=0,{},{}
for d in all_dates:
while mi<len(cals) and cals[mi]['date']<=d: last_cal=cals[mi]; mi+=1
if last_cal: cal_by_date[d]=last_cal
result=[]
for d in all_dates:
if d not in nutr and d not in wlog: continue
row={'date':d}
if d in nutr: row.update({k:float(nutr[d][k]) if nutr[d][k] is not None else None for k in ['kcal','protein_g','fat_g','carbs_g']})
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
@router.get("/weekly") @router.get("/weekly")

View File

@ -6,6 +6,7 @@ Handles progress photo uploads and retrieval.
import os import os
import uuid import uuid
import logging import logging
from datetime import date as date_cls
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -14,6 +15,7 @@ from fastapi.responses import FileResponse
import aiofiles import aiofiles
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from photo_exif import extract_taken_at_from_image_bytes, taken_at_from_file_last_modified_ms
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage from feature_logger import log_feature_usage
@ -25,10 +27,41 @@ PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
PHOTOS_DIR.mkdir(parents=True, exist_ok=True) PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
def resolve_photo_path(stored: Optional[str]) -> Optional[Path]:
"""
Map DB `path` to an existing file. Supports legacy rows (absolute path or nested)
and new rows (filename only under PHOTOS_DIR).
"""
if not stored or not str(stored).strip():
return None
raw = str(stored).strip()
p = Path(raw)
if p.is_absolute() and p.exists():
return p
cand = PHOTOS_DIR / raw
if cand.exists():
return cand
cand2 = PHOTOS_DIR / Path(raw).name
if cand2.exists():
return cand2
return None
@router.post("") @router.post("")
async def upload_photo(file: UploadFile=File(...), date: str=Form(""), async def upload_photo(
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): file: UploadFile = File(...),
"""Upload progress photo.""" date: str = Form(""),
skip_exif: str = Form(""),
file_last_modified: str = Form(""),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""
Upload progress photo.
Reihenfolge (wenn nicht ``skip_exif``): EXIF Datei-Zeitstempel (Browser ``File.lastModified``)
Formularfeld ``date`` heute. Bei ``skip_exif``: nur Formular / heute (weder EXIF noch Datei-Zeit).
"""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE # Phase 4: Check feature access and ENFORCE
@ -46,35 +79,69 @@ async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
) )
fid = str(uuid.uuid4()) raw = await file.read()
ext = Path(file.filename).suffix or '.jpg' ignore_exif = skip_exif.strip().lower() in ("1", "true", "yes", "on")
path = PHOTOS_DIR / f"{fid}{ext}" taken_at = None
async with aiofiles.open(path,'wb') as f: await f.write(await file.read()) exif_used = False
file_mtime_used = False
# Convert empty string to NULL for date field if not ignore_exif:
photo_date = date if date and date.strip() else None taken_at = extract_taken_at_from_image_bytes(raw)
exif_used = taken_at is not None
if not taken_at:
taken_at = taken_at_from_file_last_modified_ms(file_last_modified)
file_mtime_used = taken_at is not None
if taken_at:
photo_date = taken_at.date()
elif date and date.strip():
photo_date = date_cls.fromisoformat(date.strip()[:10])
else:
photo_date = date_cls.today()
fid = str(uuid.uuid4())
ext = Path(file.filename).suffix or ".jpg"
fname = f"{fid}{ext}"
path = PHOTOS_DIR / fname
async with aiofiles.open(path, "wb") as f:
await f.write(raw)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", cur.execute(
(fid,pid,photo_date,str(path))) """
INSERT INTO photos (id, profile_id, date, path, taken_at, created)
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
""",
(fid, pid, photo_date, fname, taken_at),
)
# Phase 2: Increment usage counter # Phase 2: Increment usage counter
increment_feature_usage(pid, 'photos') increment_feature_usage(pid, "photos")
return {"id":fid,"date":photo_date} return {
"id": fid,
"date": photo_date.isoformat(),
"taken_at": taken_at.isoformat() if taken_at else None,
"exif_used": exif_used,
"file_mtime_used": file_mtime_used,
}
@router.get("/{fid}") @router.get("/{fid}")
def get_photo(fid: str, session: dict=Depends(require_auth_flexible)): def get_photo(fid: str, session: dict=Depends(require_auth_flexible)):
"""Get photo by ID. Auth via header or query param (for <img> tags).""" """Get photo by ID. Auth via header or query param ssetoken (for <img> tags)."""
profile_id = str(session["profile_id"])
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT path FROM photos WHERE id=%s", (fid,)) cur.execute("SELECT path, profile_id FROM photos WHERE id=%s", (fid,))
row = cur.fetchone() row = cur.fetchone()
if not row: raise HTTPException(404, "Photo not found") if not row:
photo_path = Path(PHOTOS_DIR) / row['path'] raise HTTPException(404, "Photo not found")
if not photo_path.exists(): if str(row["profile_id"]) != profile_id:
raise HTTPException(404, "Photo not found")
photo_path = resolve_photo_path(row["path"])
if not photo_path:
raise HTTPException(404, "Photo file not found") raise HTTPException(404, "Photo file not found")
return FileResponse(photo_path) return FileResponse(photo_path)
@ -86,5 +153,38 @@ def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
"SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,)) """
SELECT * FROM photos WHERE profile_id=%s
ORDER BY COALESCE(taken_at, date::timestamptz, created) DESC NULLS LAST
LIMIT 500
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()] return [r2d(r) for r in cur.fetchall()]
@router.delete("/{fid}")
def delete_photo(
fid: str,
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""Delete a photo (DB row + Datei auf der Platte)."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT path FROM photos WHERE id=%s AND profile_id=%s",
(fid, pid),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Photo not found")
photo_path = resolve_photo_path(row["path"])
cur.execute("DELETE FROM photos WHERE id=%s AND profile_id=%s", (fid, pid))
if photo_path and photo_path.exists():
try:
photo_path.unlink()
except OSError as e:
logger.warning("Could not delete photo file %s: %s", photo_path, e)
return {"ok": True, "id": fid}

View File

@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Header
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin from auth import require_auth, require_admin, require_auth_flexible
from models import ( from models import (
PromptCreate, PromptUpdate, PromptGenerateRequest, PromptCreate, PromptUpdate, PromptGenerateRequest,
PipelineConfigCreate, PipelineConfigUpdate PipelineConfigCreate, PipelineConfigUpdate
@ -254,6 +254,178 @@ def import_prompts(
} }
# ══════════════════════════════════════════════════════════════════════════════
# UNIFIED PROMPT SYSTEM (Issue #28 Phase 2)
# ══════════════════════════════════════════════════════════════════════════════
from prompt_executor import execute_prompt_with_data
from models import UnifiedPromptCreate, UnifiedPromptUpdate
@router.get("/execute-stream")
async def execute_unified_prompt_stream(
prompt_slug: str = Query(..., description="Slug of prompt to execute"),
debug: bool = Query(False, description="Include debug information (node_states, etc.)"),
save: bool = Query(False, description="Save result to ai_insights"),
session: dict = Depends(require_auth_flexible)
):
"""
Execute a unified prompt with Server-Sent Events (SSE) streaming.
Returns live progress updates during workflow execution:
- execution_started: Workflow has begun
- node_complete: Each node completes
- workflow_graph_finished: Workflow-Graph fertig (Zwischen-Info, kein Endergebnis)
- execution_complete: Endergebnis (wie POST /execute, Feld result)
- execution_failed: Error occurred
Use this endpoint for long-running workflows (>30s) to avoid gateway timeouts.
"""
profile_id = session['profile_id']
# Use default modules/timeframes (SSE doesn't support complex params)
modules = {
'körper': True,
'ernährung': True,
'training': True,
'schlaf': True,
'vitalwerte': True
}
timeframes = {
'körper': 30,
'ernährung': 30,
'training': 14,
'schlaf': 14,
'vitalwerte': 7
}
# Wrapper function for OpenRouter calls
async def workflow_llm_call(prompt: str, model: str = None) -> str:
return await call_openrouter(prompt)
# SSE Event Generator
async def event_stream():
"""Generate Server-Sent Events during workflow execution."""
import asyncio
from asyncio import Queue
# Event queue for progress updates
event_queue = Queue()
# Flag to track execution completion
execution_complete = False
# Define progress callback for streaming updates
async def progress_callback(event_type: str, data: dict):
"""Queue SSE event for streaming to client."""
event_data = {
"type": event_type,
**data
}
await event_queue.put(event_data)
# Start workflow execution in background task
async def execute_workflow_async():
nonlocal execution_complete
try:
# Execute workflow with progress callbacks
result = await execute_prompt_with_data(
prompt_slug=prompt_slug,
profile_id=profile_id,
modules=modules,
timeframes=timeframes,
openrouter_call_func=workflow_llm_call,
enable_debug=debug or save,
progress_callback=progress_callback
)
# Save to ai_insights if requested (same logic as /execute)
if save:
if result['type'] == 'pipeline':
final_output = result.get('output', {})
if isinstance(final_output, dict) and len(final_output) == 1:
content = list(final_output.values())[0]
else:
content = json.dumps(final_output, ensure_ascii=False)
elif result['type'] == 'workflow':
content = _workflow_user_facing_content(result.get('aggregated_result'))
else:
content = result.get('output', '')
if isinstance(content, dict):
content = json.dumps(content, ensure_ascii=False)
# Save to database (minimal metadata for now)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""INSERT INTO ai_insights (profile_id, scope, content, metadata, created)
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
(profile_id, prompt_slug, content, json.dumps({"prompt_type": result['type']}))
)
conn.commit()
# Pflicht für alle Prompt-Typen: Pipeline/Base rufen keinen progress_callback
# mit Abschluss auf — ohne dieses Event endet SSE ohne resolve → „Connection to server lost“.
try:
sse_payload = json.loads(json.dumps(result, default=str))
except (TypeError, ValueError):
sse_payload = {
"type": result.get("type", "unknown"),
"error": "result_not_serializable",
}
await event_queue.put({
"type": "execution_complete",
"result": sse_payload,
})
except Exception as e:
# Queue error event
await event_queue.put({
"type": "execution_failed",
"error": str(e)
})
finally:
execution_complete = True
# Start workflow execution in background
import asyncio
execution_task = asyncio.create_task(execute_workflow_async())
# Stream events from queue
try:
while not execution_complete or not event_queue.empty():
try:
# Wait for event with timeout
event = await asyncio.wait_for(event_queue.get(), timeout=0.5)
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except asyncio.TimeoutError:
# Send keepalive ping
yield f": keepalive\n\n"
continue
# Wait for execution task to complete
await execution_task
except Exception as e:
# Send final error event
error_event = {
"type": "execution_failed",
"error": str(e)
}
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Disable nginx buffering
}
)
@router.get("/{prompt_id}") @router.get("/{prompt_id}")
def get_prompt(prompt_id: str, session: dict=Depends(require_auth)): def get_prompt(prompt_id: str, session: dict=Depends(require_auth)):
"""Get single AI prompt by ID (UUID).""" """Get single AI prompt by ID (UUID)."""
@ -1437,177 +1609,6 @@ def reset_prompt_to_default(prompt_id: str, session: dict=Depends(require_admin)
return {"ok": True} return {"ok": True}
# ══════════════════════════════════════════════════════════════════════════════
# UNIFIED PROMPT SYSTEM (Issue #28 Phase 2)
# ══════════════════════════════════════════════════════════════════════════════
from prompt_executor import execute_prompt_with_data
from models import UnifiedPromptCreate, UnifiedPromptUpdate
@router.post("/execute-stream")
async def execute_unified_prompt_stream(
prompt_slug: str = Query(..., description="Slug of prompt to execute"),
token: Optional[str] = Query(None, description="Auth token (temporary solution for SSE)"),
modules: Optional[dict] = None,
timeframes: Optional[dict] = None,
debug: bool = Query(False, description="Include debug information (node_states, etc.)"),
save: bool = Query(False, description="Save result to ai_insights")
):
"""
Execute a unified prompt with Server-Sent Events (SSE) streaming.
Returns live progress updates during workflow execution:
- execution_started: Workflow has begun
- node_complete: Each node completes
- execution_complete: Final result ready
- execution_failed: Error occurred
Use this endpoint for long-running workflows (>30s) to avoid gateway timeouts.
"""
# Manual auth: verify token and get profile_id
if not token:
raise HTTPException(401, "Missing auth token")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT profile_id FROM sessions WHERE token = %s", (token,))
row = cur.fetchone()
if not row:
raise HTTPException(401, "Invalid or expired token")
profile_id = row['profile_id']
# Use default modules/timeframes if not provided
if not modules:
modules = {
'körper': True,
'ernährung': True,
'training': True,
'schlaf': True,
'vitalwerte': True
}
if not timeframes:
timeframes = {
'körper': 30,
'ernährung': 30,
'training': 14,
'schlaf': 14,
'vitalwerte': 7
}
# Wrapper function for OpenRouter calls
async def workflow_llm_call(prompt: str, model: str = None) -> str:
return await call_openrouter(prompt)
# SSE Event Generator
async def event_stream():
"""Generate Server-Sent Events during workflow execution."""
import asyncio
from asyncio import Queue
# Event queue for progress updates
event_queue = Queue()
# Flag to track execution completion
execution_complete = False
# Define progress callback for streaming updates
async def progress_callback(event_type: str, data: dict):
"""Queue SSE event for streaming to client."""
event_data = {
"type": event_type,
**data
}
await event_queue.put(event_data)
# Start workflow execution in background task
async def execute_workflow_async():
nonlocal execution_complete
try:
# Execute workflow with progress callbacks
result = await execute_prompt_with_data(
prompt_slug=prompt_slug,
profile_id=profile_id,
modules=modules,
timeframes=timeframes,
openrouter_call_func=workflow_llm_call,
enable_debug=debug or save,
progress_callback=progress_callback
)
# Save to ai_insights if requested (same logic as /execute)
if save:
if result['type'] == 'pipeline':
final_output = result.get('output', {})
if isinstance(final_output, dict) and len(final_output) == 1:
content = list(final_output.values())[0]
else:
content = json.dumps(final_output, ensure_ascii=False)
elif result['type'] == 'workflow':
content = _workflow_user_facing_content(result.get('aggregated_result'))
else:
content = result.get('output', '')
if isinstance(content, dict):
content = json.dumps(content, ensure_ascii=False)
# Save to database (minimal metadata for now)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""INSERT INTO ai_insights (profile_id, scope, content, metadata, created)
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
(profile_id, prompt_slug, content, json.dumps({"prompt_type": result['type']}))
)
conn.commit()
except Exception as e:
# Queue error event
await event_queue.put({
"type": "execution_failed",
"error": str(e)
})
finally:
execution_complete = True
# Start workflow execution in background
import asyncio
execution_task = asyncio.create_task(execute_workflow_async())
# Stream events from queue
try:
while not execution_complete or not event_queue.empty():
try:
# Wait for event with timeout
event = await asyncio.wait_for(event_queue.get(), timeout=0.5)
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except asyncio.TimeoutError:
# Send keepalive ping
yield f": keepalive\n\n"
continue
# Wait for execution task to complete
await execution_task
except Exception as e:
# Send final error event
error_event = {
"type": "execution_failed",
"error": str(e)
}
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Disable nginx buffering
}
)
@router.post("/execute") @router.post("/execute")
async def execute_unified_prompt( async def execute_unified_prompt(
prompt_slug: str = Query(..., description="Slug of prompt to execute"), prompt_slug: str = Query(..., description="Slug of prompt to execute"),

View File

@ -19,6 +19,8 @@ from psycopg2.extras import Json
from auth import require_auth from auth import require_auth
from data_layer.reference_values import ( from data_layer.reference_values import (
fetch_reference_type_by_key, fetch_reference_type_by_key,
get_profile_reference_values_current_snapshot,
get_profile_reference_values_recent_snapshot,
get_profile_reference_values_summary, get_profile_reference_values_summary,
list_active_reference_value_types_data, list_active_reference_value_types_data,
list_profile_reference_values_for_type, list_profile_reference_values_for_type,
@ -93,6 +95,48 @@ def profile_reference_values_summary(
return get_profile_reference_values_summary(pid) return get_profile_reference_values_summary(pid)
@router.get("/profile-reference-values/snapshot-current")
def profile_reference_values_snapshot_current(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""
Layer 1: Alle aktuellen Referenzwerte (jüngster Eintrag pro Typ), wie Platzhalter
``{{reference_values_current_json}}``.
"""
pid = get_pid(x_profile_id)
return get_profile_reference_values_current_snapshot(pid)
@router.get("/profile-reference-values/snapshot-recent")
def profile_reference_values_snapshot_recent(
limit_per_type: int = Query(5, ge=1, le=50, description="Einträge pro Referenztyp (neueste zuerst)"),
date_from: Optional[str] = Query(None, description="Filter effective_date >= (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Filter effective_date <= (YYYY-MM-DD)"),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""
Layer 1: Bis zu N Einträge pro Typ (Verlauf), wie Platzhalter ``{{reference_values_recent_json}}``.
"""
pid = get_pid(x_profile_id)
df = date_from
dto = date_to
for label, raw in (("date_from", df), ("date_to", dto)):
if raw is None or not str(raw).strip():
continue
try:
datetime.strptime(str(raw).strip(), "%Y-%m-%d")
except ValueError:
raise HTTPException(400, f"{label}: Ungültiges Datum, Format YYYY-MM-DD")
return get_profile_reference_values_recent_snapshot(
pid,
limit_per_type=limit_per_type,
date_from=df,
date_to=dto,
)
@router.get("/profile-reference-values") @router.get("/profile-reference-values")
def list_profile_reference_values( def list_profile_reference_values(
type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"), type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"),

324
backend/routers/reports.py Normal file
View File

@ -0,0 +1,324 @@
"""
Strukturierter PDF-Bericht: mehrere Definitionen pro Profil, Katalog, PDF-Erzeugung.
PDF-Zähler: data_export (wie andere Exporte).
"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from auth import check_feature_access, increment_feature_usage, require_auth
from db import get_cursor, get_db
from feature_logger import log_feature_usage
from report_chart_fetch import CHART_CATALOG_FOR_API
from report_pdf_render import build_structured_report_pdf
from report_profile_schema import (
ALLOWED_VIZ_BUNDLE_IDS,
ReportProfilePayload,
default_report_profile_dict,
parse_report_profile,
)
router = APIRouter(prefix="/api/reports", tags=["reports"])
logger = logging.getLogger(__name__)
_MAX_REPORT_DEFINITIONS = 20
class CreateReportDefinitionBody(BaseModel):
name: str = Field(default="Neuer Bericht", min_length=1, max_length=120)
class UpdateReportDefinitionBody(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
payload: dict | None = None
class GeneratePdfRequest(BaseModel):
definition_id: UUID | None = None
def _profile_display_name(profile_id: str) -> str:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT name FROM profiles WHERE id = %s", (profile_id,))
row = cur.fetchone()
if not row:
return "Profil"
return (row.get("name") or "Profil").strip() or "Profil"
def _row_to_definition(row: dict) -> dict:
pl = row.get("payload")
if not isinstance(pl, dict):
pl = {}
return {
"id": str(row["id"]),
"name": (row.get("name") or "Bericht").strip() or "Bericht",
"sort_order": int(row.get("sort_order") or 0),
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
"payload": pl,
}
@router.get("/catalog")
def get_reports_catalog(session: dict = Depends(require_auth)):
"""Metadaten für UI: verfügbare Diagramme, Bundles, Zeitraumgrenzen."""
viz_titles = {
"body_history_viz": "Körper (Verlauf-Bundle)",
"nutrition_history_viz": "Ernährung (Verlauf-Bundle)",
"fitness_history_viz": "Fitness (Verlauf-Bundle)",
"recovery_history_viz": "Erholung (Verlauf-Bundle)",
"history_overview_viz": "Gesamtübersicht (Korrelationen)",
}
return {
"catalog_version": 3,
"chart_days": {"min": 7, "max": 90},
"charts": CHART_CATALOG_FOR_API,
"viz_bundles": [{"id": bid, "title": viz_titles.get(bid, bid)} for bid in sorted(ALLOWED_VIZ_BUNDLE_IDS)],
"block_types": [
{"id": "section", "title": "Überschrift"},
{"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"},
{"id": "chart", "title": "Einzel-Diagramm (Legacy)"},
{"id": "ai_insight", "title": "KI-Auswertung"},
],
}
@router.get("/definitions")
def list_report_definitions(session: dict = Depends(require_auth)):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name, sort_order, updated_at, payload
FROM report_definitions
WHERE profile_id = %s
ORDER BY sort_order ASC, name ASC, updated_at DESC
""",
(pid,),
)
rows = cur.fetchall()
return {"definitions": [_row_to_definition(dict(r)) for r in rows]}
@router.post("/definitions")
def create_report_definition(
body: CreateReportDefinitionBody | None = Body(default=None),
session: dict = Depends(require_auth),
):
req = body or CreateReportDefinitionBody()
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COUNT(*) AS n FROM report_definitions WHERE profile_id = %s",
(pid,),
)
n = int((cur.fetchone() or {}).get("n") or 0)
if n >= _MAX_REPORT_DEFINITIONS:
raise HTTPException(
status_code=400,
detail=f"Maximal {_MAX_REPORT_DEFINITIONS} Berichte erlaubt.",
)
payload_dict = default_report_profile_dict()
payload_dict["document_title"] = ""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_so FROM report_definitions WHERE profile_id = %s",
(pid,),
)
next_so = int((cur.fetchone() or {}).get("next_so") or 0)
cur.execute(
"""
INSERT INTO report_definitions (profile_id, name, payload, sort_order, updated_at)
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id, name, sort_order, updated_at, payload
""",
(pid, req.name.strip(), Json(payload_dict), next_so),
)
row = cur.fetchone()
conn.commit()
if not row:
raise HTTPException(status_code=500, detail="Bericht konnte nicht angelegt werden.")
return {"definition": _row_to_definition(dict(row))}
@router.put("/definitions/{definition_id}")
def update_report_definition(
definition_id: UUID,
body: UpdateReportDefinitionBody,
session: dict = Depends(require_auth),
):
pid = session["profile_id"]
name = body.name.strip() if body.name else None
parsed_payload: ReportProfilePayload | None = None
if body.payload is not None:
try:
parsed_payload = ReportProfilePayload.model_validate(body.payload)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
with get_db() as conn:
cur = get_cursor(conn)
if parsed_payload is not None and name is not None:
cur.execute(
"""
UPDATE report_definitions
SET name = %s, payload = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND profile_id = %s
RETURNING id, name, sort_order, updated_at, payload
""",
(name, Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
)
elif parsed_payload is not None:
cur.execute(
"""
UPDATE report_definitions
SET payload = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND profile_id = %s
RETURNING id, name, sort_order, updated_at, payload
""",
(Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
)
elif name is not None:
cur.execute(
"""
UPDATE report_definitions
SET name = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND profile_id = %s
RETURNING id, name, sort_order, updated_at, payload
""",
(name, str(definition_id), pid),
)
else:
raise HTTPException(status_code=400, detail="Nichts zu aktualisieren (name oder payload fehlt).")
row = cur.fetchone()
conn.commit()
if not row:
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
return {"definition": _row_to_definition(dict(row))}
@router.delete("/definitions/{definition_id}")
def delete_report_definition(definition_id: UUID, session: dict = Depends(require_auth)):
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM report_definitions WHERE id = %s AND profile_id = %s RETURNING id",
(str(definition_id), pid),
)
deleted = cur.fetchone()
conn.commit()
if not deleted:
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
return {"ok": True}
def _fetch_definition_payload(profile_id: str, definition_id: UUID | None) -> tuple[dict, str]:
"""Returns (raw_payload_dict, report_label_for_filename)."""
with get_db() as conn:
cur = get_cursor(conn)
if definition_id is not None:
cur.execute(
"""
SELECT payload, name FROM report_definitions
WHERE id = %s AND profile_id = %s
""",
(str(definition_id), profile_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
pl = row.get("payload")
label = (row.get("name") or "Bericht").strip() or "Bericht"
if not isinstance(pl, dict):
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
return pl, label
cur.execute(
"""
SELECT payload, name FROM report_definitions
WHERE profile_id = %s
ORDER BY sort_order ASC, name ASC, updated_at DESC
LIMIT 1
""",
(profile_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(
status_code=400,
detail="Kein Bericht angelegt — bitte unter Einstellungen PDF-Berichte einen Bericht erstellen.",
)
pl = row.get("payload")
label = (row.get("name") or "Bericht").strip() or "Bericht"
if not isinstance(pl, dict):
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
return pl, label
@router.post("/generate-pdf")
def generate_structured_report_pdf(
body: GeneratePdfRequest | None = Body(default=None),
session: dict = Depends(require_auth),
):
pid = session["profile_id"]
req = body or GeneratePdfRequest()
access = check_feature_access(pid, "data_export")
log_feature_usage(pid, "data_export", access, "report_generate_pdf")
if not access["allowed"]:
logger.warning(
"[FEATURE-LIMIT] report pdf blocked: %s used=%s limit=%s",
pid,
access.get("used"),
access.get("limit"),
)
raise HTTPException(
status_code=403,
detail=(
"Limit erreicht: Daten-Export nicht möglich "
f"({access.get('used')}/{access.get('limit')})."
),
)
raw, report_label = _fetch_definition_payload(pid, req.definition_id)
try:
payload = parse_report_profile(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
profile_name = _profile_display_name(pid)
try:
pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=profile_name, payload=payload)
except Exception as e:
logger.exception("report pdf build failed")
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
increment_feature_usage(pid, "data_export")
doc_title = (payload.document_title or "").strip()
base_label = doc_title or report_label
safe_name = "".join(c for c in base_label if c.isalnum() or c in (" ", "-", "_")).strip() or "bericht"
fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
)

View File

@ -104,6 +104,7 @@ CREATE TABLE IF NOT EXISTS circumference_log (
c_thigh NUMERIC(5,2), c_thigh NUMERIC(5,2),
c_calf NUMERIC(5,2), c_calf NUMERIC(5,2),
c_arm NUMERIC(5,2), c_arm NUMERIC(5,2),
c_arm_relaxed NUMERIC(5,2),
notes TEXT, notes TEXT,
photo_id UUID, photo_id UUID,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
@ -180,6 +181,7 @@ CREATE TABLE IF NOT EXISTS photos (
meas_id UUID, -- Legacy: reference to measurement (circumference/caliper) meas_id UUID, -- Legacy: reference to measurement (circumference/caliper)
date DATE, date DATE,
path TEXT NOT NULL, path TEXT NOT NULL,
taken_at TIMESTAMPTZ, -- Aufnahmezeit aus EXIF (optional)
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
); );

View File

@ -0,0 +1,179 @@
"""
Diagnose: Was liegt in activity_session_metrics (EAV) vs. activity_log?
Ausführung (mit gesetzten DB_*-Variablen wie die App, z. B. aus .env):
cd backend
python scripts/inspect_activity_eav.py
Lokal ohne Docker-Hostname: z. B. ``set DB_HOST=127.0.0.1`` (Windows) / ``export DB_HOST=127.0.0.1``,
Port/User/Pass wie in der laufenden Postgres-Instanz.
Im Backend-Container (Compose-Service meist ``backend``, Arbeitsverzeichnis ``/app``):
docker compose exec backend python /app/scripts/inspect_activity_eav.py
Optional:
python scripts/inspect_activity_eav.py --limit 30
python scripts/inspect_activity_eav.py --profile <uuid>
python scripts/inspect_activity_eav.py --activity <activity_log uuid>
Keine Schreibzugriffe nur SELECT.
"""
from __future__ import annotations
import argparse
import os
import sys
# backend/ als Import-Root
_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _BACKEND_ROOT not in sys.path:
sys.path.insert(0, _BACKEND_ROOT)
def _val_row(r: dict) -> str | None:
dt = r.get("data_type")
if dt == "integer":
v = r.get("value_int")
return str(v) if v is not None else None
if dt == "float":
v = r.get("value_num")
return str(v) if v is not None else None
if dt == "string":
v = r.get("value_text")
return repr(v) if v is not None else None
if dt == "boolean":
v = r.get("value_bool")
return str(v) if v is not None else None
return None
def main() -> None:
parser = argparse.ArgumentParser(description="EAV activity_session_metrics inspizieren")
parser.add_argument("--limit", type=int, default=40, help="Zeilen Report A/B")
parser.add_argument("--profile", type=str, default=None, help="profile_id filtern")
parser.add_argument("--activity", type=str, default=None, help="activity_log.id (einzelne Session)")
args = parser.parse_args()
from db import get_db, get_cursor
if args.activity:
with get_db() as conn:
with get_cursor(conn) as cur:
cur.execute(
"""
SELECT al.id, al.profile_id, al.date, al.start_time, al.source,
al.training_category, al.training_type_id, al.activity_type,
al.duration_min, al.kcal_active, al.hr_avg, al.hr_max, al.distance_km
FROM activity_log al
WHERE al.id = %s::uuid
""",
(args.activity,),
)
h = cur.fetchone()
if not h:
print("activity_log: keine Zeile für diese id")
return
print("=== activity_log (Kopfzeile) ===")
for k, v in dict(h).items():
print(f" {k}: {v}")
cur.execute(
"""
SELECT m.id AS metric_id, tp.key, tp.data_type, tp.source_field,
m.value_num, m.value_int, m.value_text, m.value_bool, m.updated_at
FROM activity_session_metrics m
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE m.activity_log_id = %s::uuid
ORDER BY tp.key
""",
(args.activity,),
)
rows = cur.fetchall()
print(f"\n=== activity_session_metrics ({len(rows)} Zeilen) ===")
for r in rows:
d = dict(r)
print(
f" {d['key']} ({d['data_type']}) "
f"value={_val_row(d)!r} source_field={d.get('source_field')!r} "
f"updated_at={d.get('updated_at')}"
)
if not rows:
print(" (keine EAV-Zeilen)")
return
prof_filter = ""
if args.profile:
prof_filter = " AND al.profile_id = %s::uuid "
params_a: tuple = (args.profile, args.limit) if args.profile else (args.limit,)
params_b: tuple = (args.profile, args.limit) if args.profile else (args.limit,)
q_recent_eav = f"""
SELECT
al.id AS activity_id,
al.profile_id,
al.date,
al.start_time,
al.source,
al.training_type_id,
al.training_category,
tp.key AS parameter_key,
tp.data_type,
tp.source_field AS tp_source_field,
m.value_num,
m.value_int,
m.value_text,
m.value_bool,
m.updated_at
FROM activity_session_metrics m
JOIN activity_log al ON al.id = m.activity_log_id
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE 1=1 {prof_filter}
ORDER BY m.updated_at DESC NULLS LAST, al.date DESC, al.start_time DESC
LIMIT %s
"""
q_csv_no_eav = f"""
SELECT
al.id AS activity_id,
al.profile_id,
al.date,
al.start_time,
al.source,
al.training_type_id,
al.training_category,
(SELECT COUNT(*) FROM activity_session_metrics m WHERE m.activity_log_id = al.id) AS eav_count
FROM activity_log al
WHERE al.source = 'csv' {prof_filter}
ORDER BY al.date DESC, al.start_time DESC
LIMIT %s
"""
with get_db() as conn:
with get_cursor(conn) as cur:
print("=== A) Neueste EAV-Zeilen (join activity_log + training_parameters) ===\n")
cur.execute(q_recent_eav, params_a)
for r in cur.fetchall():
d = dict(r)
v = _val_row(d)
print(
f"{d['date']} {d['start_time']} | {d['activity_id']} | src={d['source']!r} | "
f"type={d['training_type_id']} cat={d['training_category']!r} | "
f"{d['parameter_key']}={v!r} (tp.source_field={d.get('tp_source_field')!r})"
)
print("\n=== B) Neueste CSV-importierte Sessions: EAV-Anzahl pro Zeile ===\n")
cur.execute(q_csv_no_eav, params_b)
for r in cur.fetchall():
d = dict(r)
print(
f"{d['date']} {d['start_time']} | {d['activity_id']} | "
f"type={d['training_type_id']} | eav_count={d['eav_count']}"
)
print("\nFertig. Für eine Session im Detail: --activity <uuid>")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,582 @@
"""Unit tests for data_layer.activity_session_metrics (no DB for most cases)."""
import uuid
from unittest.mock import patch
import pytest
from data_layer.activity_session_metrics import (
_normalize_metric_value_for_read,
ActivitySessionMetricsError,
enrich_sessions_with_metrics,
merge_column_backed_and_eav_metrics,
merge_parameter_schema_rows,
resolve_activity_attribute_schema,
upsert_session_metrics_from_csv_mapped,
_row_value_tuple,
_validate_single_value,
)
def _tp_row(
pid: int,
key: str,
*,
data_type: str = "integer",
cat_sort: int = 0,
cat_required: bool = False,
name_de: str = "X",
name_en: str = "X",
param_category: str = "physical",
unit: str | None = None,
validation_rules: dict | None = None,
source_field: str | None = None,
):
return {
"training_parameter_id": pid,
"cat_sort": cat_sort,
"cat_required": cat_required,
"cat_ui_group": None,
"key": key,
"name_de": name_de,
"name_en": name_en,
"param_category": param_category,
"data_type": data_type,
"unit": unit,
"validation_rules": validation_rules or {},
"source_field": source_field,
}
def _ttp_row(
pid: int,
key: str,
*,
typ_sort: int | None = None,
typ_required: bool | None = None,
typ_ui_group: str | None = None,
data_type: str = "integer",
name_de: str = "X",
name_en: str = "X",
param_category: str = "physical",
unit: str | None = None,
validation_rules: dict | None = None,
source_field: str | None = None,
):
return {
"training_parameter_id": pid,
"typ_sort": typ_sort,
"typ_required": typ_required,
"typ_ui_group": typ_ui_group,
"key": key,
"name_de": name_de,
"name_en": name_en,
"param_category": param_category,
"data_type": data_type,
"unit": unit,
"validation_rules": validation_rules or {},
"source_field": source_field,
}
def test_merge_category_only_sorted_by_sort_order_then_key():
cat = [
_tp_row(2, "zebra", cat_sort=10),
_tp_row(1, "alpha", cat_sort=5),
]
merged = merge_parameter_schema_rows(cat, [])
assert [m["key"] for m in merged] == ["alpha", "zebra"]
assert merged[0]["required"] is False
def test_merge_type_overrides_required_and_sort():
cat = [_tp_row(1, "rpe", cat_sort=0, cat_required=False)]
typ = [_ttp_row(1, "rpe", typ_sort=99, typ_required=True)]
merged = merge_parameter_schema_rows(cat, typ)
assert len(merged) == 1
assert merged[0]["sort_order"] == 99
assert merged[0]["required"] is True
def test_merge_parameter_schema_includes_descriptions():
cat = [
{
"training_parameter_id": 1,
"cat_sort": 0,
"cat_required": False,
"cat_ui_group": None,
"key": "custom_w",
"name_de": "Leistung",
"name_en": "Watts",
"description_de": "Mittlere 5-Min-Leistung",
"description_en": "5 min average power",
"param_category": "performance",
"data_type": "integer",
"unit": "W",
"validation_rules": {},
"source_field": None,
}
]
merged = merge_parameter_schema_rows(cat, [])
assert merged[0]["description_de"] == "Mittlere 5-Min-Leistung"
assert merged[0]["description_en"] == "5 min average power"
def test_merge_eav_float_value_normalized_no_long_tail():
"""Layer 1: lange Floats (z. B. kcal_per_km) für Lesepfad kompakt."""
schema = [
{
"training_parameter_id": 1,
"key": "kcal_per_km",
"data_type": "float",
"unit": "kcal/km",
"validation_rules": {},
"source_field": None,
"name_de": "Kcal/km",
"name_en": "kcal/km",
"description_de": None,
"description_en": None,
"param_category": "performance",
}
]
eav = [
{
"training_parameter_id": 1,
"key": "kcal_per_km",
"data_type": "float",
"unit": "kcal/km",
"value": 51.5818181818181818,
}
]
out = merge_column_backed_and_eav_metrics({}, schema, eav)
assert len(out) == 1
v = out[0]["value"]
assert "581818" not in repr(v)
def test_merge_column_backed_includes_human_labels_from_schema():
schema = [
{
"training_parameter_id": 1,
"key": "watts",
"data_type": "integer",
"unit": "W",
"validation_rules": {},
"source_field": "avg_power",
"name_de": "Leistung",
"name_en": "Power",
"description_de": "Gerätewert",
"description_en": "Device reading",
}
]
out = merge_column_backed_and_eav_metrics({"avg_power": 200}, schema, [])
assert len(out) == 1
assert out[0]["value"] == 200
assert out[0]["name_de"] == "Leistung"
assert out[0]["description_en"] == "Device reading"
def test_merge_type_adds_parameter_not_in_category():
typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")]
merged = merge_parameter_schema_rows([], typ)
assert len(merged) == 1
assert merged[0]["key"] == "cadence"
assert merged[0]["required"] is True
def test_validate_integer_range():
_validate_single_value("integer", 5, {"min": 1, "max": 10})
with pytest.raises(ActivitySessionMetricsError) as ei:
_validate_single_value("integer", 0, {"min": 1})
assert ei.value.status_code == 400
def test_validate_float_accepts_int():
_validate_single_value("float", 3, {"min": 0, "max": 10})
def test_validate_boolean_rejects_int():
with pytest.raises(ActivitySessionMetricsError):
_validate_single_value("boolean", 1, {})
def test_row_value_tuple_mapping():
assert _row_value_tuple("integer", 42) == (None, 42, None, None)
assert _row_value_tuple("float", 1.5) == (1.5, None, None, None)
assert _row_value_tuple("string", "hi") == (None, None, "hi", None)
assert _row_value_tuple("boolean", True) == (None, None, None, True)
def test_normalize_metric_string_dtype_compacts_numeric_strings():
assert _normalize_metric_value_for_read("string", "51.58181818181818") == 52
assert _normalize_metric_value_for_read("string", "Freitext") == "Freitext"
class _FakeCursor:
"""Sequences fetchone/fetchall for resolve_activity_attribute_schema."""
def __init__(self, fetchone_chain, fetchall_chain):
self._fetchone = list(fetchone_chain)
self._fetchall = list(fetchall_chain)
self.executes: list[tuple] = []
def execute(self, sql, params=None):
self.executes.append((sql, params))
def fetchone(self):
return self._fetchone.pop(0)
def fetchall(self):
return self._fetchall.pop(0)
def test_resolve_with_explicit_category_no_type():
cur = _FakeCursor(
fetchone_chain=[],
fetchall_chain=[
[
_tp_row(1, "rpe", cat_sort=0),
],
],
)
out = resolve_activity_attribute_schema(cur, "cardio", None)
assert len(out) == 1
assert out[0]["key"] == "rpe"
assert len(cur.executes) == 1
def test_resolve_loads_category_from_training_type_id():
cur = _FakeCursor(
fetchone_chain=[{"category": "strength"}],
fetchall_chain=[
[_tp_row(1, "rpe", cat_sort=0)],
[],
],
)
out = resolve_activity_attribute_schema(cur, None, 42)
assert len(out) == 1
assert cur.executes[0][1] == (42,)
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
def test_enrich_sessions_batch(mock_resolve):
aid = str(uuid.uuid4())
bid = str(uuid.uuid4())
class _Cur:
def __init__(self):
self.params = None
self._fetch_n = 0
def execute(self, sql, params=None):
self.sql = sql
self.params = params
def fetchall(self):
self._fetch_n += 1
if self._fetch_n == 1:
return [
{
"id": uuid.UUID(aid),
"training_category": None,
"training_type_id": None,
},
{
"id": uuid.UUID(bid),
"training_category": None,
"training_type_id": None,
},
]
return [
{
"activity_log_id": uuid.UUID(aid),
"training_parameter_id": 3,
"key": "rpe",
"data_type": "integer",
"unit": None,
"value_num": None,
"value_int": 7,
"value_text": None,
"value_bool": None,
},
]
sessions = [{"id": aid}, {"id": bid}]
enrich_sessions_with_metrics(_Cur(), sessions)
assert sessions[0]["session_metrics"] == []
assert sessions[1]["session_metrics"] == []
def test_merge_column_backed_prefers_column_over_stale_eav():
schema = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"validation_rules": {},
"source_field": "hr_avg",
}
]
eav = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"value": 99.0,
}
]
out = merge_column_backed_and_eav_metrics({"hr_avg": 140.0}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 140.0
def test_merge_falls_back_to_eav_when_column_empty():
schema = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"validation_rules": {},
"source_field": "hr_avg",
}
]
eav = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"value": 99.0,
}
]
out = merge_column_backed_and_eav_metrics({"hr_avg": None}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 99.0
def test_merge_ignores_eav_when_parameter_not_in_schema():
"""Nur tcp/ttp-Schema zählt: verwaiste EAV-Zeilen erscheinen nicht in der effektiven Liste."""
schema = []
eav = [
{
"training_parameter_id": 2,
"key": "custom_param",
"data_type": "string",
"unit": None,
"value": "x",
}
]
out = merge_column_backed_and_eav_metrics({}, schema, eav)
assert out == []
def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
"""Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min."""
schema = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"validation_rules": {},
"source_field": None,
}
]
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, [])
assert len(out) == 1
assert out[0]["key"] == "min_hr"
assert out[0]["value"] == 88
def test_merge_eav_primary_prefers_legacy_column_over_eav_when_both():
"""Kanon: bei min_hr + hr_min und EAV-Zeile gewinnt activity_log (hr_min)."""
schema = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"validation_rules": {},
"source_field": None,
}
]
eav = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"value": 100,
}
]
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 88
def test_merge_registry_key_prefers_activity_log_column_over_eav():
"""Parameter-Key = Registry-Feld (z. B. duration_min): Spalte vor EAV."""
schema = [
{
"training_parameter_id": 3,
"key": "duration_min",
"data_type": "float",
"unit": "min",
"validation_rules": {},
"source_field": None,
}
]
eav = [
{
"training_parameter_id": 3,
"key": "duration_min",
"data_type": "float",
"unit": "min",
"value": 99.0,
}
]
out = merge_column_backed_and_eav_metrics({"duration_min": 45.0}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 45.0
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
def test_upsert_csv_skips_eav_when_source_field_maps_activity_log(mock_schema):
"""Parameter mit source_field: kanonisch activity_log — kein doppeltes EAV (z. B. avg_hr → hr_avg)."""
mock_schema.return_value = [
{
"key": "avg_hr",
"training_parameter_id": 42,
"data_type": "integer",
"validation_rules": {"min": 30, "max": 220},
"source_field": "hr_avg",
}
]
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {
"profile_id": "00000000-0000-0000-0000-000000000001",
"hr_avg": 130,
}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"avg_hr": 130},
"cardio",
1,
)
assert cur.asm_inserts == 0
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
def test_upsert_csv_writes_eav_when_source_field_but_column_empty(mock_schema):
"""source_field gesetzt, activity_log-Spalte leer — Wert aus mapped nur in EAV (kein Registry-Patch)."""
mock_schema.return_value = [
{
"key": "avg_hr",
"training_parameter_id": 42,
"data_type": "integer",
"validation_rules": {"min": 30, "max": 220},
"source_field": "hr_avg",
}
]
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {
"profile_id": "00000000-0000-0000-0000-000000000001",
"hr_avg": None,
}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"avg_hr": 130},
"cardio",
1,
)
assert cur.asm_inserts == 1
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
def test_upsert_csv_writes_eav_when_no_source_field(mock_schema):
mock_schema.return_value = [
{
"key": "custom_note",
"training_parameter_id": 99,
"data_type": "string",
"validation_rules": {},
"source_field": None,
}
]
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"custom_note": "x"},
"cardio",
1,
)
assert cur.asm_inserts == 1
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
def test_upsert_csv_skips_eav_when_mapped_key_not_in_profile_schema(mock_resolve):
"""Import-Mapping allein legt kein EAV an — Key muss in tcp/ttp (resolve) vorkommen."""
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"stola": 12},
"cardio",
1,
)
assert cur.asm_inserts == 0

View File

@ -249,11 +249,18 @@ def test_run_universal_import_activity_garmin_time_plus_date_columns(monkeypatch
cur = _SeqCursor([None, {"id": new_id}]) cur = _SeqCursor([None, {"id": new_id}])
out = run_universal_csv_import(cur, PID, "activity", text, "garmin.csv", mapping) out = run_universal_csv_import(cur, PID, "activity", text, "garmin.csv", mapping)
assert out["rows_imported"] == 1 assert out["rows_imported"] == 1
# Duplicate-Key muss Datum + kombinierte Startzeit enthalten # Duplicate-Check: Datum + TIME, IS NOT DISTINCT FROM (kein String-Vergleich für start_time)
assert any( dup_sqls = [
params and "2024-01-20 08:30:00" in str(params) (_sql, params)
for _sql, params in cur.executes for _sql, params in cur.executes
if params if params and "IS NOT DISTINCT FROM" in _sql and "activity_log" in _sql
]
assert dup_sqls, f"erwarteter Duplicate-SELECT fehlt; executes={cur.executes!r}"
assert any(
len(p) >= 3
and str(p[1]).startswith("2024-01-20")
and (getattr(p[2], "hour", None) == 8 and getattr(p[2], "minute", None) == 30)
for _, p in dup_sqls
) )

View File

@ -11,6 +11,7 @@ from csv_parser.core import (
headers_signature_rank_metrics, headers_signature_rank_metrics,
get_csv_import_limits, get_csv_import_limits,
iter_csv_dict_rows, iter_csv_dict_rows,
resolve_effective_csv_delimiter,
) )
from csv_parser.field_units import source_unit_choices_for_field from csv_parser.field_units import source_unit_choices_for_field
from csv_parser.mapping_suggest import build_type_conversions_for_mapping from csv_parser.mapping_suggest import build_type_conversions_for_mapping
@ -29,6 +30,20 @@ def test_sniff_delimiter():
assert sniff_delimiter("a,b,c") == "," assert sniff_delimiter("a,b,c") == ","
def test_resolve_effective_csv_delimiter_semicolon_file_comma_template():
"""DE-Apple: «;» in der Datei, englische Vorlage speichert «,»."""
header = "Workout Type;Start;End;Duration;Aktive Energie (kJ)"
row = "Laufen;2026-04-17 16:25;2026-04-17 17:00;00:30:00;500"
text = header + "\n" + row + "\n"
assert resolve_effective_csv_delimiter(text, ",") == ";"
assert resolve_effective_csv_delimiter(text, None) == ";"
def test_resolve_effective_csv_delimiter_comma_file_keeps_template():
text = "Workout Type,Start,End\nWalk,2026-04-17 16:25,2026-04-17 17:00\n"
assert resolve_effective_csv_delimiter(text, ",") == ","
def test_parse_csv_sample_header(): def test_parse_csv_sample_header():
text = "Date;kcal\n2024-01-01;2000\n" text = "Date;kcal\n2024-01-01;2000\n"
headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3) headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3)
@ -38,6 +53,29 @@ def test_parse_csv_sample_header():
assert rows[0]["kcal"] == "2000" assert rows[0]["kcal"] == "2000"
def test_parse_csv_sample_nbsp_in_header_matches_normal_space_key():
"""Excel/Apple: NBSP (U+00A0) im Spaltennamen — gleicher Key wie normales Leerzeichen."""
text = "Aufgestiegene\u00a0Höhe (m);Wert\n12;3\n"
headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3)
assert headers == ["Aufgestiegene Höhe (m)", "Wert"]
assert rows[0]["Aufgestiegene Höhe (m)"] == "12"
def test_iter_csv_dict_rows_nbsp_header_canonical():
text = "col\u00a0one;b\n1;2\n"
rows = list(iter_csv_dict_rows(text, ";", has_header=True))
assert rows == [{"col one": "1", "b": "2"}]
def test_build_row_field_mapping_space_vs_nbsp_in_csv_header():
"""Vorlage (Dialog) mit normalem Leerzeichen, CSV mit NBSP — Zuordnung muss greifen."""
csv_row = {"Aufgestiegene\u00a0Höhe (m)": "10"}
fm = {"Aufgestiegene Höhe (m)": "stola"}
tc = {"stola": {"type": "float", "decimal_separator": ".", "flexible": True}}
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
assert out.get("stola") == 10.0
def test_column_signature_sorted_unique(): def test_column_signature_sorted_unique():
sig = column_signature(["B", "a", "a"]) sig = column_signature(["B", "a", "a"])
assert sig == ["a", "b"] assert sig == ["a", "b"]
@ -183,6 +221,16 @@ def test_build_row_fddb_raw_header_keys_match_normalized_template():
assert out["kcal"] is not None and abs(float(out["kcal"]) - (42000 / 4.184)) < 0.1 assert out["kcal"] is not None and abs(float(out["kcal"]) - (42000 / 4.184)) < 0.1
def test_build_row_apple_workout_elevation_header_prefix_matches_shorter_mapping_key():
"""Apple Workouts: „Aufgestiegene Höhe (m)“ normalisiert anders als manuell „aufgestiegene Höhe“."""
csv_row = {"Aufgestiegene Höhe (m)": "47.13", "Workout Type": "Outdoor Spaziergang"}
fm = {"aufgestiegene Höhe": "stola", "Workout Type": "activity_type"}
tc = {"stola": {"type": "string"}}
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
assert out.get("stola") == "47.13"
assert out.get("activity_type") == "Outdoor Spaziergang"
def test_convert_date_ddmm_with_seconds(): def test_convert_date_ddmm_with_seconds():
d = convert_value( d = convert_value(
"15.01.2024 14:30:00", "15.01.2024 14:30:00",

View File

@ -5,6 +5,7 @@ from dashboard_layout_schema import (
DashboardLayoutPayload, DashboardLayoutPayload,
coalesce_effective_layout, coalesce_effective_layout,
default_layout_dict, default_layout_dict,
merge_missing_catalog_widgets,
) )
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
@ -56,3 +57,19 @@ def test_coalesce_valid_raw():
custom, eff = coalesce_effective_layout(raw) custom, eff = coalesce_effective_layout(raw)
assert custom is True assert custom is True
assert eff == raw assert eff == raw
def test_merge_missing_catalog_widgets_keeps_order_and_fills_ids():
raw = {
"version": 1,
"widgets": [
{"id": "kpi_board", "enabled": True, "config": {}},
{"id": "welcome", "enabled": False, "config": {}},
],
}
merged = merge_missing_catalog_widgets(raw)
assert [w["id"] for w in merged["widgets"][:2]] == ["kpi_board", "welcome"]
assert {w["id"] for w in merged["widgets"]} == ALLOWED_WIDGET_IDS
extra = [w for w in merged["widgets"] if w["id"] not in ("kpi_board", "welcome")]
assert all(w["enabled"] is False for w in extra)
DashboardLayoutPayload.model_validate(merged)

View File

@ -14,6 +14,196 @@ def test_body_chart_days_bounds():
validate_widget_entry_config("body_overview", {"chart_days": 91}) validate_widget_entry_config("body_overview", {"chart_days": 91})
def test_body_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("body_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_weight_chart"] is True
assert d["kpi_detail"] == "compact"
assert d["show_body_fat_chart"] is False
def test_body_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("body_history_viz", {"chart_days": 60})
assert d["chart_days"] == 60
assert d["show_goals_strip"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("body_history_viz", {"chart_days": 5})
def test_body_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"body_history_viz",
{"show_kpis": False, "show_weight_chart": False},
)
def test_body_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("body_history_viz", {"evil": True})
def test_nutrition_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("nutrition_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_kcal_vs_weight"] is True
assert d["kpi_detail"] == "compact"
assert d["show_calorie_balance_chart"] is False
assert d["show_energy_protein_charts"] is False
def test_nutrition_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("nutrition_history_viz", {"chart_days": 45})
assert d["chart_days"] == 45
assert d["show_goals_strip"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("nutrition_history_viz", {"chart_days": 5})
def test_nutrition_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"nutrition_history_viz",
{"show_kpis": False, "show_kcal_vs_weight": False, "show_macro_daily_bars": False, "show_macro_distribution_pair": False},
)
def test_nutrition_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("nutrition_history_viz", {"evil": True})
def test_fitness_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("fitness_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_chart_training_volume"] is True
assert d["kpi_detail"] == "compact"
assert d["show_layer_meta"] is False
assert d["show_chart_load_monitoring"] is False
def test_fitness_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("fitness_history_viz", {"chart_days": 60})
assert d["chart_days"] == 60
assert d["show_progress_insights"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("fitness_history_viz", {"chart_days": 5})
def test_fitness_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"fitness_history_viz",
{
"show_kpis": False,
"show_progress_insights": False,
"show_chart_training_volume": False,
"show_chart_training_type_distribution": False,
"show_chart_quality_sessions": False,
"show_chart_load_monitoring": False,
},
)
def test_fitness_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("fitness_history_viz", {"evil": True})
def test_recovery_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("recovery_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_chart_recovery_score"] is True
assert d["kpi_detail"] == "compact"
assert d["show_heart_context_card"] is False
assert d["show_vitals_extra_trends"] is False
def test_recovery_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("recovery_history_viz", {"chart_days": 42})
assert d["chart_days"] == 42
assert d["show_layer_meta"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("recovery_history_viz", {"chart_days": 3})
def test_recovery_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"recovery_history_viz",
{
"show_kpis": False,
"show_progress_insights": False,
"show_heart_context_card": False,
"show_vitals_extra_trends": False,
"show_chart_recovery_score": False,
"show_chart_sleep_quality": False,
"show_chart_sleep_debt": False,
"show_chart_hrv_rhr": False,
},
)
def test_recovery_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("recovery_history_viz", {"evil": True})
def test_history_overview_viz_empty_expands_defaults():
d = validate_widget_entry_config("history_overview_viz", {})
assert d["chart_days"] == 30
assert d["show_confidence_banner"] is True
assert d["show_section_body"] is True
assert d["show_section_nutrition"] is True
assert d["show_section_fitness"] is True
assert d["show_section_recovery"] is True
assert d["show_correlation_c1_c3"] is True
assert d["show_drivers_c4"] is True
def test_history_overview_viz_chart_days_and_merge():
d = validate_widget_entry_config("history_overview_viz", {"chart_days": 60})
assert d["chart_days"] == 60
assert d["show_intro_blurb"] is True
with pytest.raises(ValueError):
validate_widget_entry_config("history_overview_viz", {"chart_days": 5})
def test_history_overview_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"history_overview_viz",
{
"show_confidence_banner": False,
"show_section_body": False,
"show_section_nutrition": False,
"show_section_fitness": False,
"show_section_recovery": False,
"show_correlation_c1_c3": False,
"show_drivers_c4": False,
},
)
def test_history_overview_viz_legacy_show_area_summaries_maps_sections():
d = validate_widget_entry_config(
"history_overview_viz",
{"show_area_summaries": False, "show_correlation_c1_c3": True},
)
assert d["show_section_body"] is False
assert d["show_section_fitness"] is False
assert d["show_correlation_c1_c3"] is True
def test_history_overview_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("history_overview_viz", {"evil": True})
def test_welcome_config_rejected_unknown_key(): def test_welcome_config_rejected_unknown_key():
with pytest.raises(ValueError): with pytest.raises(ValueError):
validate_widget_entry_config("welcome", {"x": 1}) validate_widget_entry_config("welcome", {"x": 1})

View File

@ -0,0 +1,89 @@
"""Tests für data_layer.prompt_output_compact (KI-Platzhalter, Token)."""
import pytest
from data_layer.prompt_output_compact import (
compact_float_for_prompt,
compact_json_payload_for_prompts,
format_scalar_for_prompt_text,
normalize_prompt_number,
session_metrics_list_to_key_value_compact,
)
@pytest.mark.parametrize(
"x,expected",
[
(0.0, 0),
(123.456, 123),
(45.67, 46),
(9.876, 9.88),
(0.99, 0.99),
(0.055, 0.055),
(0.01234, 0.012),
],
)
def test_compact_float_for_prompt(x, expected):
out = compact_float_for_prompt(x)
if isinstance(expected, float):
assert abs(float(out) - expected) < 0.0001
else:
assert out == expected
def test_compact_json_nested():
raw = {"a": 12.345678, "b": {"c": 0.0666}, "d": [1.111, 2.0]}
out = compact_json_payload_for_prompts(raw)
assert out["a"] == 12
assert abs(out["b"]["c"] - 0.067) < 0.001
assert out["d"][0] == 1.11
def test_format_scalar_no_long_float_tail():
s = format_scalar_for_prompt_text(51.5818181818181818)
assert "181818" not in s
assert len(s) <= 8
def test_format_scalar_numeric_string_no_long_tail():
s = format_scalar_for_prompt_text("51.581818181818181818")
assert "181818" not in s
def test_session_metrics_string_dtype_compacts_numeric_strings():
sm = [
{
"key": "temp_c",
"data_type": "string",
"value": "22.333333333333336",
},
{
"key": "kcal_per_km",
"data_type": "string",
"value": "51.581818181818181818",
},
]
out = session_metrics_list_to_key_value_compact(sm)
assert out["temp_c"] == 22
assert out["kcal_per_km"] == 52
def test_session_metrics_key_value_only():
sm = [
{
"key": "rpe",
"data_type": "integer",
"value": 7,
"name_de": "RPE",
"description_de": "lang",
},
{
"key": "watts",
"data_type": "float",
"value": 199.999,
"unit": "W",
},
]
out = session_metrics_list_to_key_value_compact(sm)
assert out == {"rpe": 7, "watts": 200}
assert "name_de" not in str(out)

View File

@ -1,6 +1,9 @@
"""Unit tests for data_layer.reference_values (summary assembly, no DB).""" """Unit tests for data_layer.reference_values (summary assembly, no DB)."""
from data_layer.reference_values import build_summary_tiles_from_ranked_rows from datetime import date
from decimal import Decimal
from data_layer.reference_values import _entry_dict_from_ranked_row, build_summary_tiles_from_ranked_rows
def test_build_summary_tiles_single_type_two_rows(): def test_build_summary_tiles_single_type_two_rows():
@ -46,3 +49,27 @@ def test_build_summary_tiles_multi_type_order():
] ]
tiles = build_summary_tiles_from_ranked_rows(raw) tiles = build_summary_tiles_from_ranked_rows(raw)
assert [x["type_key"] for x in tiles] == ["a", "b"] assert [x["type_key"] for x in tiles] == ["a", "b"]
def test_entry_dict_from_ranked_row_normalizes_decimal():
row = {
"id": 1,
"profile_id": "p",
"reference_value_type_id": 9,
"effective_date": date(2026, 4, 1),
"value_numeric": Decimal("42.5"),
"value_text": None,
"unit": "bpm",
"source": "lab",
"confidence": "high",
"method": "spiro",
"notes": None,
"extra": None,
"created_at": date(2026, 4, 2),
"updated_at": date(2026, 4, 2),
"type_key": "hr_max",
"type_label": "HF max",
}
out = _entry_dict_from_ranked_row(row)
assert out["value_numeric"] == 42.5
assert out["effective_date"] == "2026-04-01"

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