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 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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user