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 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")
+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 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:
+1
View File
@@ -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"),
+61 -4
View File
@@ -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)
+122 -3
View File
@@ -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>