Add body battery: sync, storage, and health UI chart
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

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:
2026-06-07 11:13:38 +01:00
parent 37ffd4c9e0
commit 616099402b
5 changed files with 198 additions and 10 deletions
+13 -3
View File
@@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func from sqlalchemy import select, desc, func
from pydantic import BaseModel from pydantic import BaseModel, model_validator
from typing import Optional, List from typing import Optional, List, Any
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app.core.database import get_db from app.core.database import get_db
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
active_calories: Optional[float] active_calories: Optional[float]
total_calories: Optional[float] total_calories: Optional[float]
spo2_avg: 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: class Config:
from_attributes = True from_attributes = True
@@ -136,7 +143,10 @@ async def intraday_health(
) )
) )
metric = result.scalar_one_or_none() 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") @router.put("/manual")
+1
View File
@@ -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 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 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 intraday_hr JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
]: ]:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
except Exception as e: except Exception as e:
+1
View File
@@ -244,6 +244,7 @@ class HealthMetric(Base):
total_calories = Column(Float, nullable=True) total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True) spo2_avg = Column(Float, nullable=True)
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response 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__ = ( __table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"), UniqueConstraint("user_id", "date", name="uq_health_user_date"),
+61 -4
View File
@@ -202,6 +202,7 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
processed = 0 processed = 0
import time as _time import time as _time
import json as _json
for i in range(max(days, 1)): for i in range(max(days, 1)):
day = start_date + timedelta(days=i) day = start_date + timedelta(days=i)
day_str = day.isoformat() 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) # Intraday HR (requires display_name; skip gracefully if absent)
hr_raw = _safe(garmin.get_heart_rates, day_str) if garmin.display_name else None 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) 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 _time.sleep(0.25) # avoid hammering Garmin's wellness API
row = _parse_day(stats, sleep_data, hrv_data) 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) bwf = float(bw)
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2)) _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 # Intraday heart rate — store non-null [epoch_ms, bpm] pairs
if hr_raw: if hr_raw:
raw_vals = hr_raw.get("heartRateValues") or [] 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: if not row:
continue continue
# psycopg2 treats Python lists as PostgreSQL arrays; serialize JSON columns # psycopg2 treats Python lists/dicts as PG arrays/hstore; serialize JSON
# explicitly so they arrive as a JSON string that the json/jsonb column accepts. # columns as strings so psycopg2 passes them correctly to json/jsonb columns.
import json as _json if "intraday_hr" in row and not isinstance(row["intraday_hr"], str):
if "intraday_hr" in row and isinstance(row["intraday_hr"], list):
row["intraday_hr"] = _json.dumps(row["intraday_hr"]) 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()) cols = list(row.keys())
col_sql = ", ".join(cols) col_sql = ", ".join(cols)
@@ -288,6 +297,54 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
return processed 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): def _safe(fn, *args):
try: try:
return fn(*args) return fn(*args)
+122 -3
View File
@@ -1,8 +1,8 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useQuery, keepPreviousData } from '@tanstack/react-query' import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { import {
AreaChart, Area, BarChart, Bar, ReferenceLine, AreaChart, Area, BarChart, Bar, ComposedChart, Line, ReferenceLine,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts' } from 'recharts'
import { format, subDays } from 'date-fns' import { format, subDays } from 'date-fns'
import api from '../utils/api' 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 }) { function SleepStagesBar({ deep, light, rem, awake }) {
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0) const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
if (!total) return null 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 ( if (!day) return (
<div className="text-center py-10 text-gray-600"> <div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p> <p className="text-3xl mb-2">📊</p>
@@ -249,6 +353,9 @@ function DailySnapshot({ day, avg30, intradayHr, onOlder, onNewer, hasOlder, has
</div> </div>
)} )}
{/* Body battery */}
<BodyBatteryChart bb={bodyBattery} />
{/* Activity strip */} {/* Activity strip */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
@@ -490,6 +597,7 @@ export default function HealthPage() {
day={selectedDay} day={selectedDay}
avg30={summary?.avg_30d} avg30={summary?.avg_30d}
intradayHr={intradayData?.hr_values} intradayHr={intradayData?.hr_values}
bodyBattery={intradayData?.body_battery}
onOlder={goOlder} onOlder={goOlder}
onNewer={goNewer} onNewer={goNewer}
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1} hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
@@ -599,6 +707,17 @@ export default function HealthPage() {
selectedDate={selDateForCharts} onDayClick={handleDayClick} /> selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </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) && ( {metrics.some(d => d.vo2max) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <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> <h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>