feat: Phase 0c - Multi-Layer Data Architecture (Proof of Concept)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- 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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-28 18:26:22 +01:00
parent 255d1d61c5
commit c79cc9eafb
6 changed files with 959 additions and 82 deletions

View File

@ -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',
]

View File

@ -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
}

241
backend/data_layer/utils.py Normal file
View File

@ -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

View File

@ -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():

View File

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

328
backend/routers/charts.py Normal file
View File

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