mitai-jinkendo/docs/issues/issue-53-phase-0c-multi-layer-architecture.md

2145 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)