feat: Phase 0c - Multi-Layer Data Architecture (Proof of Concept)
- 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:
parent
255d1d61c5
commit
c79cc9eafb
51
backend/data_layer/__init__.py
Normal file
51
backend/data_layer/__init__.py
Normal 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',
|
||||||
|
]
|
||||||
271
backend/data_layer/body_metrics.py
Normal file
271
backend/data_layer/body_metrics.py
Normal 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
241
backend/data_layer/utils.py
Normal 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
|
||||||
|
|
@ -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 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 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 goal_types, goal_progress, training_phases, fitness_tests # v9h Goal System (Split routers)
|
||||||
|
from routers import charts # Phase 0c Multi-Layer Architecture
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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(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)
|
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 ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,22 @@ Placeholder Resolver for AI Prompts
|
||||||
|
|
||||||
Provides a registry of placeholder functions that resolve to actual user data.
|
Provides a registry of placeholder functions that resolve to actual user data.
|
||||||
Used for prompt templates and preview functionality.
|
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
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, List, Optional, Callable
|
from typing import Dict, List, Optional, Callable
|
||||||
from db import get_db, get_cursor, r2d
|
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 ──────────────────────────────────────────────────────────
|
# ── 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:
|
def get_weight_trend(profile_id: str, days: int = 28) -> str:
|
||||||
"""Calculate weight trend description."""
|
"""
|
||||||
with get_db() as conn:
|
Calculate weight trend description.
|
||||||
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()]
|
|
||||||
|
|
||||||
if len(rows) < 2:
|
Phase 0c: Refactored to use data_layer.body_metrics.get_weight_trend_data()
|
||||||
return "nicht genug Daten"
|
This function now only FORMATS the data for AI consumption.
|
||||||
|
"""
|
||||||
|
data = get_weight_trend_data(profile_id, days)
|
||||||
|
|
||||||
first = rows[0]['weight']
|
if data['confidence'] == 'insufficient':
|
||||||
last = rows[-1]['weight']
|
return "nicht genug Daten"
|
||||||
delta = last - first
|
|
||||||
|
|
||||||
if abs(delta) < 0.3:
|
direction = data['direction']
|
||||||
return "stabil"
|
delta = data['delta']
|
||||||
elif delta > 0:
|
|
||||||
return f"steigend (+{delta:.1f} kg in {days} Tagen)"
|
if direction == "stable":
|
||||||
else:
|
return "stabil"
|
||||||
return f"sinkend ({delta:.1f} kg in {days} Tagen)"
|
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]:
|
def get_latest_bf(profile_id: str) -> Optional[str]:
|
||||||
"""Get latest body fat percentage from caliper."""
|
"""
|
||||||
with get_db() as conn:
|
Get latest body fat percentage from caliper.
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
Phase 0c: Refactored to use data_layer.body_metrics.get_body_composition_data()
|
||||||
"""SELECT body_fat_pct FROM caliper_log
|
This function now only FORMATS the data for AI consumption.
|
||||||
WHERE profile_id=%s AND body_fat_pct IS NOT NULL
|
"""
|
||||||
ORDER BY date DESC LIMIT 1""",
|
data = get_body_composition_data(profile_id)
|
||||||
(profile_id,)
|
|
||||||
)
|
if data['confidence'] == 'insufficient':
|
||||||
row = cur.fetchone()
|
return "nicht verfügbar"
|
||||||
return f"{row['body_fat_pct']:.1f}%" if row else "nicht verfügbar"
|
|
||||||
|
return f"{data['body_fat_pct']:.1f}%"
|
||||||
|
|
||||||
|
|
||||||
def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str:
|
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:
|
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:
|
Get latest circumference measurements summary with age annotations.
|
||||||
cur = get_cursor(conn)
|
|
||||||
|
|
||||||
# Define all circumference points with their labels
|
Phase 0c: Refactored to use data_layer.body_metrics.get_circumference_summary_data()
|
||||||
fields = [
|
This function now only FORMATS the data for AI consumption.
|
||||||
('c_neck', 'Nacken'),
|
"""
|
||||||
('c_chest', 'Brust'),
|
data = get_circumference_summary_data(profile_id)
|
||||||
('c_waist', 'Taille'),
|
|
||||||
('c_belly', 'Bauch'),
|
|
||||||
('c_hip', 'Hüfte'),
|
|
||||||
('c_thigh', 'Oberschenkel'),
|
|
||||||
('c_calf', 'Wade'),
|
|
||||||
('c_arm', 'Arm')
|
|
||||||
]
|
|
||||||
|
|
||||||
parts = []
|
if data['confidence'] == 'insufficient':
|
||||||
today = datetime.now().date()
|
return "keine Umfangsmessungen"
|
||||||
|
|
||||||
# Get latest value for each field individually
|
parts = []
|
||||||
for field_name, label in fields:
|
for measurement in data['measurements']:
|
||||||
cur.execute(
|
age_days = measurement['age_days']
|
||||||
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
|
|
||||||
|
|
||||||
if row:
|
# Format age annotation
|
||||||
value = row[field_name]
|
if age_days == 0:
|
||||||
age_days = row['age_days']
|
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
|
parts.append(f"{measurement['point']} {measurement['value']:.1f}cm ({age_str})")
|
||||||
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"{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:
|
def get_goal_weight(profile_id: str) -> str:
|
||||||
|
|
|
||||||
328
backend/routers/charts.py
Normal file
328
backend/routers/charts.py
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user