""" 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" } ] }