mitai-jinkendo/docs/issues/issue-53-phase-0c-multi-layer-architecture.md
Lars f81171a1f5
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
docs: Phase 0c completion + new issue #55
- Mark issue #53 as completed
- Create issue #55: Dynamic Aggregation Methods
- Update CLAUDE.md with Phase 0c achievements
- Document 97 migrated functions + 20 new chart endpoints
2026-03-28 22:22:16 +01:00

59 KiB

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:

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:

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:

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)

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

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

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

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

"""
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:

"""
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:

"""
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:

"""
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:

"""
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:

"""
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:

"""
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:

"""
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):

# ── 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:

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

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

"""
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:

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

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

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)

import pytest
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_weight_trend_chart_endpoint(auth_token):
    """Test weight trend chart endpoint"""
    response = client.get(
        "/api/charts/weight-trend?days=90",
        headers={"X-Auth-Token": auth_token}
    )

    assert response.status_code == 200
    data = response.json()

    assert 'chart_type' in data
    assert data['chart_type'] == 'line'
    assert 'data' in data
    assert 'metadata' in data
    assert 'confidence' in data['metadata']

# ... weitere tests ...

Manual Testing Checklist

Data Layer:
[ ] get_weight_trend_data() mit verschiedenen days-Parametern
[ ] get_body_composition_data() mit realen Profil-Daten
[ ] get_protein_adequacy_data() mit goal_mode Variationen
[ ] get_recovery_score_data() mit/ohne vollständige Vitals
[ ] Confidence scoring bei verschiedenen Datenmengen
[ ] Outlier detection funktioniert korrekt
[ ] Baseline calculations korrekt

KI Layer (Refactored):
[ ] Alle bestehenden Platzhalter funktionieren weiter
[ ] Keine Regression in KI-Prompt-Outputs
[ ] {{active_goals}} und {{focus_areas}} JSON korrekt

Charts API:
[ ] Alle 10+ Chart-Endpoints erreichbar
[ ] JSON-Struktur Chart.js-kompatibel
[ ] Metadata vollständig
[ ] Fehlerbehandlung bei fehlenden Daten
[ ] Auth funktioniert (require_auth)

Performance:
[ ] Keine N+1 Queries
[ ] Response Times < 500ms
[ ] Kein Memory Leak bei großen Datenmengen

Step 6: Dokumentation (1-2h)

1. Architecture Documentation

Pfad: .claude/docs/technical/DATA_LAYER_ARCHITECTURE.md (NEU)

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

# Charts API Reference

## Base URL

`/api/charts`

## Authentication

All endpoints require authentication via `X-Auth-Token` header.

## Endpoints

### Body Charts

#### GET /charts/weight-trend

Weight trend with goal projections.

**Parameters:**
- `days` (query, int, optional): Analysis window (default: 90, range: 7-365)

**Response:**
```json
{
  "chart_type": "line",
  "data": {
    "labels": ["2026-01-01", "2026-01-02", ...],
    "datasets": [...]
  },
  "metadata": {
    "slope_28d": 0.23,
    "confidence": "high",
    ...
  }
}

...


#### 3. Update CLAUDE.md

**Pfad:** `CLAUDE.md`

```markdown
### Phase 0c Completion (29-30.03.2026) 🏗️
- ✅ **Multi-Layer Data Architecture:**
  - Data Layer: 8 modules, 50+ functions
  - KI Layer: Refactored placeholder_resolver.py
  - Visualization Layer: charts.py router
- ✅ **Charts API:** 10+ endpoints für Diagramme
- ✅ **Separation of Concerns:** Single Source of Truth
- ✅ **Testing:** Unit tests für Data Layer
- ✅ **Dokumentation:** Architecture + API docs

**Betroffene Dateien:**
- `backend/data_layer/*` - NEU (8 Module)
- `backend/routers/charts.py` - NEU
- `backend/placeholder_resolver.py` - REFACTORED
- `backend/goal_utils.py` - REFACTORED

Acceptance Criteria

Phase 0c ist abgeschlossen, wenn:

Funktional

  • Alle 50+ Data Layer Funktionen implementiert
  • Alle bestehenden Platzhalter funktionieren weiter (keine Regression)
  • Mindestens 10 Chart-Endpoints verfügbar
  • goal_utils.py nutzt data_layer.goals
  • Alle Charts liefern Chart.js-kompatible Daten

Technisch

  • Keine Code-Duplikation zwischen KI Layer und Charts
  • Data Layer hat Unit Tests (>80% coverage für utils.py)
  • Confidence scoring funktioniert korrekt
  • Outlier detection funktioniert
  • Alle Decimal → Float Conversions korrekt

Qualität

  • Keine SQL queries in placeholder_resolver.py
  • Keine SQL queries in routers/charts.py
  • Alle Funktionen haben Type Hints
  • Alle Funktionen haben Docstrings
  • Migrations laufen erfolgreich

Dokumentation

  • DATA_LAYER_ARCHITECTURE.md erstellt
  • CHARTS_API.md erstellt
  • CLAUDE.md aktualisiert
  • Dieses Issue-Dokument vollständig

Common Pitfalls (Learnings from Phase 0b)

1. Decimal → Float Conversion

# ❌ WRONG:
protein = row['protein']  # Decimal object

# ✅ CORRECT:
protein = float(row['protein']) if row['protein'] else 0.0

2. Date Serialization

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

# ❌ WRONG:
value = data['key']  # KeyError if missing

# ✅ CORRECT:
value = data.get('key', default_value)

4. Column Name Consistency

# ❌ WRONG (assumed name):
SELECT bf_jpl FROM caliper_log

# ✅ CORRECT (check schema):
SELECT body_fat_pct FROM caliper_log

5. Confidence Calculation

# ✅ ALWAYS use utils.calculate_confidence()
# DON'T hardcode confidence logic

6. SQL Query Structure

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