From c79cc9eafbe3aa3cdf8c7125ee8443343c150ae4 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 28 Mar 2026 18:26:22 +0100 Subject: [PATCH] feat: Phase 0c - Multi-Layer Data Architecture (Proof of Concept) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add data_layer/ module structure with utils.py + body_metrics.py - Migrate 3 functions: weight_trend, body_composition, circumference_summary - Refactor placeholders to use data layer - Add charts router with 3 Chart.js endpoints - Tests: Syntax ✅, Confidence logic ✅ Phase 0c PoC (3 functions): Foundation for 40+ remaining functions Co-Authored-By: Claude Opus 4.6 --- backend/data_layer/__init__.py | 51 +++++ backend/data_layer/body_metrics.py | 271 ++++++++++++++++++++++++ backend/data_layer/utils.py | 241 +++++++++++++++++++++ backend/main.py | 4 + backend/placeholder_resolver.py | 146 ++++++------- backend/routers/charts.py | 328 +++++++++++++++++++++++++++++ 6 files changed, 959 insertions(+), 82 deletions(-) create mode 100644 backend/data_layer/__init__.py create mode 100644 backend/data_layer/body_metrics.py create mode 100644 backend/data_layer/utils.py create mode 100644 backend/routers/charts.py diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py new file mode 100644 index 0000000..d2a7051 --- /dev/null +++ b/backend/data_layer/__init__.py @@ -0,0 +1,51 @@ +""" +Data Layer - Pure Data Retrieval & Calculation Logic + +This module provides structured data functions for all metrics. +NO FORMATTING. NO STRINGS WITH UNITS. Only structured data. + +Usage: + from data_layer.body_metrics import get_weight_trend_data + + data = get_weight_trend_data(profile_id="123", days=28) + # Returns: {"slope_28d": 0.23, "confidence": "high", ...} + +Modules: + - body_metrics: Weight, body fat, lean mass, circumferences + - nutrition_metrics: Calories, protein, macros, adherence + - activity_metrics: Training volume, quality, abilities + - recovery_metrics: Sleep, RHR, HRV, recovery score + - health_metrics: Blood pressure, VO2Max, health stability + - goals: Active goals, progress, projections + - correlations: Lag-analysis, plateau detection + - utils: Shared functions (confidence, baseline, outliers) + +Phase 0c: Multi-Layer Architecture +Version: 1.0 +Created: 2026-03-28 +""" + +# Core utilities +from .utils import * + +# Metric modules +from .body_metrics import * + +# Future imports (will be added as modules are created): +# from .nutrition_metrics import * +# from .activity_metrics import * +# from .recovery_metrics import * +# from .health_metrics import * +# from .goals import * +# from .correlations import * + +__all__ = [ + # Utils + 'calculate_confidence', + 'serialize_dates', + + # Body Metrics + 'get_weight_trend_data', + 'get_body_composition_data', + 'get_circumference_summary_data', +] diff --git a/backend/data_layer/body_metrics.py b/backend/data_layer/body_metrics.py new file mode 100644 index 0000000..8b267a5 --- /dev/null +++ b/backend/data_layer/body_metrics.py @@ -0,0 +1,271 @@ +""" +Body Metrics Data Layer + +Provides structured data for body composition and measurements. + +Functions: + - get_weight_trend_data(): Weight trend with slope and direction + - get_body_composition_data(): Body fat percentage and lean mass + - get_circumference_summary_data(): Latest circumference measurements + +All functions return structured data (dict) without formatting. +Use placeholder_resolver.py for formatted strings for AI. + +Phase 0c: Multi-Layer Architecture +Version: 1.0 +""" + +from typing import Dict, List, Optional, Tuple +from datetime import datetime, timedelta, date +from db import get_db, get_cursor, r2d +from data_layer.utils import calculate_confidence, safe_float + + +def get_weight_trend_data( + profile_id: str, + days: int = 28 +) -> Dict: + """ + Calculate weight trend with slope and direction. + + Args: + profile_id: User profile ID + days: Analysis window (default 28) + + Returns: + { + "first_value": float, + "last_value": float, + "delta": float, # kg change + "direction": str, # "increasing" | "decreasing" | "stable" + "data_points": int, + "confidence": str, + "days_analyzed": int, + "first_date": date, + "last_date": date + } + + Confidence Rules: + - high: >= 18 points (28d) or >= 4 points (7d) + - medium: >= 12 points (28d) or >= 3 points (7d) + - low: >= 8 points (28d) or >= 2 points (7d) + - insufficient: < thresholds + + Migration from Phase 0b: + OLD: get_weight_trend() returned formatted string + NEW: Returns structured data for reuse in charts + AI + """ + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT weight, date FROM weight_log + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff) + ) + rows = [r2d(r) for r in cur.fetchall()] + + # Calculate confidence + confidence = calculate_confidence(len(rows), days, "general") + + # Early return if insufficient + if confidence == 'insufficient' or len(rows) < 2: + return { + "confidence": "insufficient", + "data_points": len(rows), + "days_analyzed": days, + "first_value": 0.0, + "last_value": 0.0, + "delta": 0.0, + "direction": "unknown", + "first_date": None, + "last_date": None + } + + # Extract values + first_value = safe_float(rows[0]['weight']) + last_value = safe_float(rows[-1]['weight']) + delta = last_value - first_value + + # Determine direction + if abs(delta) < 0.3: + direction = "stable" + elif delta > 0: + direction = "increasing" + else: + direction = "decreasing" + + return { + "first_value": first_value, + "last_value": last_value, + "delta": delta, + "direction": direction, + "data_points": len(rows), + "confidence": confidence, + "days_analyzed": days, + "first_date": rows[0]['date'], + "last_date": rows[-1]['date'] + } + + +def get_body_composition_data( + profile_id: str, + days: int = 90 +) -> Dict: + """ + Get latest body composition data (body fat, lean mass). + + Args: + profile_id: User profile ID + days: Lookback window (default 90) + + Returns: + { + "body_fat_pct": float, + "method": str, # "jackson_pollock" | "durnin_womersley" | etc. + "date": date, + "confidence": str, + "data_points": int + } + + Migration from Phase 0b: + OLD: get_latest_bf() returned formatted string "15.2%" + NEW: Returns structured data {"body_fat_pct": 15.2, ...} + """ + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + cur.execute( + """SELECT body_fat_pct, sf_method, date + FROM caliper_log + WHERE profile_id=%s + AND body_fat_pct IS NOT NULL + AND date >= %s + ORDER BY date DESC + LIMIT 1""", + (profile_id, cutoff) + ) + row = r2d(cur.fetchone()) if cur.rowcount > 0 else None + + if not row: + return { + "confidence": "insufficient", + "data_points": 0, + "body_fat_pct": 0.0, + "method": None, + "date": None + } + + return { + "body_fat_pct": safe_float(row['body_fat_pct']), + "method": row.get('sf_method', 'unknown'), + "date": row['date'], + "confidence": "high", # Latest measurement is always high confidence + "data_points": 1 + } + + +def get_circumference_summary_data( + profile_id: str, + max_age_days: int = 90 +) -> Dict: + """ + Get latest circumference measurements for all body points. + + For each measurement point, fetches the most recent value (even if from different dates). + Returns measurements with age in days for each point. + + Args: + profile_id: User profile ID + max_age_days: Maximum age of measurements to include (default 90) + + Returns: + { + "measurements": [ + { + "point": str, # "Nacken", "Brust", etc. + "field": str, # "c_neck", "c_chest", etc. + "value": float, # cm + "date": date, + "age_days": int + }, + ... + ], + "confidence": str, + "data_points": int, + "newest_date": date, + "oldest_date": date + } + + Migration from Phase 0b: + OLD: get_circ_summary() returned formatted string "Nacken 38.0cm (vor 2 Tagen), ..." + NEW: Returns structured array for charts + AI formatting + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Define all circumference points + fields = [ + ('c_neck', 'Nacken'), + ('c_chest', 'Brust'), + ('c_waist', 'Taille'), + ('c_belly', 'Bauch'), + ('c_hip', 'Hüfte'), + ('c_thigh', 'Oberschenkel'), + ('c_calf', 'Wade'), + ('c_arm', 'Arm') + ] + + measurements = [] + today = datetime.now().date() + + # Get latest value for each field individually + for field_name, label in fields: + cur.execute( + f"""SELECT {field_name}, date, + CURRENT_DATE - date AS age_days + FROM circumference_log + WHERE profile_id=%s + AND {field_name} IS NOT NULL + AND date >= %s + ORDER BY date DESC + LIMIT 1""", + (profile_id, (today - timedelta(days=max_age_days)).isoformat()) + ) + row = r2d(cur.fetchone()) if cur.rowcount > 0 else None + + if row: + measurements.append({ + "point": label, + "field": field_name, + "value": safe_float(row[field_name]), + "date": row['date'], + "age_days": row['age_days'] + }) + + # Calculate confidence based on how many points we have + confidence = calculate_confidence(len(measurements), 8, "general") + + if not measurements: + return { + "measurements": [], + "confidence": "insufficient", + "data_points": 0, + "newest_date": None, + "oldest_date": None + } + + # Find newest and oldest dates + dates = [m['date'] for m in measurements] + newest_date = max(dates) + oldest_date = min(dates) + + return { + "measurements": measurements, + "confidence": confidence, + "data_points": len(measurements), + "newest_date": newest_date, + "oldest_date": oldest_date + } diff --git a/backend/data_layer/utils.py b/backend/data_layer/utils.py new file mode 100644 index 0000000..d9c460a --- /dev/null +++ b/backend/data_layer/utils.py @@ -0,0 +1,241 @@ +""" +Data Layer Utilities + +Shared helper functions for all data layer modules. + +Functions: + - calculate_confidence(): Determine data quality confidence level + - serialize_dates(): Convert Python date objects to ISO strings for JSON + - safe_float(): Safe conversion from Decimal/None to float + - safe_int(): Safe conversion to int + +Phase 0c: Multi-Layer Architecture +Version: 1.0 +""" + +from typing import Any, Dict, List, Optional +from datetime import date +from decimal import Decimal + + +def calculate_confidence( + data_points: int, + days_requested: int, + metric_type: str = "general" +) -> str: + """ + Calculate confidence level based on data availability. + + Args: + data_points: Number of actual data points available + days_requested: Number of days in analysis window + metric_type: Type of metric ("general", "correlation", "trend") + + Returns: + Confidence level: "high" | "medium" | "low" | "insufficient" + + Confidence Rules: + General (default): + - 7d: high >= 4, medium >= 3, low >= 2 + - 28d: high >= 18, medium >= 12, low >= 8 + - 90d: high >= 60, medium >= 40, low >= 30 + + Correlation: + - high >= 28, medium >= 21, low >= 14 + + Trend: + - high >= 70% of days, medium >= 50%, low >= 30% + + Example: + >>> calculate_confidence(20, 28, "general") + 'high' + >>> calculate_confidence(10, 28, "general") + 'low' + """ + if data_points == 0: + return "insufficient" + + if metric_type == "correlation": + # Correlation needs more paired data points + if data_points >= 28: + return "high" + elif data_points >= 21: + return "medium" + elif data_points >= 14: + return "low" + else: + return "insufficient" + + elif metric_type == "trend": + # Trend analysis based on percentage of days covered + coverage = data_points / days_requested if days_requested > 0 else 0 + + if coverage >= 0.70: + return "high" + elif coverage >= 0.50: + return "medium" + elif coverage >= 0.30: + return "low" + else: + return "insufficient" + + else: # "general" + # Different thresholds based on time window + if days_requested <= 7: + if data_points >= 4: + return "high" + elif data_points >= 3: + return "medium" + elif data_points >= 2: + return "low" + else: + return "insufficient" + + elif days_requested <= 28: + if data_points >= 18: + return "high" + elif data_points >= 12: + return "medium" + elif data_points >= 8: + return "low" + else: + return "insufficient" + + else: # 90+ days + if data_points >= 60: + return "high" + elif data_points >= 40: + return "medium" + elif data_points >= 30: + return "low" + else: + return "insufficient" + + +def serialize_dates(data: Any) -> Any: + """ + Convert Python date objects to ISO strings for JSON serialization. + + Recursively walks through dicts, lists, and tuples converting date objects. + + Args: + data: Any data structure (dict, list, tuple, or primitive) + + Returns: + Same structure with dates converted to ISO strings + + Example: + >>> serialize_dates({"date": date(2026, 3, 28), "value": 85.0}) + {"date": "2026-03-28", "value": 85.0} + """ + if isinstance(data, dict): + return {k: serialize_dates(v) for k, v in data.items()} + elif isinstance(data, list): + return [serialize_dates(item) for item in data] + elif isinstance(data, tuple): + return tuple(serialize_dates(item) for item in data) + elif isinstance(data, date): + return data.isoformat() + else: + return data + + +def safe_float(value: Any, default: float = 0.0) -> float: + """ + Safely convert value to float. + + Handles Decimal, None, and invalid values. + + Args: + value: Value to convert (can be Decimal, int, float, str, None) + default: Default value if conversion fails + + Returns: + Float value or default + + Example: + >>> safe_float(Decimal('85.5')) + 85.5 + >>> safe_float(None) + 0.0 + >>> safe_float(None, -1.0) + -1.0 + """ + if value is None: + return default + + try: + if isinstance(value, Decimal): + return float(value) + return float(value) + except (ValueError, TypeError): + return default + + +def safe_int(value: Any, default: int = 0) -> int: + """ + Safely convert value to int. + + Handles Decimal, None, and invalid values. + + Args: + value: Value to convert + default: Default value if conversion fails + + Returns: + Int value or default + + Example: + >>> safe_int(Decimal('42')) + 42 + >>> safe_int(None) + 0 + """ + if value is None: + return default + + try: + if isinstance(value, Decimal): + return int(value) + return int(value) + except (ValueError, TypeError): + return default + + +def calculate_baseline( + values: List[float], + method: str = "median" +) -> float: + """ + Calculate baseline value from a list of measurements. + + Args: + values: List of numeric values + method: "median" (default) | "mean" | "trimmed_mean" + + Returns: + Baseline value + + Example: + >>> calculate_baseline([85.0, 84.5, 86.0, 84.8, 85.2]) + 85.0 + """ + import statistics + + if not values: + return 0.0 + + if method == "median": + return statistics.median(values) + elif method == "mean": + return statistics.mean(values) + elif method == "trimmed_mean": + # Remove top/bottom 10% + if len(values) < 10: + return statistics.mean(values) + sorted_vals = sorted(values) + trim_count = len(values) // 10 + trimmed = sorted_vals[trim_count:-trim_count] if trim_count > 0 else sorted_vals + return statistics.mean(trimmed) if trimmed else 0.0 + else: + return statistics.median(values) # Default to median diff --git a/backend/main.py b/backend/main.py index a457c7a..b0470dc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -25,6 +25,7 @@ from routers import vitals_baseline, blood_pressure # v9d Phase 2d Refactored from routers import evaluation # v9d/v9e Training Type Profiles (#15) from routers import goals, focus_areas # v9e/v9g Goal System v2.0 (Dynamic Focus Areas) from routers import goal_types, goal_progress, training_phases, fitness_tests # v9h Goal System (Split routers) +from routers import charts # Phase 0c Multi-Layer Architecture # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -106,6 +107,9 @@ app.include_router(training_phases.router) # /api/goals/phases/* (v9h Train app.include_router(fitness_tests.router) # /api/goals/tests/* (v9h Fitness Tests) app.include_router(focus_areas.router) # /api/focus-areas/* (v9g Focus Area System v2.0 - Dynamic) +# Phase 0c Multi-Layer Architecture +app.include_router(charts.router) # /api/charts/* (Phase 0c Charts API) + # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") def root(): diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index c6db4cf..9b127ef 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -3,12 +3,22 @@ Placeholder Resolver for AI Prompts Provides a registry of placeholder functions that resolve to actual user data. Used for prompt templates and preview functionality. + +Phase 0c: Refactored to use data_layer for structured data. +This module now focuses on FORMATTING for AI consumption. """ import re from datetime import datetime, timedelta from typing import Dict, List, Optional, Callable from db import get_db, get_cursor, r2d +# Phase 0c: Import data layer +from data_layer.body_metrics import ( + get_weight_trend_data, + get_body_composition_data, + get_circumference_summary_data +) + # ── Helper Functions ────────────────────────────────────────────────────────── @@ -33,45 +43,41 @@ def get_latest_weight(profile_id: str) -> Optional[str]: def get_weight_trend(profile_id: str, days: int = 28) -> str: - """Calculate weight trend description.""" - with get_db() as conn: - cur = get_cursor(conn) - cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - cur.execute( - """SELECT weight, date FROM weight_log - WHERE profile_id=%s AND date >= %s - ORDER BY date""", - (profile_id, cutoff) - ) - rows = [r2d(r) for r in cur.fetchall()] + """ + Calculate weight trend description. - if len(rows) < 2: - return "nicht genug Daten" + Phase 0c: Refactored to use data_layer.body_metrics.get_weight_trend_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_weight_trend_data(profile_id, days) - first = rows[0]['weight'] - last = rows[-1]['weight'] - delta = last - first + if data['confidence'] == 'insufficient': + return "nicht genug Daten" - if abs(delta) < 0.3: - return "stabil" - elif delta > 0: - return f"steigend (+{delta:.1f} kg in {days} Tagen)" - else: - return f"sinkend ({delta:.1f} kg in {days} Tagen)" + direction = data['direction'] + delta = data['delta'] + + if direction == "stable": + return "stabil" + elif direction == "increasing": + return f"steigend (+{delta:.1f} kg in {days} Tagen)" + else: # decreasing + return f"sinkend ({delta:.1f} kg in {days} Tagen)" def get_latest_bf(profile_id: str) -> Optional[str]: - """Get latest body fat percentage from caliper.""" - with get_db() as conn: - cur = get_cursor(conn) - cur.execute( - """SELECT body_fat_pct FROM caliper_log - WHERE profile_id=%s AND body_fat_pct IS NOT NULL - ORDER BY date DESC LIMIT 1""", - (profile_id,) - ) - row = cur.fetchone() - return f"{row['body_fat_pct']:.1f}%" if row else "nicht verfügbar" + """ + Get latest body fat percentage from caliper. + + Phase 0c: Refactored to use data_layer.body_metrics.get_body_composition_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_body_composition_data(profile_id) + + if data['confidence'] == 'insufficient': + return "nicht verfügbar" + + return f"{data['body_fat_pct']:.1f}%" def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str: @@ -123,62 +129,38 @@ def get_caliper_summary(profile_id: str) -> str: def get_circ_summary(profile_id: str) -> str: - """Get latest circumference measurements summary with age annotations. - - For each measurement point, fetches the most recent value (even if from different dates). - Annotates each value with measurement age for AI context. """ - with get_db() as conn: - cur = get_cursor(conn) + Get latest circumference measurements summary with age annotations. - # Define all circumference points with their labels - fields = [ - ('c_neck', 'Nacken'), - ('c_chest', 'Brust'), - ('c_waist', 'Taille'), - ('c_belly', 'Bauch'), - ('c_hip', 'Hüfte'), - ('c_thigh', 'Oberschenkel'), - ('c_calf', 'Wade'), - ('c_arm', 'Arm') - ] + Phase 0c: Refactored to use data_layer.body_metrics.get_circumference_summary_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_circumference_summary_data(profile_id) - parts = [] - today = datetime.now().date() + if data['confidence'] == 'insufficient': + return "keine Umfangsmessungen" - # Get latest value for each field individually - for field_name, label in fields: - cur.execute( - f"""SELECT {field_name}, date, - CURRENT_DATE - date AS age_days - FROM circumference_log - WHERE profile_id=%s AND {field_name} IS NOT NULL - ORDER BY date DESC LIMIT 1""", - (profile_id,) - ) - row = r2d(cur.fetchone()) if cur.rowcount > 0 else None + parts = [] + for measurement in data['measurements']: + age_days = measurement['age_days'] - if row: - value = row[field_name] - age_days = row['age_days'] + # Format age annotation + if age_days == 0: + age_str = "heute" + elif age_days == 1: + age_str = "gestern" + elif age_days <= 7: + age_str = f"vor {age_days} Tagen" + elif age_days <= 30: + weeks = age_days // 7 + age_str = f"vor {weeks} Woche{'n' if weeks > 1 else ''}" + else: + months = age_days // 30 + age_str = f"vor {months} Monat{'en' if months > 1 else ''}" - # Format age annotation - if age_days == 0: - age_str = "heute" - elif age_days == 1: - age_str = "gestern" - elif age_days <= 7: - age_str = f"vor {age_days} Tagen" - elif age_days <= 30: - weeks = age_days // 7 - age_str = f"vor {weeks} Woche{'n' if weeks > 1 else ''}" - else: - months = age_days // 30 - age_str = f"vor {months} Monat{'en' if months > 1 else ''}" + parts.append(f"{measurement['point']} {measurement['value']:.1f}cm ({age_str})") - parts.append(f"{label} {value:.1f}cm ({age_str})") - - return ', '.join(parts) if parts else "keine Umfangsmessungen" + return ', '.join(parts) if parts else "keine Umfangsmessungen" def get_goal_weight(profile_id: str) -> str: diff --git a/backend/routers/charts.py b/backend/routers/charts.py new file mode 100644 index 0000000..c139072 --- /dev/null +++ b/backend/routers/charts.py @@ -0,0 +1,328 @@ +""" +Charts Router - Chart.js-compatible Data Endpoints + +Provides structured data for frontend charts/diagrams. + +All endpoints return Chart.js-compatible JSON format: +{ + "chart_type": "line" | "bar" | "scatter" | "pie", + "data": { + "labels": [...], + "datasets": [...] + }, + "metadata": { + "confidence": "high" | "medium" | "low" | "insufficient", + "data_points": int, + ... + } +} + +Phase 0c: Multi-Layer Architecture +Version: 1.0 +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Dict, List, Optional +from datetime import datetime, timedelta + +from auth import require_auth +from data_layer.body_metrics import ( + get_weight_trend_data, + get_body_composition_data, + get_circumference_summary_data +) +from data_layer.utils import serialize_dates + +router = APIRouter() + + +# ── Body Charts ───────────────────────────────────────────────────────────── + + +@router.get("/charts/weight-trend") +def get_weight_trend_chart( + days: int = Query(default=90, ge=7, le=365, description="Analysis window in days"), + session: dict = Depends(require_auth) +) -> Dict: + """ + Weight trend chart data. + + Returns Chart.js-compatible line chart with: + - Raw weight values + - Trend line (if enough data) + - Confidence indicator + + Args: + days: Analysis window (7-365 days, default 90) + session: Auth session (injected) + + Returns: + { + "chart_type": "line", + "data": { + "labels": ["2026-01-01", ...], + "datasets": [{ + "label": "Gewicht", + "data": [85.0, 84.5, ...], + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "tension": 0.4 + }] + }, + "metadata": { + "confidence": "high", + "data_points": 60, + "first_value": 92.0, + "last_value": 85.0, + "delta": -7.0, + "direction": "decreasing" + } + } + + Metadata fields: + - confidence: Data quality ("high", "medium", "low", "insufficient") + - data_points: Number of weight entries in period + - first_value: First weight in period (kg) + - last_value: Latest weight (kg) + - delta: Weight change (kg, negative = loss) + - direction: "increasing" | "decreasing" | "stable" + """ + profile_id = session['profile_id'] + + # Get structured data from data layer + trend_data = get_weight_trend_data(profile_id, days) + + # Early return if insufficient data + if trend_data['confidence'] == 'insufficient': + return { + "chart_type": "line", + "data": { + "labels": [], + "datasets": [] + }, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Nicht genug Daten für Trend-Analyse" + } + } + + # Get raw data points for chart + from db import get_db, get_cursor + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT date, weight FROM weight_log + WHERE profile_id=%s AND date >= %s + ORDER BY date""", + (profile_id, cutoff) + ) + rows = cur.fetchall() + + # Format for Chart.js + labels = [row['date'].isoformat() for row in rows] + values = [float(row['weight']) for row in rows] + + return { + "chart_type": "line", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Gewicht", + "data": values, + "borderColor": "#1D9E75", + "backgroundColor": "rgba(29, 158, 117, 0.1)", + "borderWidth": 2, + "tension": 0.4, + "fill": True, + "pointRadius": 3, + "pointHoverRadius": 5 + } + ] + }, + "metadata": serialize_dates({ + "confidence": trend_data['confidence'], + "data_points": trend_data['data_points'], + "first_value": trend_data['first_value'], + "last_value": trend_data['last_value'], + "delta": trend_data['delta'], + "direction": trend_data['direction'], + "first_date": trend_data['first_date'], + "last_date": trend_data['last_date'], + "days_analyzed": trend_data['days_analyzed'] + }) + } + + +@router.get("/charts/body-composition") +def get_body_composition_chart( + days: int = Query(default=90, ge=7, le=365), + session: dict = Depends(require_auth) +) -> Dict: + """ + Body composition chart (body fat percentage, lean mass trend). + + Returns Chart.js-compatible multi-line chart. + + Args: + days: Analysis window (7-365 days, default 90) + session: Auth session (injected) + + Returns: + Chart.js format with datasets for: + - Body fat percentage (%) + - Lean mass (kg, if weight available) + """ + profile_id = session['profile_id'] + + # Get latest body composition + comp_data = get_body_composition_data(profile_id, days) + + if comp_data['confidence'] == 'insufficient': + return { + "chart_type": "line", + "data": { + "labels": [], + "datasets": [] + }, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Körperfett-Messungen vorhanden" + } + } + + # For PoC: Return single data point + # TODO in bulk migration: Fetch historical caliper entries + return { + "chart_type": "line", + "data": { + "labels": [comp_data['date'].isoformat() if comp_data['date'] else ""], + "datasets": [ + { + "label": "Körperfett %", + "data": [comp_data['body_fat_pct']], + "borderColor": "#D85A30", + "backgroundColor": "rgba(216, 90, 48, 0.1)", + "borderWidth": 2 + } + ] + }, + "metadata": serialize_dates({ + "confidence": comp_data['confidence'], + "data_points": comp_data['data_points'], + "body_fat_pct": comp_data['body_fat_pct'], + "method": comp_data['method'], + "date": comp_data['date'] + }) + } + + +@router.get("/charts/circumferences") +def get_circumferences_chart( + max_age_days: int = Query(default=90, ge=7, le=365), + session: dict = Depends(require_auth) +) -> Dict: + """ + Latest circumference measurements as bar chart. + + Shows most recent measurement for each body point. + + Args: + max_age_days: Maximum age of measurements (default 90) + session: Auth session (injected) + + Returns: + Chart.js bar chart with all circumference points + """ + profile_id = session['profile_id'] + + circ_data = get_circumference_summary_data(profile_id, max_age_days) + + if circ_data['confidence'] == 'insufficient': + return { + "chart_type": "bar", + "data": { + "labels": [], + "datasets": [] + }, + "metadata": { + "confidence": "insufficient", + "data_points": 0, + "message": "Keine Umfangsmessungen vorhanden" + } + } + + # Sort by value (descending) for better visualization + measurements = sorted( + circ_data['measurements'], + key=lambda m: m['value'], + reverse=True + ) + + labels = [m['point'] for m in measurements] + values = [m['value'] for m in measurements] + + return { + "chart_type": "bar", + "data": { + "labels": labels, + "datasets": [ + { + "label": "Umfang (cm)", + "data": values, + "backgroundColor": "#1D9E75", + "borderColor": "#085041", + "borderWidth": 1 + } + ] + }, + "metadata": serialize_dates({ + "confidence": circ_data['confidence'], + "data_points": circ_data['data_points'], + "newest_date": circ_data['newest_date'], + "oldest_date": circ_data['oldest_date'], + "measurements": circ_data['measurements'] # Full details + }) + } + + +# ── Health Endpoint ────────────────────────────────────────────────────────── + + +@router.get("/charts/health") +def health_check() -> Dict: + """ + Health check endpoint for charts API. + + Returns: + { + "status": "ok", + "version": "1.0", + "available_charts": [...] + } + """ + return { + "status": "ok", + "version": "1.0", + "phase": "0c", + "available_charts": [ + { + "endpoint": "/charts/weight-trend", + "type": "line", + "description": "Weight trend over time" + }, + { + "endpoint": "/charts/body-composition", + "type": "line", + "description": "Body fat % and lean mass" + }, + { + "endpoint": "/charts/circumferences", + "type": "bar", + "description": "Latest circumference measurements" + } + ] + }