From 471e43466c970e8c6eb210db59a9d477530ba58c Mon Sep 17 00:00:00 2001 From: owain Date: Thu, 18 Jun 2026 13:56:16 +0100 Subject: [PATCH] frontend: body battery activity overlays on dashboard; redesign highlight as coloured band below the data --- frontend/src/pages/DashboardPage.jsx | 56 ++++++++++++++++++++++++--- frontend/src/pages/HealthPage.jsx | 58 ++++++++++++++++++---------- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 78fb935..aa8570d 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -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 ( + + {icon} + + ) +} + +// 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 (
@@ -135,13 +169,25 @@ function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) { format(new Date(ts), 'HH:mm')} interval={Math.max(1, Math.floor(data.length / 6))} /> - + format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} /> {data.map((d, i) => )} + {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 ( + } /> + ) + })}
@@ -587,7 +633,7 @@ export default function DashboardPage() { } switch (id) { case 'weekly': return - case 'bodyBattery': return + case 'bodyBattery': return case 'vo2maxTrend': return case 'sleepDetail': return case 'weight': return diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index b695ddb..24fc86d 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -6,7 +6,7 @@ import { } from 'recharts' import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns' 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 HrvBadge from '../components/ui/HrvBadge' import SleepHypnogram from '../components/health/SleepHypnogram' @@ -186,14 +186,43 @@ function IntradayHrChart({ values }) { function ActivityRefLabel({ viewBox, icon }) { if (!viewBox) return null - const { x, y, width = 0 } = viewBox + const { x, y, width = 0, height = 0 } = viewBox return ( - + {icon} ) } +// 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 ( + } + /> + ) + }) +} + function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) { if (!bb) return null const { charged, drained, start_level, end_level } = bb @@ -225,6 +254,8 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) return best } + const hasActivities = (activities || []).length > 0 + return (

Body Battery

@@ -250,7 +281,8 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) format(new Date(ts), 'HH:mm')} interval={Math.max(1, Math.floor(chartData.length / 6))} /> - v} ticks={[0, 25, 50, 75, 100]} /> format(new Date(ts), 'HH:mm')} @@ -260,23 +292,7 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) ))} - {(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 - return ( - } - /> - ) - })} +