frontend: share one BodyBatteryChart component across Health + Dashboard (Dashboard now shows mid-activity pause markers too)
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 9s

This commit is contained in:
2026-06-20 20:16:06 +01:00
parent e33946e270
commit 7d52b28f9d
3 changed files with 187 additions and 249 deletions
+8 -98
View File
@@ -2,7 +2,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState, useEffect, useRef } from 'react'
import {
BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceArea,
BarChart, Bar, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import GridLayout, { WidthProvider } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
@@ -13,6 +13,7 @@ import { useIsMobile } from '../hooks/useMediaQuery'
import StatCard from '../components/ui/StatCard'
import HrvBadge from '../components/ui/HrvBadge'
import SleepHypnogram from '../components/health/SleepHypnogram'
import BodyBatteryChart from '../components/health/BodyBatteryChart'
import ActivityMap from '../components/activity/ActivityMap'
import {
formatDuration, formatDistance, formatHeartRate, formatElevation,
@@ -21,7 +22,6 @@ import {
import { useUnit } from '../hooks/useUnits'
import SportIcon from '../components/ui/SportIcon'
import { vo2Color } from '../utils/vo2'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
const Grid = WidthProvider(GridLayout)
@@ -107,105 +107,15 @@ function Stat({ label, value }) {
// ── Chart widgets ────────────────────────────────────────────────────────────
// Sport icon centred inside the activity band that sits below the battery bars.
function BBActivityRefLabel({ viewBox, sport }) {
if (!viewBox) return null
const { x, y, width = 0, height = 0 } = viewBox
const size = 13
return (
<SportIcon sport={sport} size={size} color="#fff"
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
style={{ pointerEvents: 'none' }} />
)
}
// Activities are drawn as solid coloured bands in a reserved strip below the
// battery bars (the negative Y region) so they don't obscure the data.
const BB_ACTIVITY_BAND_BOTTOM = -18
// Body Battery widget — wraps the shared BodyBatteryChart in a dashboard Card.
// The dashboard variant fills its grid cell (fill) and uses a compact y-axis.
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd, activities }) {
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level }))
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
const data = raw.map((d, i) => ({
...d,
type: inferBBType(d.t, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs),
}))
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level
const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
const hasGraph = data.length >= 2
const presentTypes = [...new Set(data.map(d => d.type))]
// Only activities that overlap the battery samples for this day.
const dayStart = data.length ? data[0].t : null
const dayEnd = data.length ? data[data.length - 1].t : null
const dayActivities = (activities || []).filter(a => {
if (dayStart == null) return false
const start = new Date(a.start_time).getTime()
const end = a.duration_s ? start + a.duration_s * 1000 : start
return end >= dayStart && start <= dayEnd
})
const hasActivities = dayActivities.length > 0
// The X axis is categorical, so overlays must snap to a sample that exists.
const nearestT = (ms) => {
let best = null, bd = Infinity
for (const d of data) { const dd = Math.abs(d.t - ms); if (dd < bd) { bd = dd; best = d.t } }
return best
}
return (
<Card title="Body Battery" viewHref="/health">
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-3 flex-wrap">
{peak != null && <span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>}
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
</div>
{hasGraph ? (
<>
<div className="flex-1 min-h-0 mt-2">
<ResponsiveContainer width="100%" height="100%" minHeight={80}>
<BarChart data={data} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={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(data.length / 6))} />
<YAxis domain={[hasActivities ? BB_ACTIVITY_BAND_BOTTOM : 0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }}
axisLine={false} tickLine={false} width={26} ticks={[0, 50, 100]} />
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} />
<Bar dataKey="level" isAnimationActive={false} radius={0}>
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
</Bar>
{dayActivities.map(a => {
const start = new Date(a.start_time).getTime()
const end = a.duration_s ? start + a.duration_s * 1000 : start
const x1 = nearestT(start), x2 = nearestT(end)
if (x1 == null || x2 == null) return null
const color = sportColor(a.sport_type)
return (
<ReferenceArea key={`area-${a.id}`} x1={x1} x2={x2} y1={0} y2={BB_ACTIVITY_BAND_BOTTOM}
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
label={<BBActivityRefLabel sport={a.sport_type} />} />
)
})}
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{presentTypes.map(type => (
<div key={type} className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
</div>
))}
</div>
</>
) : (
<p className="text-xs text-gray-600 mt-3">No body battery data today</p>
)}
</div>
<BodyBatteryChart
bb={bb} hires={hires} sleepStart={sleepStart} sleepEnd={sleepEnd} activities={activities}
fill yTicks={[0, 50, 100]} yAxisWidth={26} leftMargin={0} iconSize={13}
emptyText="No body battery data today" />
</Card>
)
}