diff --git a/backend/app/api/health.py b/backend/app/api/health.py index faecf55..9cf18ec 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, func -from pydantic import BaseModel -from typing import Optional, List +from pydantic import BaseModel, model_validator +from typing import Optional, List, Any from datetime import datetime, timedelta, timezone from app.core.database import get_db @@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel): active_calories: Optional[float] total_calories: Optional[float] spo2_avg: Optional[float] + body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped + + @model_validator(mode='after') + def _strip_bb_values(self): + if isinstance(self.body_battery, dict): + self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'} + return self class Config: from_attributes = True @@ -136,7 +143,10 @@ async def intraday_health( ) ) metric = result.scalar_one_or_none() - return {"hr_values": metric.intraday_hr if metric else None} + return { + "hr_values": metric.intraday_hr if metric else None, + "body_battery": metric.body_battery if metric else None, + } @router.put("/manual") diff --git a/backend/app/main.py b/backend/app/main.py index 74f8d9d..f565d43 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -57,6 +57,7 @@ async def init_db(): "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB", + "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB", ]: await conn.execute(text(stmt)) except Exception as e: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 3e86db4..f14c08c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -244,6 +244,7 @@ class HealthMetric(Base): total_calories = Column(Float, nullable=True) spo2_avg = Column(Float, nullable=True) intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response + body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]} __table_args__ = ( UniqueConstraint("user_id", "date", name="uq_health_user_date"), diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index 5bb9239..5011ad9 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -202,6 +202,7 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, processed = 0 import time as _time + import json as _json for i in range(max(days, 1)): day = start_date + timedelta(days=i) day_str = day.isoformat() @@ -212,6 +213,7 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, # Intraday HR (requires display_name; skip gracefully if absent) hr_raw = _safe(garmin.get_heart_rates, day_str) if garmin.display_name else None bc_data = _safe(garmin.get_body_composition, day_str, day_str) + bb_raw = _safe(garmin.get_body_battery, day_str, day_str) _time.sleep(0.25) # avoid hammering Garmin's wellness API row = _parse_day(stats, sleep_data, hrv_data) @@ -243,6 +245,12 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, bwf = float(bw) _set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2)) + # Body battery — store summary + fine-grained timeline + if bb_raw: + bb = _parse_body_battery(bb_raw, day_str) + if bb: + row["body_battery"] = _json.dumps(bb) + # Intraday heart rate — store non-null [epoch_ms, bpm] pairs if hr_raw: raw_vals = hr_raw.get("heartRateValues") or [] @@ -253,11 +261,12 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, if not row: continue - # psycopg2 treats Python lists as PostgreSQL arrays; serialize JSON columns - # explicitly so they arrive as a JSON string that the json/jsonb column accepts. - import json as _json - if "intraday_hr" in row and isinstance(row["intraday_hr"], list): + # psycopg2 treats Python lists/dicts as PG arrays/hstore; serialize JSON + # columns as strings so psycopg2 passes them correctly to json/jsonb columns. + if "intraday_hr" in row and not isinstance(row["intraday_hr"], str): row["intraday_hr"] = _json.dumps(row["intraday_hr"]) + if "body_battery" in row and not isinstance(row["body_battery"], str): + row["body_battery"] = _json.dumps(row["body_battery"]) cols = list(row.keys()) col_sql = ", ".join(cols) @@ -288,6 +297,54 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, return processed +def _parse_body_battery(bb_response, day_str: str): + """Parse get_body_battery() response for a single day into a compact dict.""" + if not bb_response: + return None + entry = next((e for e in bb_response if e.get("date") == day_str), None) + if not entry and bb_response: + entry = bb_response[0] + if not entry: + return None + + charged = entry.get("charged") + drained = entry.get("drained") + start_lvl = entry.get("startValue") + end_lvl = entry.get("endValue") + + # Fine-grained timeline: [[ts_ms, level, type_code, stress], ...] + # type_code: 0=REST, 1=ACTIVE, 2=SLEEP, 3=STRESS, 4=UNMEASURABLE + values = entry.get("bodyBatteryValuesArray") or [] + + if not values: + # Fall back to bodyBatteryStatList (segment-level data) + type_map = {"REST": 0, "ACTIVE": 1, "SLEEP": 2, "STRESS": 3, "UNMEASURABLE": 4} + for seg in (entry.get("bodyBatteryStatList") or []): + ts_str = seg.get("startTimestampGMT") or seg.get("startTimestampLocal") + if ts_str: + try: + from datetime import datetime as _dt, timezone as _tz + ts = _dt.fromisoformat(ts_str.rstrip("Z")).replace(tzinfo=_tz.utc) + type_code = type_map.get(seg.get("activityType", "UNMEASURABLE"), 4) + values.append([int(ts.timestamp() * 1000), + int(seg.get("bodyBatteryLevel") or 0), + type_code, + int(seg.get("stressLevel") or -1)]) + except Exception: + pass + + if charged is None and end_lvl is None and not values: + return None + + return { + "charged": charged, + "drained": drained, + "start_level": start_lvl, + "end_level": end_lvl, + "values": values, # stripped from list-API, returned in intraday endpoint + } + + def _safe(fn, *args): try: return fn(*args) diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 9932e9c..354c0f7 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -1,8 +1,8 @@ import { useState, useMemo } from 'react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { - AreaChart, Area, BarChart, Bar, ReferenceLine, - XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + AreaChart, Area, BarChart, Bar, ComposedChart, Line, ReferenceLine, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, } from 'recharts' import { format, subDays } from 'date-fns' import api from '../utils/api' @@ -58,6 +58,110 @@ function IntradayHrChart({ values }) { ) } +// ── Body Battery ───────────────────────────────────────────────────────────── + +const BB_TYPE_COLOR = { 0: '#3b82f6', 1: '#6b7280', 2: '#1e3a5f', 3: '#f97316', 4: '#374151' } +const BB_TYPE_LABEL = { 0: 'Rest', 1: 'Active', 2: 'Sleep', 3: 'Stress', 4: 'Unmeasurable' } + +function bbLevelColor(level) { + if (level == null) return '#6b7280' + if (level >= 75) return '#3b82f6' + if (level >= 50) return '#22c55e' + if (level >= 25) return '#f59e0b' + return '#ef4444' +} + +function BatteryRing({ level }) { + if (level == null) return -- + const r = 38, stroke = 8 + const c = 2 * Math.PI * r + const filled = c * (Math.min(100, Math.max(0, level)) / 100) + const color = bbLevelColor(level) + return ( + + ) +} + +function BodyBatteryChart({ bb }) { + if (!bb) return null + const { charged, drained, start_level, end_level, values } = bb + if (!values?.length && end_level == null) return null + + const chartData = (values || []).map(([ts, level, type, stress]) => ({ + t: ts, + level, + type: type ?? 4, + bar: stress > 0 ? stress : (type === 2 ? 8 : type === 0 ? 20 : 35), + })) + + return ( +
Charged
+ +{charged} +Drained
+ -{drained} +{start_level} → {end_level}
+ )} +📊
@@ -249,6 +353,9 @@ function DailySnapshot({ day, avg30, intradayHr, onOlder, onNewer, hasOlder, has