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