Add body battery: sync, storage, and health UI chart
Parses Garmin Connect get_body_battery() per day, storing charged/drained/ start+end levels and the fine-grained [[ts_ms, level, type, stress]] values array in a new body_battery JSONB column on health_metrics. Frontend adds: - BatteryRing SVG gauge (color-scaled 0–100) - BodyBatteryChart: ComposedChart with type-colored bars (REST/ACTIVE/SLEEP/ STRESS) and battery level overlay line, matching Garmin's layout - Body battery trend chart in the Trends section (end_level per day) Also adds avg_hr_day and weight data which now correctly sync with the intraday_hr JSON serialization fix from the previous commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <span className="text-3xl font-bold text-gray-600">--</span>
|
||||
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 (
|
||||
<svg width="96" height="96" viewBox="0 0 96 96">
|
||||
<circle cx="48" cy="48" r={r} fill="none" stroke="#1f2937" strokeWidth={stroke} />
|
||||
<circle cx="48" cy="48" r={r} fill="none" stroke={color} strokeWidth={stroke}
|
||||
strokeDasharray={`${filled} ${c - filled}`} strokeLinecap="round"
|
||||
transform="rotate(-90 48 48)" />
|
||||
<text x="48" y="44" textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize="20" fontWeight="bold">{Math.round(level)}</text>
|
||||
<text x="48" y="62" textAnchor="middle" fill="#6b7280" fontSize="11">/ 100</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<BatteryRing level={end_level} />
|
||||
<div className="space-y-3">
|
||||
{charged != null && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Charged</p>
|
||||
<span className="text-xl font-semibold text-blue-400">+{charged}</span>
|
||||
</div>
|
||||
)}
|
||||
{drained != null && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Drained</p>
|
||||
<span className="text-xl font-semibold text-orange-400">-{drained}</span>
|
||||
</div>
|
||||
)}
|
||||
{start_level != null && end_level != null && (
|
||||
<p className="text-xs text-gray-500">{start_level} → {end_level}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={110}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
width={28} domain={[0, 100]} />
|
||||
<Tooltip contentStyle={tooltipStyle}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : [Math.round(v), 'Stress']} />
|
||||
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={8}>
|
||||
{chartData.map((d, i) => (
|
||||
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.7} />
|
||||
))}
|
||||
</Bar>
|
||||
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={2}
|
||||
dot={false} isAnimationActive={false} connectNulls />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{Object.entries(BB_TYPE_LABEL).map(([code, label]) => (
|
||||
<div key={code} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SleepStagesBar({ deep, light, rem, awake }) {
|
||||
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
||||
if (!total) return null
|
||||
@@ -98,7 +202,7 @@ function NavArrow({ onClick, disabled, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DailySnapshot({ day, avg30, intradayHr, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
if (!day) return (
|
||||
<div className="text-center py-10 text-gray-600">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
@@ -249,6 +353,9 @@ function DailySnapshot({ day, avg30, intradayHr, onOlder, onNewer, hasOlder, has
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body battery */}
|
||||
<BodyBatteryChart bb={bodyBattery} />
|
||||
|
||||
{/* Activity strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
|
||||
@@ -490,6 +597,7 @@ export default function HealthPage() {
|
||||
day={selectedDay}
|
||||
avg30={summary?.avg_30d}
|
||||
intradayHr={intradayData?.hr_values}
|
||||
bodyBattery={intradayData?.body_battery}
|
||||
onOlder={goOlder}
|
||||
onNewer={goNewer}
|
||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||
@@ -599,6 +707,17 @@ export default function HealthPage() {
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
|
||||
{metrics.some(d => d.body_battery?.end_level != null) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Body Battery (end of day)</h3>
|
||||
<MetricChart
|
||||
data={metrics.map(d => ({ ...d, body_battery_level: d.body_battery?.end_level ?? null }))}
|
||||
dataKey="body_battery_level" color="#3b82f6"
|
||||
formatter={v => `${Math.round(v)}`}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.some(d => d.vo2max) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||
|
||||
Reference in New Issue
Block a user