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 { 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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user