- Mark issue #53 as completed - Create issue #55: Dynamic Aggregation Methods - Update CLAUDE.md with Phase 0c achievements - Document 97 migrated functions + 20 new chart endpoints
2139 lines
59 KiB
Markdown
2139 lines
59 KiB
Markdown
# Issue #53: Phase 0c - Multi-Layer Data Architecture
|
|
|
|
**Status:** ✅ COMPLETED
|
|
**Priorität:** High (Strategic)
|
|
**Aufwand:** 20-27h (5-7 Tage bei 4h/Tag)
|
|
**Erstellt:** 28. März 2026
|
|
**Abgeschlossen:** 28. März 2026
|
|
**Abhängigkeiten:** Phase 0a ✅, Phase 0b ✅
|
|
|
|
**Completion Summary:**
|
|
- ✅ Data Layer: 97 functions migrated to `data_layer/` (6 modules)
|
|
- ✅ Chart Endpoints: 20 new endpoints implemented (E1-E5, A1-A8, R1-R5, C1-C4)
|
|
- ✅ Single Source of Truth: All calculations in data_layer, used by KI + Charts
|
|
- ✅ Commits: 7 systematic commits (6 module migrations + 1 chart expansion)
|
|
- ✅ charts.py: 329 → 2246 lines (+1917 lines)
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
**Ziel:** Refactoring der Datenarchitektur von monolithischer Platzhalter-Logik zu einer dreischichtigen Architektur mit klarer Separation of Concerns.
|
|
|
|
**Motivation:**
|
|
- Aktuell sind Datenermittlung, Berechnungslogik und Formatierung in `placeholder_resolver.py` vermischt
|
|
- Keine Wiederverwendbarkeit für Charts, Diagramme, API-Endpoints
|
|
- Jede neue Visualisierung erfordert Duplikation der Berechnungslogik
|
|
- Schwer testbar, schwer erweiterbar
|
|
|
|
**Lösung:**
|
|
```
|
|
┌────────────────────────────────────────────────┐
|
|
│ Layer 1: DATA LAYER (neu) │
|
|
│ - Pure data retrieval + calculation logic │
|
|
│ - Returns: Structured data (dict/list/float) │
|
|
│ - No formatting, no strings │
|
|
│ - Testable, reusable │
|
|
└──────────────────┬─────────────────────────────┘
|
|
│
|
|
┌───────────┴──────────┐
|
|
│ │
|
|
▼ ▼
|
|
┌──────────────┐ ┌─────────────────────┐
|
|
│ Layer 2a: │ │ Layer 2b: │
|
|
│ KI LAYER │ │ VISUALIZATION LAYER │
|
|
│ (refactored) │ │ (new) │
|
|
└──────────────┘ └─────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 0b Achievements (Blaupause für Phase 0c)
|
|
|
|
### Was wurde in Phase 0b implementiert? ✅
|
|
|
|
**Datum:** 28. März 2026 (früher Chat)
|
|
**Commits:** 20+ Commits mit "Phase 0b" prefix
|
|
|
|
#### 1. Platzhalter-Funktionen (in placeholder_resolver.py)
|
|
|
|
**Körper-Metriken:**
|
|
```python
|
|
def _get_body_progress_score(profile_id: str, goal_mode: str) -> dict:
|
|
"""
|
|
Berechnet goal-mode-abhängigen Body Progress Score.
|
|
|
|
Aktuelle Implementierung (Phase 0b):
|
|
- SQL queries direkt in Funktion
|
|
- Berechnungslogik inline
|
|
- Returns: dict mit score + components
|
|
|
|
Phase 0c Migration:
|
|
→ Wird zu: data_layer.body_metrics.get_body_progress_data()
|
|
→ Placeholder nutzt dann nur noch: data['score']
|
|
"""
|
|
```
|
|
|
|
**Fokus-Bereiche:**
|
|
```python
|
|
def _get_active_goals_json(profile_id: str) -> str:
|
|
"""
|
|
Returns: JSON string mit aktiven Zielen
|
|
|
|
Phase 0c Migration:
|
|
→ Data Layer: data_layer.goals.get_active_goals() → list[dict]
|
|
→ KI Layer: json.dumps(data) → str
|
|
"""
|
|
|
|
def _get_focus_areas_json(profile_id: str) -> str:
|
|
"""
|
|
Returns: JSON string mit gewichteten Focus Areas
|
|
|
|
Phase 0c Migration:
|
|
→ Data Layer: data_layer.goals.get_weighted_focus_areas() → list[dict]
|
|
→ KI Layer: json.dumps(data) → str
|
|
"""
|
|
```
|
|
|
|
**Ernährungs-Metriken:**
|
|
```python
|
|
def _get_nutrition_metrics(profile_id: str, days: int) -> dict:
|
|
"""
|
|
Berechnet Protein/kg, Adherence, etc.
|
|
|
|
Phase 0c Migration:
|
|
→ Wird zu: data_layer.nutrition_metrics.get_protein_adequacy_data()
|
|
→ Zusätzliche Metriken in: get_energy_balance_data(), get_macro_distribution_data()
|
|
"""
|
|
```
|
|
|
|
#### 2. Score-System (goal_utils.py)
|
|
|
|
```python
|
|
# backend/goal_utils.py
|
|
|
|
def map_focus_to_score_components(profile_id: str) -> dict:
|
|
"""
|
|
Maps gewichtete Focus Areas zu Score-Komponenten.
|
|
|
|
Returns:
|
|
{
|
|
"body": 0.30,
|
|
"nutrition": 0.25,
|
|
"training": 0.20,
|
|
"recovery": 0.15,
|
|
"health": 0.10
|
|
}
|
|
|
|
Phase 0c: BLEIBT in goal_utils.py
|
|
→ Ist Score-Gewichtung, nicht Datenermittlung
|
|
"""
|
|
|
|
def get_active_goals(profile_id: str) -> list[dict]:
|
|
"""
|
|
Holt alle aktiven Ziele mit vollständigen Daten.
|
|
|
|
Phase 0c Migration:
|
|
→ Wird zu: data_layer.goals.get_active_goals()
|
|
→ goal_utils.py importiert dann aus data_layer
|
|
"""
|
|
```
|
|
|
|
#### 3. Bug Fixes (Learnings für Phase 0c)
|
|
|
|
**Decimal → Float Conversion:**
|
|
```python
|
|
# Problem: PostgreSQL Decimal-Type nicht JSON-serializable
|
|
# Lösung: Explizite Konvertierung
|
|
|
|
# ALT (Phase 0b Bug):
|
|
protein_g = row['protein'] # Decimal object
|
|
return {"protein": protein_g} # JSON error
|
|
|
|
# FIX (Phase 0b):
|
|
protein_g = float(row['protein']) if row['protein'] else 0.0
|
|
return {"protein": protein_g} # OK
|
|
```
|
|
|
|
**Column Name Consistency:**
|
|
```python
|
|
# Problem: Inkonsistente Spaltennamen
|
|
# Lösung: Immer aus Schema prüfen
|
|
|
|
# ALT (Phase 0b Bug):
|
|
SELECT bf_jpl FROM caliper_log # Spalte existiert nicht
|
|
|
|
# FIX (Phase 0b):
|
|
SELECT body_fat_pct FROM caliper_log # Korrekt
|
|
```
|
|
|
|
**Dict Access Safety:**
|
|
```python
|
|
# Problem: KeyError bei fehlenden Daten
|
|
# Lösung: .get() mit defaults
|
|
|
|
# ALT (Phase 0b Bug):
|
|
sleep_quality = sleep_data['quality'] # KeyError wenn leer
|
|
|
|
# FIX (Phase 0b):
|
|
sleep_quality = sleep_data.get('quality', 0.0) # Safe
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 0c: Detaillierte Spezifikation
|
|
|
|
### Ziele
|
|
|
|
1. ✅ **Single Source of Truth:** Jede Berechnung nur einmal implementiert
|
|
2. ✅ **Wiederverwendbarkeit:** Gleiche Daten für KI + Charts + API
|
|
3. ✅ **Testbarkeit:** Data Layer isoliert testbar
|
|
4. ✅ **Erweiterbarkeit:** Neue Features ohne Code-Duplikation
|
|
5. ✅ **Performance:** Caching auf Data Layer Ebene möglich
|
|
|
|
### Nicht-Ziele (Scope Grenzen)
|
|
|
|
❌ **NICHT in Phase 0c:**
|
|
- Neue Charts im Frontend implementieren (nur Backend-Endpoints)
|
|
- Frontend Chart-Komponenten (kommt in Phase 1)
|
|
- Caching-Layer (kommt später)
|
|
- API-Dokumentation mit Swagger (kommt später)
|
|
|
|
---
|
|
|
|
## Implementierungs-Plan
|
|
|
|
### Step 1: Data Layer Module erstellen (8-10h)
|
|
|
|
**Verzeichnisstruktur:**
|
|
```
|
|
backend/
|
|
├── data_layer/ # NEU
|
|
│ ├── __init__.py # Exports all functions
|
|
│ ├── body_metrics.py # Gewicht, FM, LBM, Umfänge, BF%
|
|
│ ├── nutrition_metrics.py # Kalorien, Protein, Makros, Adherence
|
|
│ ├── activity_metrics.py # Volumen, Qualität, Monotony, Abilities
|
|
│ ├── recovery_metrics.py # RHR, HRV, Sleep, Recovery Score
|
|
│ ├── health_metrics.py # BP, VO2Max, SpO2, Health Stability
|
|
│ ├── goals.py # Active goals, progress, projections
|
|
│ ├── correlations.py # Lag-Korrelationen, Plateau Detection
|
|
│ └── utils.py # Shared: confidence, baseline, outliers
|
|
├── placeholder_resolver.py # REFACTORED (nutzt data_layer)
|
|
├── goal_utils.py # REFACTORED (nutzt data_layer.goals)
|
|
└── routers/
|
|
└── charts.py # NEU (nutzt data_layer)
|
|
```
|
|
|
|
#### Module 1: body_metrics.py
|
|
|
|
**Pfad:** `backend/data_layer/body_metrics.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Body composition metrics and weight trend analysis.
|
|
|
|
All functions return structured data (dict/list) without formatting.
|
|
Use these for both AI placeholders AND chart endpoints.
|
|
"""
|
|
|
|
from typing import Optional
|
|
from datetime import date, timedelta
|
|
from db import get_db, get_cursor
|
|
|
|
|
|
def get_weight_trend_data(
|
|
profile_id: str,
|
|
days: int = 90,
|
|
include_projections: bool = True
|
|
) -> dict:
|
|
"""
|
|
Weight trend with rolling medians, slopes, and goal projections.
|
|
|
|
Args:
|
|
profile_id: User profile ID
|
|
days: Number of days to analyze (default 90)
|
|
include_projections: Include goal projection calculations
|
|
|
|
Returns:
|
|
{
|
|
"raw_values": [(date, weight), ...],
|
|
"rolling_median_7d": [(date, value), ...],
|
|
"slope_7d": float, # kg per week
|
|
"slope_28d": float,
|
|
"slope_90d": float,
|
|
"confidence": str, # "high"/"medium"/"low"/"insufficient"
|
|
"data_points": int,
|
|
"first_date": date,
|
|
"last_date": date,
|
|
"first_value": float,
|
|
"last_value": float,
|
|
"delta": float,
|
|
"projection": { # Only if include_projections=True
|
|
"target_weight": float,
|
|
"current_rate": float,
|
|
"estimated_days": int,
|
|
"estimated_date": date
|
|
} | None
|
|
}
|
|
|
|
Confidence Rules (from utils.py):
|
|
- "high": >= 60 points (90d) or >= 18 points (28d) or >= 4 points (7d)
|
|
- "medium": >= 40 points (90d) or >= 12 points (28d) or >= 3 points (7d)
|
|
- "low": < thresholds above but some data
|
|
- "insufficient": < 3 points total
|
|
|
|
Migration from Phase 0b:
|
|
- OLD: _get_weight_trend_slope() in placeholder_resolver.py (inline SQL)
|
|
- NEW: This function (reusable)
|
|
- KI Layer: resolve_weight_28d_trend_slope() → f"{data['slope_28d']:.2f} kg/Woche"
|
|
- Chart: GET /api/charts/weight-trend → return data
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_body_composition_data(
|
|
profile_id: str,
|
|
days: int = 90
|
|
) -> dict:
|
|
"""
|
|
Fat mass, lean mass, body fat percentage trends.
|
|
|
|
Returns:
|
|
{
|
|
"dates": [date, ...],
|
|
"weight": [float, ...],
|
|
"body_fat_pct": [float, ...],
|
|
"fat_mass": [float, ...],
|
|
"lean_mass": [float, ...],
|
|
"fm_delta_7d": float,
|
|
"fm_delta_28d": float,
|
|
"fm_delta_90d": float,
|
|
"lbm_delta_7d": float,
|
|
"lbm_delta_28d": float,
|
|
"lbm_delta_90d": float,
|
|
"recomposition_score": int, # 0-100
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Recomposition Score Logic:
|
|
- FM↓ + LBM↑ = 100 (perfect)
|
|
- FM↓ + LBM= = 80 (good)
|
|
- FM= + LBM↑ = 70 (ok)
|
|
- FM↓ + LBM↓ = depends on ratio
|
|
- FM↑ + LBM↓ = 0 (worst)
|
|
|
|
Migration from Phase 0b:
|
|
- OLD: Part of _get_body_progress_score() (mixed with scoring)
|
|
- NEW: This function (pure data)
|
|
- Score calculation stays in goal_utils.py
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_circumference_summary(
|
|
profile_id: str,
|
|
days: int = 90
|
|
) -> dict:
|
|
"""
|
|
Circumference measurements with best-of-each strategy.
|
|
|
|
Returns:
|
|
{
|
|
"measurements": {
|
|
"c_neck": {"value": float, "date": date, "age_days": int},
|
|
"c_chest": {"value": float, "date": date, "age_days": int},
|
|
"c_waist": {"value": float, "date": date, "age_days": int},
|
|
"c_hips": {"value": float, "date": date, "age_days": int},
|
|
"c_thigh_l": {"value": float, "date": date, "age_days": int},
|
|
"c_thigh_r": {"value": float, "date": date, "age_days": int},
|
|
"c_bicep_l": {"value": float, "date": date, "age_days": int},
|
|
"c_bicep_r": {"value": float, "date": date, "age_days": int}
|
|
},
|
|
"ratios": {
|
|
"waist_to_hip": float, # WHR - Bauchfettverteilung
|
|
"waist_to_height": float # WHtR - Gesundheitsrisiko
|
|
},
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Best-of-Each Logic:
|
|
- Pro Messpunkt: Neuester Wert innerhalb days
|
|
- WHR: waist / hips (< 0.90 men, < 0.85 women = low risk)
|
|
- WHtR: waist / height_cm (< 0.50 = low risk)
|
|
|
|
Migration from Phase 0b:
|
|
- OLD: resolve_circ_summary() in placeholder_resolver.py
|
|
- NEW: This function
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
#### Module 2: nutrition_metrics.py
|
|
|
|
**Pfad:** `backend/data_layer/nutrition_metrics.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Nutrition analysis: calories, protein, macros, adherence.
|
|
"""
|
|
|
|
def get_protein_adequacy_data(
|
|
profile_id: str,
|
|
days: int = 28,
|
|
goal_mode: Optional[str] = None
|
|
) -> dict:
|
|
"""
|
|
Protein intake vs. target (goal_mode-dependent).
|
|
|
|
Returns:
|
|
{
|
|
"daily_values": [(date, protein_g, target_g), ...],
|
|
"avg_protein_g": float,
|
|
"avg_protein_per_kg": float,
|
|
"avg_protein_per_kg_lbm": float,
|
|
"target_protein_g": float,
|
|
"target_protein_per_kg": float,
|
|
"adherence_pct": float, # % of days >= 90% of target
|
|
"adherence_score": int, # 0-100
|
|
"goal_mode": str,
|
|
"current_weight": float,
|
|
"lean_body_mass": float,
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Target Protein per Goal Mode:
|
|
- "strength": 2.0-2.2 g/kg
|
|
- "weight_loss": 1.8-2.0 g/kg
|
|
- "recomposition": 2.0-2.2 g/kg
|
|
- "endurance": 1.4-1.6 g/kg
|
|
- "health": 1.2-1.6 g/kg
|
|
|
|
Adherence Score:
|
|
- 100: >= 95% of days meet target
|
|
- 80: >= 80% of days meet target
|
|
- 60: >= 60% of days meet target
|
|
- <60: proportional
|
|
|
|
Migration from Phase 0b:
|
|
- OLD: _get_nutrition_metrics() in placeholder_resolver.py
|
|
- NEW: This function
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_energy_balance_data(
|
|
profile_id: str,
|
|
days: int = 28
|
|
) -> dict:
|
|
"""
|
|
Calorie intake vs. expenditure, deficit/surplus calculations.
|
|
|
|
Returns:
|
|
{
|
|
"daily_values": [(date, intake_kcal, activity_kcal, net), ...],
|
|
"avg_intake": float,
|
|
"avg_activity_kcal": float,
|
|
"avg_net": float, # intake - activity
|
|
"estimated_bmr": float,
|
|
"energy_availability": float, # (intake - activity) / LBM
|
|
"deficit_surplus_avg": float, # negative = deficit
|
|
"confidence": str,
|
|
"data_points": int,
|
|
"red_s_warning": bool # True if EA < 30 kcal/kg LBM
|
|
}
|
|
|
|
Energy Availability:
|
|
- EA = (intake - activity) / LBM (kg)
|
|
- < 30 kcal/kg LBM = RED-S risk (Relative Energy Deficiency in Sport)
|
|
- 30-45 = moderate risk
|
|
- > 45 = adequate
|
|
|
|
Migration:
|
|
- NEW function (was part of Phase 0b scope, moved to 0c)
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_macro_distribution_data(
|
|
profile_id: str,
|
|
days: int = 28
|
|
) -> dict:
|
|
"""
|
|
Macronutrient distribution and balance.
|
|
|
|
Returns:
|
|
{
|
|
"avg_kcal": float,
|
|
"avg_protein_g": float,
|
|
"avg_carbs_g": float,
|
|
"avg_fat_g": float,
|
|
"pct_protein": float, # % of total kcal
|
|
"pct_carbs": float,
|
|
"pct_fat": float,
|
|
"balance_score": int, # 0-100, goal_mode-dependent
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Balance Score (example for strength goal):
|
|
- Protein: 25-35% = 100, outside = penalty
|
|
- Carbs: 40-50% = 100, outside = penalty
|
|
- Fat: 20-30% = 100, outside = penalty
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
#### Module 3: activity_metrics.py
|
|
|
|
**Pfad:** `backend/data_layer/activity_metrics.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Training volume, quality, monotony, ability balance.
|
|
"""
|
|
|
|
def get_training_volume_data(
|
|
profile_id: str,
|
|
weeks: int = 4
|
|
) -> dict:
|
|
"""
|
|
Training volume per week, distribution by type.
|
|
|
|
Returns:
|
|
{
|
|
"weekly_totals": [
|
|
{
|
|
"week_start": date,
|
|
"duration_min": int,
|
|
"kcal": int,
|
|
"sessions": int,
|
|
"avg_quality": float
|
|
},
|
|
...
|
|
],
|
|
"by_type": {
|
|
"strength": {"duration": int, "sessions": int, "kcal": int},
|
|
"cardio": {"duration": int, "sessions": int, "kcal": int},
|
|
...
|
|
},
|
|
"total_duration": int,
|
|
"total_sessions": int,
|
|
"avg_quality": float, # 1.0-5.0
|
|
"monotony": float, # < 2.0 = gut
|
|
"strain": float, # kumulativ
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Monotony Calculation:
|
|
- monotony = avg_daily_duration / std_dev_daily_duration
|
|
- < 1.5 = hohe Variation (gut)
|
|
- 1.5-2.0 = moderate Variation
|
|
- > 2.0 = niedrige Variation (Risiko Plateau/Übertraining)
|
|
|
|
Strain Calculation:
|
|
- strain = total_duration * monotony
|
|
- Hohe Strain + hohe Monotony = Übertraining-Risiko
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_activity_quality_distribution(
|
|
profile_id: str,
|
|
days: int = 28
|
|
) -> dict:
|
|
"""
|
|
Quality label distribution and trends.
|
|
|
|
Returns:
|
|
{
|
|
"distribution": {
|
|
"excellent": int, # count
|
|
"very_good": int,
|
|
"good": int,
|
|
"acceptable": int,
|
|
"poor": int
|
|
},
|
|
"avg_quality": float, # 1.0-5.0
|
|
"quality_trend": str, # "improving"/"stable"/"declining"
|
|
"high_quality_pct": float, # % excellent + very_good
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Quality Trend:
|
|
- Compare first_half_avg vs. second_half_avg
|
|
- > 0.2 difference = improving/declining
|
|
- <= 0.2 = stable
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_ability_balance_data(
|
|
profile_id: str,
|
|
weeks: int = 4
|
|
) -> dict:
|
|
"""
|
|
Balance across 5 ability dimensions (from training_types).
|
|
|
|
Returns:
|
|
{
|
|
"abilities": {
|
|
"strength": float, # normalized 0-1
|
|
"cardio": float,
|
|
"mobility": float,
|
|
"coordination": float,
|
|
"mental": float
|
|
},
|
|
"balance_score": int, # 0-100
|
|
"imbalances": [
|
|
{"ability": str, "severity": str, "recommendation": str},
|
|
...
|
|
],
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Balance Score:
|
|
- Perfect balance (all ~0.20) = 100
|
|
- Moderate imbalance (one dominant) = 70-80
|
|
- Severe imbalance (one > 0.50) = < 50
|
|
|
|
Migration:
|
|
- NEW function (was part of Phase 0b scope, moved to 0c)
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
#### Module 4: recovery_metrics.py
|
|
|
|
**Pfad:** `backend/data_layer/recovery_metrics.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Recovery score, sleep analysis, vitals baselines.
|
|
"""
|
|
|
|
def get_recovery_score_data(
|
|
profile_id: str,
|
|
days: int = 7
|
|
) -> dict:
|
|
"""
|
|
Composite recovery score from RHR, HRV, sleep, rest days.
|
|
|
|
Returns:
|
|
{
|
|
"score": int, # 0-100
|
|
"components": {
|
|
"rhr": {
|
|
"value": float,
|
|
"baseline_7d": float,
|
|
"deviation_pct": float,
|
|
"score": int # 0-100
|
|
},
|
|
"hrv": {
|
|
"value": float,
|
|
"baseline_7d": float,
|
|
"deviation_pct": float,
|
|
"score": int
|
|
},
|
|
"sleep": {
|
|
"duration_h": float,
|
|
"quality_pct": float, # Deep+REM / total
|
|
"score": int
|
|
},
|
|
"rest_compliance": {
|
|
"rest_days": int,
|
|
"recommended": int,
|
|
"score": int
|
|
}
|
|
},
|
|
"trend": str, # "improving"/"stable"/"declining"
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Component Weights:
|
|
- RHR: 30%
|
|
- HRV: 30%
|
|
- Sleep: 30%
|
|
- Rest Compliance: 10%
|
|
|
|
Score Calculations:
|
|
RHR Score:
|
|
- Below baseline by >5% = 100
|
|
- At baseline ±5% = 80
|
|
- Above baseline by 5-10% = 50
|
|
- Above baseline by >10% = 20
|
|
|
|
HRV Score:
|
|
- Above baseline by >10% = 100
|
|
- At baseline ±10% = 80
|
|
- Below baseline by 10-20% = 50
|
|
- Below baseline by >20% = 20
|
|
|
|
Sleep Score:
|
|
- Duration >= 7h AND quality >= 75% = 100
|
|
- Duration >= 6h AND quality >= 65% = 80
|
|
- Duration >= 5h OR quality >= 50% = 50
|
|
- Else = 20
|
|
|
|
Rest Compliance:
|
|
- rest_days >= recommended = 100
|
|
- rest_days >= recommended - 1 = 70
|
|
- Else = proportional
|
|
|
|
Migration from Phase 0b:
|
|
- OLD: Part of health_stability_score (mixed logic)
|
|
- NEW: This function (focused on recovery only)
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_sleep_regularity_data(
|
|
profile_id: str,
|
|
days: int = 28
|
|
) -> dict:
|
|
"""
|
|
Sleep regularity index and patterns.
|
|
|
|
Returns:
|
|
{
|
|
"regularity_score": int, # 0-100
|
|
"avg_duration_h": float,
|
|
"std_dev_duration": float,
|
|
"avg_bedtime": str, # "23:15" (HH:MM)
|
|
"std_dev_bedtime_min": float,
|
|
"sleep_debt_h": float, # cumulative vs. 7h target
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Regularity Score:
|
|
- Based on consistency of duration and bedtime
|
|
- Low std_dev = high score
|
|
- Formula: 100 - (std_dev_duration * 10 + std_dev_bedtime_min / 6)
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_vitals_baseline_data(
|
|
profile_id: str,
|
|
days: int = 7
|
|
) -> dict:
|
|
"""
|
|
Baseline vitals: RHR, HRV, VO2Max, SpO2, respiratory rate.
|
|
|
|
Returns:
|
|
{
|
|
"rhr": {
|
|
"current": float,
|
|
"baseline_7d": float,
|
|
"baseline_28d": float,
|
|
"trend": str # "improving"/"stable"/"declining"
|
|
},
|
|
"hrv": {
|
|
"current": float,
|
|
"baseline_7d": float,
|
|
"baseline_28d": float,
|
|
"trend": str
|
|
},
|
|
"vo2_max": {
|
|
"current": float,
|
|
"baseline_28d": float,
|
|
"trend": str
|
|
},
|
|
"spo2": {
|
|
"current": float,
|
|
"baseline_7d": float
|
|
},
|
|
"respiratory_rate": {
|
|
"current": float,
|
|
"baseline_7d": float
|
|
},
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
Trend Calculation:
|
|
- Compare current vs. baseline
|
|
- RHR: lower = improving
|
|
- HRV: higher = improving
|
|
- VO2Max: higher = improving
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
#### Module 5: health_metrics.py
|
|
|
|
**Pfad:** `backend/data_layer/health_metrics.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Blood pressure, health stability score, risk indicators.
|
|
"""
|
|
|
|
def get_blood_pressure_data(
|
|
profile_id: str,
|
|
days: int = 28
|
|
) -> dict:
|
|
"""
|
|
Blood pressure trends and risk classification.
|
|
|
|
Returns:
|
|
{
|
|
"measurements": [
|
|
{
|
|
"date": date,
|
|
"systolic": int,
|
|
"diastolic": int,
|
|
"pulse": int,
|
|
"context": str,
|
|
"classification": str # WHO/ISH
|
|
},
|
|
...
|
|
],
|
|
"avg_systolic": float,
|
|
"avg_diastolic": float,
|
|
"avg_pulse": float,
|
|
"risk_level": str, # "normal"/"elevated"/"hypertension_stage_1"/...
|
|
"measurements_by_context": dict,
|
|
"confidence": str,
|
|
"data_points": int
|
|
}
|
|
|
|
WHO/ISH Classification:
|
|
- Normal: <120/<80
|
|
- Elevated: 120-129/<80
|
|
- Hypertension Stage 1: 130-139/80-89
|
|
- Hypertension Stage 2: >=140/>=90
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_health_stability_score(
|
|
profile_id: str,
|
|
days: int = 28
|
|
) -> dict:
|
|
"""
|
|
Overall health stability across multiple dimensions.
|
|
|
|
Returns:
|
|
{
|
|
"score": int, # 0-100
|
|
"components": {
|
|
"vitals_stability": int, # RHR, HRV, BP variance
|
|
"sleep_regularity": int,
|
|
"activity_consistency": int,
|
|
"nutrition_adherence": int,
|
|
"recovery_quality": int
|
|
},
|
|
"risk_indicators": [
|
|
{"type": str, "severity": str, "message": str},
|
|
...
|
|
],
|
|
"confidence": str
|
|
}
|
|
|
|
Risk Indicators:
|
|
- RED-S: energy_availability < 30
|
|
- Overtraining: high strain + low recovery
|
|
- BP Risk: avg systolic >= 130
|
|
- Sleep Debt: cumulative > 10h
|
|
- HRV Drop: < baseline by >20%
|
|
|
|
Migration:
|
|
- NEW function (was part of Phase 0b scope, moved to 0c)
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
#### Module 6: goals.py
|
|
|
|
**Pfad:** `backend/data_layer/goals.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Goal tracking, progress, projections.
|
|
"""
|
|
|
|
def get_active_goals(profile_id: str) -> list[dict]:
|
|
"""
|
|
All active goals with full details.
|
|
|
|
Returns:
|
|
[
|
|
{
|
|
"id": str,
|
|
"goal_type": str,
|
|
"name": str,
|
|
"target_value": float,
|
|
"target_date": date | None,
|
|
"current_value": float,
|
|
"start_value": float,
|
|
"start_date": date,
|
|
"progress_pct": float,
|
|
"status": str,
|
|
"is_primary": bool,
|
|
"created_at": date,
|
|
"focus_contributions": [
|
|
{"focus_area": str, "weight": float},
|
|
...
|
|
]
|
|
},
|
|
...
|
|
]
|
|
|
|
Migration from Phase 0b:
|
|
- OLD: goal_utils.get_active_goals()
|
|
- NEW: This function (moved to data_layer)
|
|
- goal_utils.py imports from here
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_goal_progress_data(
|
|
profile_id: str,
|
|
goal_id: str
|
|
) -> dict:
|
|
"""
|
|
Detailed progress tracking for a single goal.
|
|
|
|
Returns:
|
|
{
|
|
"goal": dict, # Full goal object
|
|
"history": [
|
|
{"date": date, "value": float},
|
|
...
|
|
],
|
|
"progress_pct": float,
|
|
"time_progress_pct": float, # (elapsed / total) * 100
|
|
"deviation": float, # actual - expected (time-based)
|
|
"projection": {
|
|
"estimated_completion": date,
|
|
"linear_rate": float,
|
|
"confidence": str
|
|
} | None,
|
|
"is_behind_schedule": bool,
|
|
"is_on_track": bool
|
|
}
|
|
|
|
Time-Based Tracking (from Phase 0b Enhancement, 28.03.2026):
|
|
- expected_progress = (elapsed_days / total_days) * 100
|
|
- deviation = actual_progress - expected_progress
|
|
- Negative = behind schedule
|
|
- Positive = ahead of schedule
|
|
|
|
Auto-Population (from Phase 0b Enhancement, 28.03.2026):
|
|
- start_value automatically populated from first historical measurement
|
|
- start_date adjusted to actual measurement date
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def get_weighted_focus_areas(profile_id: str) -> list[dict]:
|
|
"""
|
|
User's weighted focus areas.
|
|
|
|
Returns:
|
|
[
|
|
{
|
|
"key": str,
|
|
"name": str,
|
|
"category": str,
|
|
"weight": float, # 0-100
|
|
"active_goals": int # count
|
|
},
|
|
...
|
|
]
|
|
|
|
Migration from Phase 0b:
|
|
- OLD: Part of placeholder resolution
|
|
- NEW: This function (clean data)
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
#### Module 7: correlations.py
|
|
|
|
**Pfad:** `backend/data_layer/correlations.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Lag-based correlations, plateau detection.
|
|
"""
|
|
|
|
def get_correlation_data(
|
|
profile_id: str,
|
|
metric_a: str,
|
|
metric_b: str,
|
|
days: int = 90,
|
|
max_lag: int = 7
|
|
) -> dict:
|
|
"""
|
|
Lag-based correlation between two metrics.
|
|
|
|
Args:
|
|
metric_a: e.g., "calorie_deficit"
|
|
metric_b: e.g., "weight_change"
|
|
max_lag: Maximum lag in days to test
|
|
|
|
Returns:
|
|
{
|
|
"correlation": float, # Pearson r at best lag
|
|
"best_lag": int, # Days of lag
|
|
"p_value": float,
|
|
"confidence": str,
|
|
"paired_points": int,
|
|
"interpretation": str # "strong"/"moderate"/"weak"/"none"
|
|
}
|
|
|
|
Confidence Rules:
|
|
- "high": >= 28 paired points
|
|
- "medium": >= 21 paired points
|
|
- "low": >= 14 paired points
|
|
- "insufficient": < 14 paired points
|
|
|
|
Interpretation:
|
|
- |r| > 0.7: "strong"
|
|
- |r| > 0.5: "moderate"
|
|
- |r| > 0.3: "weak"
|
|
- |r| <= 0.3: "none"
|
|
|
|
Migration:
|
|
- NEW function (was Phase 0b scope, moved to 0c)
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def detect_plateau(
|
|
profile_id: str,
|
|
metric: str,
|
|
days: int = 28
|
|
) -> dict:
|
|
"""
|
|
Detect if metric has plateaued despite expected change.
|
|
|
|
Returns:
|
|
{
|
|
"is_plateau": bool,
|
|
"metric": str,
|
|
"duration_days": int,
|
|
"expected_change": float,
|
|
"actual_change": float,
|
|
"confidence": str,
|
|
"possible_causes": [str, ...]
|
|
}
|
|
|
|
Plateau Criteria:
|
|
- Weight: < 0.2kg change in 28d despite calorie deficit
|
|
- Strength: No PR in 42d despite training
|
|
- VO2Max: < 1% change in 90d despite cardio training
|
|
|
|
Possible Causes:
|
|
- "metabolic_adaptation" (weight)
|
|
- "insufficient_stimulus" (strength/cardio)
|
|
- "overtraining" (all)
|
|
- "nutrition_inadequate" (strength)
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
#### Module 8: utils.py
|
|
|
|
**Pfad:** `backend/data_layer/utils.py`
|
|
|
|
**Funktionen:**
|
|
|
|
```python
|
|
"""
|
|
Shared utilities: confidence scoring, baseline calculations, outlier detection.
|
|
"""
|
|
|
|
def calculate_confidence(
|
|
data_points: int,
|
|
days_requested: int,
|
|
metric_type: str = "general"
|
|
) -> str:
|
|
"""
|
|
Determine confidence level based on data availability.
|
|
|
|
Args:
|
|
data_points: Number of actual data points
|
|
days_requested: Number of days in analysis window
|
|
metric_type: "general" | "correlation" | "trend"
|
|
|
|
Returns:
|
|
"high" | "medium" | "low" | "insufficient"
|
|
|
|
Rules:
|
|
General (days_requested):
|
|
7d: high >= 4, medium >= 3, low >= 2
|
|
28d: high >= 18, medium >= 12, low >= 8
|
|
90d: high >= 60, medium >= 40, low >= 25
|
|
|
|
Correlation:
|
|
high >= 28, medium >= 21, low >= 14
|
|
|
|
Trend:
|
|
high >= (days * 0.7), medium >= (days * 0.5), low >= (days * 0.3)
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def calculate_baseline(
|
|
values: list[float],
|
|
method: str = "median"
|
|
) -> float:
|
|
"""
|
|
Calculate baseline value.
|
|
|
|
Args:
|
|
values: List of measurements
|
|
method: "median" | "mean" | "trimmed_mean"
|
|
|
|
Returns:
|
|
Baseline value (float)
|
|
|
|
Trimmed Mean:
|
|
- Remove top/bottom 10% of values
|
|
- Calculate mean of remaining
|
|
- More robust than mean, less aggressive than median
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def detect_outliers(
|
|
values: list[float],
|
|
method: str = "iqr"
|
|
) -> list[int]:
|
|
"""
|
|
Detect outlier indices.
|
|
|
|
Args:
|
|
values: List of measurements
|
|
method: "iqr" | "zscore" | "mad"
|
|
|
|
Returns:
|
|
List of outlier indices
|
|
|
|
IQR Method (recommended):
|
|
- Q1 = 25th percentile
|
|
- Q3 = 75th percentile
|
|
- IQR = Q3 - Q1
|
|
- Outliers: < Q1 - 1.5*IQR OR > Q3 + 1.5*IQR
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def calculate_linear_regression(
|
|
x: list[float],
|
|
y: list[float]
|
|
) -> dict:
|
|
"""
|
|
Simple linear regression.
|
|
|
|
Returns:
|
|
{
|
|
"slope": float,
|
|
"intercept": float,
|
|
"r_squared": float,
|
|
"p_value": float
|
|
}
|
|
"""
|
|
# Implementation here
|
|
|
|
|
|
def serialize_dates(obj):
|
|
"""
|
|
Convert date/datetime objects to ISO strings for JSON serialization.
|
|
|
|
(Already exists in routers/goals.py - move here for reusability)
|
|
|
|
Migration from Phase 0b Enhancement (28.03.2026):
|
|
- Learned from bug: Python date objects don't auto-serialize
|
|
- Solution: Recursive conversion to ISO strings
|
|
"""
|
|
# Implementation here
|
|
```
|
|
|
|
---
|
|
|
|
### Step 2: Placeholder Resolver Refactoring (3-4h)
|
|
|
|
**Pfad:** `backend/placeholder_resolver.py`
|
|
|
|
**Ziel:** Von ~1100 Zeilen zu ~400 Zeilen durch Nutzung des Data Layer.
|
|
|
|
**Muster (für alle Platzhalter):**
|
|
|
|
```python
|
|
# ── ALTE IMPLEMENTIERUNG (Phase 0b) ──────────────────────────────
|
|
def resolve_weight_28d_trend_slope(profile_id: str) -> str:
|
|
"""Returns kg/Woche slope for KI prompts"""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
|
|
# 30 Zeilen SQL queries
|
|
cur.execute("""
|
|
SELECT date, weight
|
|
FROM weight_log
|
|
WHERE profile_id = %s
|
|
AND date >= NOW() - INTERVAL '28 days'
|
|
ORDER BY date
|
|
""", (profile_id,))
|
|
rows = cur.fetchall()
|
|
|
|
if len(rows) < 18:
|
|
return "Nicht genug Daten"
|
|
|
|
# 15 Zeilen Berechnungslogik
|
|
x = [(row[0] - rows[0][0]).days for row in rows]
|
|
y = [row[1] for row in rows]
|
|
|
|
n = len(x)
|
|
sum_x = sum(x)
|
|
sum_y = sum(y)
|
|
sum_xy = sum(xi * yi for xi, yi in zip(x, y))
|
|
sum_x2 = sum(xi ** 2 for xi in x)
|
|
|
|
slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x ** 2)
|
|
slope_per_week = slope * 7
|
|
|
|
# Formatierung
|
|
return f"{slope_per_week:.2f} kg/Woche"
|
|
|
|
|
|
# ── NEUE IMPLEMENTIERUNG (Phase 0c) ──────────────────────────────
|
|
from data_layer.body_metrics import get_weight_trend_data
|
|
|
|
def resolve_weight_28d_trend_slope(profile_id: str) -> str:
|
|
"""Returns kg/Woche slope for KI prompts"""
|
|
data = get_weight_trend_data(profile_id, days=28)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "Nicht genug Daten"
|
|
|
|
return f"{data['slope_28d']:.2f} kg/Woche"
|
|
```
|
|
|
|
**Alle zu refactorierenden Platzhalter:**
|
|
|
|
```python
|
|
# KÖRPER
|
|
resolve_weight_28d_trend_slope() → get_weight_trend_data()
|
|
resolve_weight_7d_rolling_median() → get_weight_trend_data()
|
|
resolve_fm_28d_delta() → get_body_composition_data()
|
|
resolve_lbm_28d_delta() → get_body_composition_data()
|
|
resolve_recomposition_score() → get_body_composition_data()
|
|
resolve_circ_summary() → get_circumference_summary()
|
|
|
|
# ERNÄHRUNG
|
|
resolve_protein_g_per_kg() → get_protein_adequacy_data()
|
|
resolve_protein_adequacy() → get_protein_adequacy_data()
|
|
resolve_nutrition_adherence_score() → get_protein_adequacy_data()
|
|
resolve_energy_balance() → get_energy_balance_data()
|
|
|
|
# AKTIVITÄT
|
|
resolve_training_volume_28d() → get_training_volume_data()
|
|
resolve_activity_quality_avg() → get_activity_quality_distribution()
|
|
resolve_activity_monotony() → get_training_volume_data()
|
|
|
|
# RECOVERY
|
|
resolve_recovery_score() → get_recovery_score_data()
|
|
resolve_sleep_regularity() → get_sleep_regularity_data()
|
|
resolve_sleep_debt_hours() → get_sleep_regularity_data()
|
|
|
|
# GOALS (JSON Platzhalter)
|
|
resolve_active_goals() → get_active_goals() + json.dumps()
|
|
resolve_focus_areas() → get_weighted_focus_areas() + json.dumps()
|
|
|
|
# HEALTH
|
|
resolve_bp_avg() → get_blood_pressure_data()
|
|
resolve_vitals_baseline() → get_vitals_baseline_data()
|
|
```
|
|
|
|
**Platzhalter-Mapping aktualisieren:**
|
|
|
|
```python
|
|
# backend/placeholder_resolver.py
|
|
|
|
PLACEHOLDER_FUNCTIONS = {
|
|
# ... existing placeholders ...
|
|
|
|
# Phase 0c: Refactored to use data_layer
|
|
"weight_28d_trend_slope": resolve_weight_28d_trend_slope,
|
|
"weight_7d_rolling_median": resolve_weight_7d_rolling_median,
|
|
"fm_28d_delta": resolve_fm_28d_delta,
|
|
# ... etc.
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 3: Charts Router erstellen (6-8h)
|
|
|
|
**Pfad:** `backend/routers/charts.py`
|
|
|
|
**Struktur:**
|
|
|
|
```python
|
|
"""
|
|
Chart data endpoints for frontend visualizations.
|
|
|
|
All endpoints use data_layer functions and return structured JSON
|
|
compatible with Chart.js / Recharts.
|
|
|
|
Implements charts from konzept_diagramme_auswertungen_v2.md:
|
|
- K1-K10: Body charts
|
|
- E1-E4: Nutrition charts
|
|
- A1-A5: Activity charts
|
|
- V1-V3: Vitals charts
|
|
- R1-R2: Recovery charts
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from auth import require_auth
|
|
from data_layer.body_metrics import (
|
|
get_weight_trend_data,
|
|
get_body_composition_data,
|
|
get_circumference_summary
|
|
)
|
|
from data_layer.nutrition_metrics import (
|
|
get_protein_adequacy_data,
|
|
get_energy_balance_data,
|
|
get_macro_distribution_data
|
|
)
|
|
from data_layer.activity_metrics import (
|
|
get_training_volume_data,
|
|
get_activity_quality_distribution,
|
|
get_ability_balance_data
|
|
)
|
|
from data_layer.recovery_metrics import (
|
|
get_recovery_score_data,
|
|
get_sleep_regularity_data,
|
|
get_vitals_baseline_data
|
|
)
|
|
from data_layer.health_metrics import (
|
|
get_blood_pressure_data,
|
|
get_health_stability_score
|
|
)
|
|
from data_layer.correlations import (
|
|
get_correlation_data,
|
|
detect_plateau
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/charts", tags=["charts"])
|
|
|
|
|
|
# ── BODY CHARTS (K1-K10) ────────────────────────────────────────
|
|
|
|
@router.get("/weight-trend")
|
|
def weight_trend_chart(
|
|
days: int = Query(90, ge=7, le=365),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
K1: Weight Trend + Goal Projection
|
|
|
|
Returns Chart.js compatible data structure.
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_weight_trend_data(pid, days=days)
|
|
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {
|
|
"labels": [str(d[0]) for d in data['raw_values']],
|
|
"datasets": [
|
|
{
|
|
"label": "Rohwerte",
|
|
"data": [d[1] for d in data['raw_values']],
|
|
"type": "scatter",
|
|
"backgroundColor": "rgba(29, 158, 117, 0.5)",
|
|
"borderColor": "rgba(29, 158, 117, 0.5)",
|
|
"pointRadius": 4
|
|
},
|
|
{
|
|
"label": "7d Trend (Median)",
|
|
"data": [d[1] for d in data['rolling_median_7d']],
|
|
"type": "line",
|
|
"borderColor": "#1D9E75",
|
|
"borderWidth": 3,
|
|
"fill": False,
|
|
"pointRadius": 0
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"slope_7d": data['slope_7d'],
|
|
"slope_28d": data['slope_28d'],
|
|
"slope_90d": data['slope_90d'],
|
|
"confidence": data['confidence'],
|
|
"projection": data['projection']
|
|
},
|
|
"options": {
|
|
"title": "Gewichtstrend + Zielprojektion",
|
|
"yAxisLabel": "Gewicht (kg)",
|
|
"xAxisLabel": "Datum"
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/body-composition")
|
|
def body_composition_chart(
|
|
days: int = Query(90, ge=7, le=365),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
K2: Fat Mass / Lean Mass Trend
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_body_composition_data(pid, days=days)
|
|
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {
|
|
"labels": [str(d) for d in data['dates']],
|
|
"datasets": [
|
|
{
|
|
"label": "Fettmasse (kg)",
|
|
"data": data['fat_mass'],
|
|
"borderColor": "#D85A30",
|
|
"backgroundColor": "rgba(216, 90, 48, 0.1)",
|
|
"fill": True
|
|
},
|
|
{
|
|
"label": "Magermasse (kg)",
|
|
"data": data['lean_mass'],
|
|
"borderColor": "#1D9E75",
|
|
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
"fill": True
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"fm_delta_28d": data['fm_delta_28d'],
|
|
"lbm_delta_28d": data['lbm_delta_28d'],
|
|
"recomposition_score": data['recomposition_score'],
|
|
"confidence": data['confidence']
|
|
},
|
|
"options": {
|
|
"title": "Körperkomposition",
|
|
"yAxisLabel": "Masse (kg)"
|
|
}
|
|
}
|
|
|
|
|
|
# ── NUTRITION CHARTS (E1-E4) ────────────────────────────────────
|
|
|
|
@router.get("/protein-adequacy")
|
|
def protein_adequacy_chart(
|
|
days: int = Query(28, ge=7, le=90),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
E1: Protein Intake vs. Target
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_protein_adequacy_data(pid, days=days)
|
|
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {
|
|
"labels": [str(d[0]) for d in data['daily_values']],
|
|
"datasets": [
|
|
{
|
|
"label": "Protein (g)",
|
|
"data": [d[1] for d in data['daily_values']],
|
|
"type": "bar",
|
|
"backgroundColor": "rgba(29, 158, 117, 0.7)"
|
|
},
|
|
{
|
|
"label": "Ziel (g)",
|
|
"data": [d[2] for d in data['daily_values']],
|
|
"type": "line",
|
|
"borderColor": "#D85A30",
|
|
"borderDash": [5, 5],
|
|
"fill": False
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"avg_protein_g": data['avg_protein_g'],
|
|
"target_protein_g": data['target_protein_g'],
|
|
"adherence_pct": data['adherence_pct'],
|
|
"adherence_score": data['adherence_score'],
|
|
"confidence": data['confidence']
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/energy-balance")
|
|
def energy_balance_chart(
|
|
days: int = Query(28, ge=7, le=90),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
E2: Energy Balance (Intake - Activity)
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_energy_balance_data(pid, days=days)
|
|
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {
|
|
"labels": [str(d[0]) for d in data['daily_values']],
|
|
"datasets": [
|
|
{
|
|
"label": "Aufnahme (kcal)",
|
|
"data": [d[1] for d in data['daily_values']],
|
|
"borderColor": "#1D9E75",
|
|
"fill": False
|
|
},
|
|
{
|
|
"label": "Verbrauch (kcal)",
|
|
"data": [d[2] for d in data['daily_values']],
|
|
"borderColor": "#D85A30",
|
|
"fill": False
|
|
},
|
|
{
|
|
"label": "Netto (kcal)",
|
|
"data": [d[3] for d in data['daily_values']],
|
|
"borderColor": "#666",
|
|
"borderDash": [5, 5],
|
|
"fill": False
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"avg_net": data['avg_net'],
|
|
"energy_availability": data['energy_availability'],
|
|
"red_s_warning": data['red_s_warning'],
|
|
"confidence": data['confidence']
|
|
}
|
|
}
|
|
|
|
|
|
# ── ACTIVITY CHARTS (A1-A5) ─────────────────────────────────────
|
|
|
|
@router.get("/training-volume")
|
|
def training_volume_chart(
|
|
weeks: int = Query(4, ge=1, le=12),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
A1: Training Volume per Week
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_training_volume_data(pid, weeks=weeks)
|
|
|
|
return {
|
|
"chart_type": "bar",
|
|
"data": {
|
|
"labels": [str(w['week_start']) for w in data['weekly_totals']],
|
|
"datasets": [
|
|
{
|
|
"label": "Dauer (min)",
|
|
"data": [w['duration_min'] for w in data['weekly_totals']],
|
|
"backgroundColor": "rgba(29, 158, 117, 0.7)"
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"by_type": data['by_type'],
|
|
"avg_quality": data['avg_quality'],
|
|
"monotony": data['monotony'],
|
|
"strain": data['strain'],
|
|
"confidence": data['confidence']
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/ability-balance")
|
|
def ability_balance_chart(
|
|
weeks: int = Query(4, ge=1, le=12),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
A5: Ability Balance Radar
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_ability_balance_data(pid, weeks=weeks)
|
|
|
|
return {
|
|
"chart_type": "radar",
|
|
"data": {
|
|
"labels": ["Kraft", "Ausdauer", "Mobilität", "Koordination", "Mental"],
|
|
"datasets": [
|
|
{
|
|
"label": "Aktuelle Balance",
|
|
"data": [
|
|
data['abilities']['strength'],
|
|
data['abilities']['cardio'],
|
|
data['abilities']['mobility'],
|
|
data['abilities']['coordination'],
|
|
data['abilities']['mental']
|
|
],
|
|
"backgroundColor": "rgba(29, 158, 117, 0.2)",
|
|
"borderColor": "#1D9E75",
|
|
"pointBackgroundColor": "#1D9E75"
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"balance_score": data['balance_score'],
|
|
"imbalances": data['imbalances'],
|
|
"confidence": data['confidence']
|
|
}
|
|
}
|
|
|
|
|
|
# ── RECOVERY CHARTS (R1-R2) ─────────────────────────────────────
|
|
|
|
@router.get("/recovery-score")
|
|
def recovery_score_chart(
|
|
days: int = Query(7, ge=7, le=28),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
R1: Recovery Score Breakdown
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_recovery_score_data(pid, days=days)
|
|
|
|
return {
|
|
"chart_type": "bar_horizontal",
|
|
"data": {
|
|
"labels": ["RHR", "HRV", "Sleep", "Rest Compliance"],
|
|
"datasets": [
|
|
{
|
|
"label": "Score",
|
|
"data": [
|
|
data['components']['rhr']['score'],
|
|
data['components']['hrv']['score'],
|
|
data['components']['sleep']['score'],
|
|
data['components']['rest_compliance']['score']
|
|
],
|
|
"backgroundColor": [
|
|
"#1D9E75",
|
|
"#1D9E75",
|
|
"#1D9E75",
|
|
"#1D9E75"
|
|
]
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"total_score": data['score'],
|
|
"trend": data['trend'],
|
|
"confidence": data['confidence']
|
|
}
|
|
}
|
|
|
|
|
|
# ── VITALS CHARTS (V1-V3) ───────────────────────────────────────
|
|
|
|
@router.get("/blood-pressure")
|
|
def blood_pressure_chart(
|
|
days: int = Query(28, ge=7, le=90),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
V1: Blood Pressure Trend
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_blood_pressure_data(pid, days=days)
|
|
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {
|
|
"labels": [str(m['date']) for m in data['measurements']],
|
|
"datasets": [
|
|
{
|
|
"label": "Systolisch (mmHg)",
|
|
"data": [m['systolic'] for m in data['measurements']],
|
|
"borderColor": "#D85A30",
|
|
"fill": False
|
|
},
|
|
{
|
|
"label": "Diastolisch (mmHg)",
|
|
"data": [m['diastolic'] for m in data['measurements']],
|
|
"borderColor": "#1D9E75",
|
|
"fill": False
|
|
}
|
|
]
|
|
},
|
|
"metadata": {
|
|
"avg_systolic": data['avg_systolic'],
|
|
"avg_diastolic": data['avg_diastolic'],
|
|
"risk_level": data['risk_level'],
|
|
"confidence": data['confidence']
|
|
}
|
|
}
|
|
|
|
|
|
# ── CORRELATIONS ────────────────────────────────────────────────
|
|
|
|
@router.get("/correlation")
|
|
def correlation_chart(
|
|
metric_a: str = Query(..., description="e.g., 'calorie_deficit'"),
|
|
metric_b: str = Query(..., description="e.g., 'weight_change'"),
|
|
days: int = Query(90, ge=28, le=365),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Lag-based correlation between two metrics.
|
|
"""
|
|
pid = session['profile_id']
|
|
data = get_correlation_data(pid, metric_a, metric_b, days=days)
|
|
|
|
return {
|
|
"chart_type": "scatter",
|
|
"data": {
|
|
# Scatter plot data would go here
|
|
# (implementation depends on metric types)
|
|
},
|
|
"metadata": {
|
|
"correlation": data['correlation'],
|
|
"best_lag": data['best_lag'],
|
|
"p_value": data['p_value'],
|
|
"interpretation": data['interpretation'],
|
|
"confidence": data['confidence']
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/plateau-detection")
|
|
def plateau_detection(
|
|
metric: str = Query(..., description="e.g., 'weight', 'vo2max'"),
|
|
days: int = Query(28, ge=14, le=90),
|
|
session: dict = Depends(require_auth)
|
|
):
|
|
"""
|
|
Detect if metric has plateaued.
|
|
"""
|
|
pid = session['profile_id']
|
|
data = detect_plateau(pid, metric, days=days)
|
|
|
|
return {
|
|
"is_plateau": data['is_plateau'],
|
|
"metric": data['metric'],
|
|
"duration_days": data['duration_days'],
|
|
"expected_change": data['expected_change'],
|
|
"actual_change": data['actual_change'],
|
|
"possible_causes": data['possible_causes'],
|
|
"confidence": data['confidence']
|
|
}
|
|
```
|
|
|
|
**Router in main.py registrieren:**
|
|
|
|
```python
|
|
# backend/main.py
|
|
|
|
from routers import charts # NEU
|
|
|
|
# ... existing routers ...
|
|
|
|
app.include_router(charts.router) # NEU
|
|
```
|
|
|
|
---
|
|
|
|
### Step 4: goal_utils.py Refactoring (1h)
|
|
|
|
**Pfad:** `backend/goal_utils.py`
|
|
|
|
**Änderungen:**
|
|
|
|
```python
|
|
# ALT:
|
|
def get_active_goals(profile_id: str) -> list[dict]:
|
|
# 50 Zeilen SQL + Logik
|
|
...
|
|
|
|
# NEU:
|
|
from data_layer.goals import get_active_goals as _get_active_goals
|
|
|
|
def get_active_goals(profile_id: str) -> list[dict]:
|
|
"""
|
|
Wrapper for backwards compatibility.
|
|
|
|
Phase 0c: Delegates to data_layer.goals.get_active_goals()
|
|
"""
|
|
return _get_active_goals(profile_id)
|
|
|
|
|
|
# map_focus_to_score_components() BLEIBT HIER
|
|
# → Ist Score-Gewichtung, nicht Datenermittlung
|
|
```
|
|
|
|
---
|
|
|
|
### Step 5: Testing (2-3h)
|
|
|
|
**Test-Strategie:**
|
|
|
|
#### Unit Tests für Data Layer
|
|
|
|
**Pfad:** `backend/tests/test_data_layer.py` (NEU)
|
|
|
|
```python
|
|
import pytest
|
|
from data_layer.body_metrics import get_weight_trend_data
|
|
from data_layer.utils import calculate_confidence
|
|
|
|
def test_weight_trend_data_sufficient():
|
|
"""Test with sufficient data points"""
|
|
data = get_weight_trend_data("test_profile_1", days=28)
|
|
|
|
assert data['confidence'] in ['high', 'medium', 'low', 'insufficient']
|
|
assert 'raw_values' in data
|
|
assert 'slope_28d' in data
|
|
assert len(data['raw_values']) >= 0
|
|
|
|
def test_weight_trend_data_insufficient():
|
|
"""Test with insufficient data points"""
|
|
data = get_weight_trend_data("profile_no_data", days=28)
|
|
|
|
assert data['confidence'] == 'insufficient'
|
|
|
|
def test_confidence_calculation():
|
|
"""Test confidence scoring logic"""
|
|
assert calculate_confidence(20, 28, "general") == "high"
|
|
assert calculate_confidence(15, 28, "general") == "medium"
|
|
assert calculate_confidence(5, 28, "general") == "low"
|
|
assert calculate_confidence(2, 28, "general") == "insufficient"
|
|
|
|
# ... weitere tests ...
|
|
```
|
|
|
|
#### Integration Tests
|
|
|
|
**Pfad:** `backend/tests/test_charts_api.py` (NEU)
|
|
|
|
```python
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from main import app
|
|
|
|
client = TestClient(app)
|
|
|
|
def test_weight_trend_chart_endpoint(auth_token):
|
|
"""Test weight trend chart endpoint"""
|
|
response = client.get(
|
|
"/api/charts/weight-trend?days=90",
|
|
headers={"X-Auth-Token": auth_token}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert 'chart_type' in data
|
|
assert data['chart_type'] == 'line'
|
|
assert 'data' in data
|
|
assert 'metadata' in data
|
|
assert 'confidence' in data['metadata']
|
|
|
|
# ... weitere tests ...
|
|
```
|
|
|
|
#### Manual Testing Checklist
|
|
|
|
```
|
|
Data Layer:
|
|
[ ] get_weight_trend_data() mit verschiedenen days-Parametern
|
|
[ ] get_body_composition_data() mit realen Profil-Daten
|
|
[ ] get_protein_adequacy_data() mit goal_mode Variationen
|
|
[ ] get_recovery_score_data() mit/ohne vollständige Vitals
|
|
[ ] Confidence scoring bei verschiedenen Datenmengen
|
|
[ ] Outlier detection funktioniert korrekt
|
|
[ ] Baseline calculations korrekt
|
|
|
|
KI Layer (Refactored):
|
|
[ ] Alle bestehenden Platzhalter funktionieren weiter
|
|
[ ] Keine Regression in KI-Prompt-Outputs
|
|
[ ] {{active_goals}} und {{focus_areas}} JSON korrekt
|
|
|
|
Charts API:
|
|
[ ] Alle 10+ Chart-Endpoints erreichbar
|
|
[ ] JSON-Struktur Chart.js-kompatibel
|
|
[ ] Metadata vollständig
|
|
[ ] Fehlerbehandlung bei fehlenden Daten
|
|
[ ] Auth funktioniert (require_auth)
|
|
|
|
Performance:
|
|
[ ] Keine N+1 Queries
|
|
[ ] Response Times < 500ms
|
|
[ ] Kein Memory Leak bei großen Datenmengen
|
|
```
|
|
|
|
---
|
|
|
|
### Step 6: Dokumentation (1-2h)
|
|
|
|
#### 1. Architecture Documentation
|
|
|
|
**Pfad:** `.claude/docs/technical/DATA_LAYER_ARCHITECTURE.md` (NEU)
|
|
|
|
```markdown
|
|
# Data Layer Architecture
|
|
|
|
## Overview
|
|
|
|
Three-layer architecture for data retrieval, calculation, and presentation.
|
|
|
|
## Layers
|
|
|
|
### Layer 1: Data Layer (`backend/data_layer/`)
|
|
- **Purpose:** Pure data retrieval + calculation logic
|
|
- **Returns:** Structured data (dict/list/float)
|
|
- **No formatting:** No strings, no KI-specific formatting
|
|
- **Testable:** Unit tests for each function
|
|
- **Reusable:** Used by both KI layer and visualization layer
|
|
|
|
### Layer 2a: KI Layer (`backend/placeholder_resolver.py`)
|
|
- **Purpose:** Format data for KI prompts
|
|
- **Input:** Data from data_layer
|
|
- **Output:** Formatted strings
|
|
- **Example:** `"0.23 kg/Woche"`, `"78/100"`, JSON strings
|
|
|
|
### Layer 2b: Visualization Layer (`backend/routers/charts.py`)
|
|
- **Purpose:** Provide data for frontend charts
|
|
- **Input:** Data from data_layer
|
|
- **Output:** Chart.js compatible JSON
|
|
- **Example:** `{"chart_type": "line", "data": {...}, "metadata": {...}}`
|
|
|
|
## Function Naming Conventions
|
|
|
|
- Data Layer: `get_<metric>_data()` → returns dict
|
|
- KI Layer: `resolve_<placeholder>()` → returns str
|
|
- Charts: `<metric>_chart()` → returns dict (Chart.js format)
|
|
|
|
## Migration from Phase 0b
|
|
|
|
All placeholder functions in `placeholder_resolver.py` that contained
|
|
inline SQL queries and calculations have been moved to `data_layer/`.
|
|
|
|
The placeholder functions now simply call data_layer functions and format
|
|
the result for KI consumption.
|
|
|
|
...
|
|
```
|
|
|
|
#### 2. API Documentation
|
|
|
|
**Pfad:** `docs/api/CHARTS_API.md` (NEU)
|
|
|
|
```markdown
|
|
# Charts API Reference
|
|
|
|
## Base URL
|
|
|
|
`/api/charts`
|
|
|
|
## Authentication
|
|
|
|
All endpoints require authentication via `X-Auth-Token` header.
|
|
|
|
## Endpoints
|
|
|
|
### Body Charts
|
|
|
|
#### GET /charts/weight-trend
|
|
|
|
Weight trend with goal projections.
|
|
|
|
**Parameters:**
|
|
- `days` (query, int, optional): Analysis window (default: 90, range: 7-365)
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"chart_type": "line",
|
|
"data": {
|
|
"labels": ["2026-01-01", "2026-01-02", ...],
|
|
"datasets": [...]
|
|
},
|
|
"metadata": {
|
|
"slope_28d": 0.23,
|
|
"confidence": "high",
|
|
...
|
|
}
|
|
}
|
|
```
|
|
|
|
...
|
|
```
|
|
|
|
#### 3. Update CLAUDE.md
|
|
|
|
**Pfad:** `CLAUDE.md`
|
|
|
|
```markdown
|
|
### Phase 0c Completion (29-30.03.2026) 🏗️
|
|
- ✅ **Multi-Layer Data Architecture:**
|
|
- Data Layer: 8 modules, 50+ functions
|
|
- KI Layer: Refactored placeholder_resolver.py
|
|
- Visualization Layer: charts.py router
|
|
- ✅ **Charts API:** 10+ endpoints für Diagramme
|
|
- ✅ **Separation of Concerns:** Single Source of Truth
|
|
- ✅ **Testing:** Unit tests für Data Layer
|
|
- ✅ **Dokumentation:** Architecture + API docs
|
|
|
|
**Betroffene Dateien:**
|
|
- `backend/data_layer/*` - NEU (8 Module)
|
|
- `backend/routers/charts.py` - NEU
|
|
- `backend/placeholder_resolver.py` - REFACTORED
|
|
- `backend/goal_utils.py` - REFACTORED
|
|
```
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
Phase 0c ist abgeschlossen, wenn:
|
|
|
|
### Funktional
|
|
- ✅ Alle 50+ Data Layer Funktionen implementiert
|
|
- ✅ Alle bestehenden Platzhalter funktionieren weiter (keine Regression)
|
|
- ✅ Mindestens 10 Chart-Endpoints verfügbar
|
|
- ✅ goal_utils.py nutzt data_layer.goals
|
|
- ✅ Alle Charts liefern Chart.js-kompatible Daten
|
|
|
|
### Technisch
|
|
- ✅ Keine Code-Duplikation zwischen KI Layer und Charts
|
|
- ✅ Data Layer hat Unit Tests (>80% coverage für utils.py)
|
|
- ✅ Confidence scoring funktioniert korrekt
|
|
- ✅ Outlier detection funktioniert
|
|
- ✅ Alle Decimal → Float Conversions korrekt
|
|
|
|
### Qualität
|
|
- ✅ Keine SQL queries in placeholder_resolver.py
|
|
- ✅ Keine SQL queries in routers/charts.py
|
|
- ✅ Alle Funktionen haben Type Hints
|
|
- ✅ Alle Funktionen haben Docstrings
|
|
- ✅ Migrations laufen erfolgreich
|
|
|
|
### Dokumentation
|
|
- ✅ DATA_LAYER_ARCHITECTURE.md erstellt
|
|
- ✅ CHARTS_API.md erstellt
|
|
- ✅ CLAUDE.md aktualisiert
|
|
- ✅ Dieses Issue-Dokument vollständig
|
|
|
|
---
|
|
|
|
## Common Pitfalls (Learnings from Phase 0b)
|
|
|
|
### 1. Decimal → Float Conversion
|
|
```python
|
|
# ❌ WRONG:
|
|
protein = row['protein'] # Decimal object
|
|
|
|
# ✅ CORRECT:
|
|
protein = float(row['protein']) if row['protein'] else 0.0
|
|
```
|
|
|
|
### 2. Date Serialization
|
|
```python
|
|
# ❌ WRONG:
|
|
return {"date": date_obj} # Not JSON serializable
|
|
|
|
# ✅ CORRECT:
|
|
from data_layer.utils import serialize_dates
|
|
return serialize_dates({"date": date_obj})
|
|
```
|
|
|
|
### 3. Dict Access Safety
|
|
```python
|
|
# ❌ WRONG:
|
|
value = data['key'] # KeyError if missing
|
|
|
|
# ✅ CORRECT:
|
|
value = data.get('key', default_value)
|
|
```
|
|
|
|
### 4. Column Name Consistency
|
|
```python
|
|
# ❌ WRONG (assumed name):
|
|
SELECT bf_jpl FROM caliper_log
|
|
|
|
# ✅ CORRECT (check schema):
|
|
SELECT body_fat_pct FROM caliper_log
|
|
```
|
|
|
|
### 5. Confidence Calculation
|
|
```python
|
|
# ✅ ALWAYS use utils.calculate_confidence()
|
|
# DON'T hardcode confidence logic
|
|
```
|
|
|
|
### 6. SQL Query Structure
|
|
```python
|
|
# ✅ Use parameter binding:
|
|
cur.execute("SELECT * FROM t WHERE id = %s", (id,))
|
|
|
|
# ❌ NEVER string concatenation:
|
|
cur.execute(f"SELECT * FROM t WHERE id = {id}")
|
|
```
|
|
|
|
---
|
|
|
|
## Timeline
|
|
|
|
**Geschätzte Dauer:** 20-27h (5-7 Tage bei 4h/Tag)
|
|
|
|
| Tag | Aufgabe | Stunden |
|
|
|-----|---------|---------|
|
|
| 1-2 | Data Layer Module 1-4 (body, nutrition, activity, recovery) | 6-8h |
|
|
| 3 | Data Layer Module 5-8 (health, goals, correlations, utils) | 4-5h |
|
|
| 4 | Placeholder Resolver Refactoring | 3-4h |
|
|
| 5 | Charts Router (10+ endpoints) | 6-8h |
|
|
| 6 | goal_utils.py Refactoring + Testing | 3-4h |
|
|
| 7 | Dokumentation + Final Testing | 2-3h |
|
|
|
|
**Total:** 24-32h (realistisch: 5-7 Tage)
|
|
|
|
---
|
|
|
|
## Next Steps After Phase 0c
|
|
|
|
**Phase 1: Frontend Charts (2-3 Wochen)**
|
|
- Chart-Komponenten in React implementieren
|
|
- Integration der Charts API
|
|
- Dashboard-Layout mit Charts
|
|
|
|
**Phase 2: Caching Layer**
|
|
- Redis für häufige Abfragen
|
|
- Cache invalidation strategy
|
|
|
|
**Phase 3: Advanced Analytics**
|
|
- Machine Learning für Projektionen
|
|
- Anomaly Detection mit ML
|
|
- Personalisierte Empfehlungen
|
|
|
|
---
|
|
|
|
**Erstellt:** 28. März 2026
|
|
**Autor:** Claude Sonnet 4.5
|
|
**Status:** Ready for Implementation
|
|
**Gitea Issue:** #53 (zu erstellen)
|