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
+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>