frontend: body battery activity overlays on dashboard; redesign highlight as coloured band below the data
This commit is contained in:
@@ -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,
|
||||
BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceArea,
|
||||
} from 'recharts'
|
||||
import GridLayout, { WidthProvider } from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
@@ -105,7 +105,23 @@ function Stat({ label, value }) {
|
||||
|
||||
// ── 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 sleepStartMs = sleepStart ? new Date(sleepStart).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 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">
|
||||
@@ -135,13 +169,25 @@ function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) {
|
||||
<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={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
width={26} ticks={[0, 50, 100]} />
|
||||
<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 icon={sportIcon(a.sport_type)} />} />
|
||||
)
|
||||
})}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -587,7 +633,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
switch (id) {
|
||||
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 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} />
|
||||
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
||||
|
||||
Reference in New Issue
Block a user