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