frontend: body battery activity overlays on dashboard; redesign highlight as coloured band below the data
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
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-18 13:56:16 +01:00
parent a50d13179c
commit 471e43466c
2 changed files with 88 additions and 26 deletions
+51 -5
View File
@@ -2,7 +2,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState, useEffect, useRef } from 'react' import { useMemo, useState, useEffect, useRef } from 'react'
import { import {
BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceArea,
} from 'recharts' } from 'recharts'
import GridLayout, { WidthProvider } from 'react-grid-layout' import GridLayout, { WidthProvider } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css' import 'react-grid-layout/css/styles.css'
@@ -105,7 +105,23 @@ function Stat({ label, value }) {
// ── Chart widgets ──────────────────────────────────────────────────────────── // ── Chart widgets ────────────────────────────────────────────────────────────
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) { // Sport icon centred inside the activity band that sits below the battery bars.
function BBActivityRefLabel({ viewBox, icon }) {
if (!viewBox) return null
const { x, y, width = 0, height = 0 } = viewBox
return (
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="central"
fontSize={13} style={{ pointerEvents: 'none' }}>
{icon}
</text>
)
}
// 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
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd, activities }) {
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level })) const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level }))
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
@@ -118,6 +134,24 @@ function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) {
const hasGraph = data.length >= 2 const hasGraph = data.length >= 2
const presentTypes = [...new Set(data.map(d => d.type))] 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 ( return (
<Card title="Body Battery" viewHref="/health"> <Card title="Body Battery" viewHref="/health">
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -135,13 +169,25 @@ function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) {
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} <XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')} tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(data.length / 6))} /> interval={Math.max(1, Math.floor(data.length / 6))} />
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} <YAxis domain={[hasActivities ? BB_ACTIVITY_BAND_BOTTOM : 0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }}
width={26} ticks={[0, 50, 100]} /> axisLine={false} tickLine={false} width={26} ticks={[0, 50, 100]} />
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }} <Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} /> labelFormatter={ts => format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} />
<Bar dataKey="level" isAnimationActive={false} radius={0}> <Bar dataKey="level" isAnimationActive={false} radius={0}>
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)} {data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
</Bar> </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 icon={sportIcon(a.sport_type)} />} />
)
})}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -587,7 +633,7 @@ export default function DashboardPage() {
} }
switch (id) { switch (id) {
case 'weekly': return <WeeklyChart activities={allActivities} /> case 'weekly': return <WeeklyChart activities={allActivities} />
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} /> case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} activities={allActivities} />
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} /> case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} />
case 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} /> case 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} />
case 'weight': return <WeightMini recentHealth={recentHealth} /> case 'weight': return <WeightMini recentHealth={recentHealth} />
+37 -21
View File
@@ -6,7 +6,7 @@ import {
} from 'recharts' } from 'recharts'
import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns' import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import { formatSleep, sportIcon } from '../utils/format' import { formatSleep, sportIcon, sportColor } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
import HrvBadge from '../components/ui/HrvBadge' import HrvBadge from '../components/ui/HrvBadge'
import SleepHypnogram from '../components/health/SleepHypnogram' import SleepHypnogram from '../components/health/SleepHypnogram'
@@ -186,14 +186,43 @@ function IntradayHrChart({ values }) {
function ActivityRefLabel({ viewBox, icon }) { function ActivityRefLabel({ viewBox, icon }) {
if (!viewBox) return null if (!viewBox) return null
const { x, y, width = 0 } = viewBox const { x, y, width = 0, height = 0 } = viewBox
return ( return (
<text x={x + width / 2} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}> <text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="central"
fontSize={13} style={{ pointerEvents: 'none' }}>
{icon} {icon}
</text> </text>
) )
} }
// Activity time spans are drawn as a solid coloured band in a reserved strip
// *below* the battery bars (the negative Y region) so they don't obscure the data.
const ACTIVITY_BAND_BOTTOM = -18
function ActivityBands({ activities, nearestT, sportColor, sportIcon }) {
return (activities || []).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={ACTIVITY_BAND_BOTTOM}
fill={color}
fillOpacity={0.9}
stroke={color}
strokeOpacity={1}
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
/>
)
})
}
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) { function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
if (!bb) return null if (!bb) return null
const { charged, drained, start_level, end_level } = bb const { charged, drained, start_level, end_level } = bb
@@ -225,6 +254,8 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
return best return best
} }
const hasActivities = (activities || []).length > 0
return ( return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3> <h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
@@ -250,7 +281,8 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} <XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')} tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(chartData.length / 6))} /> interval={Math.max(1, Math.floor(chartData.length / 6))} />
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} <YAxis domain={[hasActivities ? ACTIVITY_BAND_BOTTOM : 0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }}
axisLine={false} tickLine={false} width={28}
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} /> tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }} <Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')} labelFormatter={ts => format(new Date(ts), 'HH:mm')}
@@ -260,23 +292,7 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} /> <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
))} ))}
</Bar> </Bar>
{(activities || []).map(a => { <ActivityBands activities={activities} nearestT={nearestT} sportColor={sportColor} sportIcon={sportIcon} />
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
return (
<ReferenceArea
key={`area-${a.id}`}
x1={x1}
x2={x2}
fill="rgba(255,255,255,0.16)"
stroke="rgba(255,255,255,0.3)"
strokeWidth={1}
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
/>
)
})}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>