2145 lines
60 KiB
Markdown
2145 lines
60 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
|
||
|
||
**Kanonische Pfade (Abgleich Repository Stand 2026-04):**
|
||
- Fachliche Sicht: `.claude/docs/functional/DATA_ARCHITECTURE.md`
|
||
- Technische Erweiterung / Layer-Leitfaden: `.claude/docs/technical/DATA_LAYER_EXTENSION_GUIDE.md`
|
||
- Code: `backend/data_layer/`, Chart-Endpunkte: `backend/routers/charts.py`
|
||
|
||
*(Historischer Platzhalter „DATA_LAYER_ARCHITECTURE.md“ – Inhalt landete in den obigen Dateien.)*
|
||
|
||
```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
|
||
- ✅ Fachliche Datenarchitektur: `.claude/docs/functional/DATA_ARCHITECTURE.md`
|
||
- ✅ Data-Layer-Leitfaden: `.claude/docs/technical/DATA_LAYER_EXTENSION_GUIDE.md`
|
||
- ✅ Chart-Endpunkte: in `technical/API_REFERENCE.md` / Router `charts.py` (kein separates CHARTS_API.md)
|
||
- ✅ 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)
|