From 7d52b28f9d4dc462e5c3d78284259db29b2107d7 Mon Sep 17 00:00:00 2001 From: owain Date: Sat, 20 Jun 2026 20:16:06 +0100 Subject: [PATCH] frontend: share one BodyBatteryChart component across Health + Dashboard (Dashboard now shows mid-activity pause markers too) --- .../components/health/BodyBatteryChart.jsx | 169 ++++++++++++++++++ frontend/src/pages/DashboardPage.jsx | 106 +---------- frontend/src/pages/HealthPage.jsx | 161 ++--------------- 3 files changed, 187 insertions(+), 249 deletions(-) create mode 100644 frontend/src/components/health/BodyBatteryChart.jsx diff --git a/frontend/src/components/health/BodyBatteryChart.jsx b/frontend/src/components/health/BodyBatteryChart.jsx new file mode 100644 index 0000000..ec2c652 --- /dev/null +++ b/frontend/src/components/health/BodyBatteryChart.jsx @@ -0,0 +1,169 @@ +import { + BarChart, Bar, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceArea, +} from 'recharts' +import { format } from 'date-fns' +import SportIcon from '../ui/SportIcon' +import { sportColor } from '../../utils/format' +import { + BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType, +} from '../../utils/bodyBattery' + +const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' } + +// 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 data. +const ACTIVITY_BAND_BOTTOM = -18 + +function ActivityRefLabel({ viewBox, sport, size = 14 }) { + if (!viewBox) return null + const { x, y, width = 0, height = 0 } = viewBox + return ( + + ) +} + +// A small ⏸ glyph drawn over the gap between two active spans, marking where +// the recording was paused mid-activity (e.g. a long lunch break on a ride). +function PauseRefLabel({ viewBox }) { + if (!viewBox) return null + const { x, y, width = 0, height = 0 } = viewBox + const cx = x + width / 2, cy = y + height / 2 + const barW = 2, barH = 8, gap = 1.5 + return ( + + + + + ) +} + +// Shared Body Battery bar chart used by both the Health page and the Dashboard +// widget. Renders the stats header, the coloured battery bars, an activity band +// strip below them (splitting on mid-activity pauses via `active_spans`), and a +// state legend. The caller supplies the outer frame (panel / Card). Props tune +// the few cosmetic differences between the two placements. +export default function BodyBatteryChart({ + bb, hires, sleepStart, sleepEnd, activities, + yTicks = [0, 25, 50, 75, 100], yAxisWidth = 28, leftMargin = 28, + fill = false, height = 100, minHeight = 80, iconSize = 14, + emptyText = null, +}) { + 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 presentTypes = [...new Set(data.map(d => d.type))] + const hasGraph = data.length >= 2 + + // Nothing at all to show. + if (!hasGraph && peak == null && end_level == null) return null + + // Only activities overlapping the battery samples for this day get a band. + 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 (band scale), so overlays must snap to a sample + // that exists in the data. + 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 ( +
+
+ {peak != null && ( + {Math.round(peak)} + )} + {charged != null && +{charged}} + {drained != null && -{drained}} + {end_level != null && now {Math.round(end_level)}} +
+ + {hasGraph ? ( + <> +
+ + + 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.flatMap(a => { + const color = sportColor(a.sport_type) + const start = new Date(a.start_time).getTime() + const fullEnd = a.duration_s ? start + a.duration_s * 1000 : start + // active_spans (set only when a long pause splits the + // recording) draws one band per moving span with the pause + // shown as a gap; otherwise one continuous band. + const spans = (a.active_spans && a.active_spans.length > 1) + ? a.active_spans + : [[start, fullEnd]] + // Carry the sport icon on the longest span only. + let labelIdx = 0, labelLen = -1 + spans.forEach((s, i) => { const l = s[1] - s[0]; if (l > labelLen) { labelLen = l; labelIdx = i } }) + const els = [] + spans.forEach((s, i) => { + const x1 = nearestT(s[0]), x2 = nearestT(s[1]) + if (x1 != null && x2 != null) { + els.push( + : undefined} /> + ) + } + // Mark the paused stretch before the next span. + if (i < spans.length - 1) { + const g1 = nearestT(s[1]), g2 = nearestT(spans[i + 1][0]) + if (g1 != null && g2 != null && g1 !== g2) { + els.push( + } /> + ) + } + } + }) + return els + })} + + +
+
+ {presentTypes.map(type => ( +
+
+ {BB_INFERRED_LABEL[type]} +
+ ))} +
+ + ) : ( + emptyText ?

{emptyText}

: null + )} +
+ ) +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 5578a32..6966de0 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, 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 ( - - ) -} - -// 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 ( -
-
- {peak != null && {Math.round(peak)}} - {charged != null && +{charged}} - {drained != null && -{drained}} - {end_level != null && now {Math.round(end_level)}} -
- {hasGraph ? ( - <> -
- - - 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 ( - } /> - ) - })} - - -
-
- {presentTypes.map(type => ( -
-
- {BB_INFERRED_LABEL[type]} -
- ))} -
- - ) : ( -

No body battery data today

- )} -
+ ) } diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index f992705..a1b7a65 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -1,17 +1,16 @@ import { useState, useMemo } from 'react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { - AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea, - XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, + AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts' import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns' import api from '../utils/api' -import { formatSleep, sportColor } from '../utils/format' -import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' +import { formatSleep } from '../utils/format' import HrvBadge from '../components/ui/HrvBadge' -import SportIcon from '../components/ui/SportIcon' import { VO2_CATEGORIES, getVo2Category, vo2Thresholds, vo2Color } from '../utils/vo2' import SleepHypnogram from '../components/health/SleepHypnogram' +import BodyBatteryChart from '../components/health/BodyBatteryChart' const RANGES = [ { label: '1W', days: 7 }, @@ -148,154 +147,14 @@ function IntradayHrChart({ values }) { // ── Body Battery ───────────────────────────────────────────────────────────── -function ActivityRefLabel({ viewBox, sport }) { - if (!viewBox) return null - const { x, y, width = 0, height = 0 } = viewBox - const size = 14 - return ( - - ) -} - -// A small ⏸ glyph drawn over the gap between two active spans, marking where -// the recording was paused mid-activity (e.g. a long lunch break on a ride). -function PauseRefLabel({ viewBox }) { - if (!viewBox) return null - const { x, y, width = 0, height = 0 } = viewBox - const cx = x + width / 2, cy = y + height / 2 - const barW = 2, barH = 8, gap = 1.5 - return ( - - - - - ) -} - -// 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 BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) { - if (!bb) return null - const { charged, drained, start_level, end_level } = bb - if (!hiresValues?.length && !bb.values?.length && end_level == null) return null - - const rawData = hiresValues?.length - ? hiresValues.map(([ts, level]) => ({ t: ts, level })) - : (bb.values || []).map(([ts, level]) => ({ t: ts, level })) - - if (!rawData.length) return null - - const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null - const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null - - const chartData = rawData.map((d, i) => ({ - ...d, - type: inferBBType(d.t, d.level, i > 0 ? rawData[i - 1].level : null, sleepStartMs, sleepEndMs), - })) - - const presentTypes = [...new Set(chartData.map(d => d.type))] - const levelColor = bbLevelColor(end_level) - const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null - - // The X axis is categorical (band scale), so overlays must use values that - // exist in the data — snap activity start/end to the nearest sample. - const nearestT = (ms) => { - let best = null, bd = Infinity - for (const d of chartData) { const dd = Math.abs(d.t - ms); if (dd < bd) { bd = dd; best = d.t } } - return best - } - - const hasActivities = (activities || []).length > 0 - +// Health-page Body Battery panel — the shared BodyBatteryChart in this page's +// card frame. Uses a fixed chart height (the panel has no intrinsic height). +function BodyBatteryPanel({ bb, hiresValues, sleepStart, sleepEnd, activities }) { return (

Body Battery

- -
- {maxLevel != null && ( - {Math.round(maxLevel)} - )} - {charged != null && ( - +{charged} - )} - {drained != null && ( - -{drained} - )} - {end_level != null && ( - now {Math.round(end_level)} - )} -
- -
- - - 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')} - formatter={v => [`${Math.round(v)}%`, 'Battery']} /> - - {chartData.map((d, i) => ( - - ))} - - {(activities || []).flatMap(a => { - const color = sportColor(a.sport_type) - const start = new Date(a.start_time).getTime() - const fullEnd = a.duration_s ? start + a.duration_s * 1000 : start - // active_spans (set only when a long pause splits the recording) - // draws one band per moving span with the pause shown as a gap; - // otherwise one continuous band as before. - const spans = (a.active_spans && a.active_spans.length > 1) - ? a.active_spans - : [[start, fullEnd]] - // Carry the sport icon on the longest span only. - let labelIdx = 0, labelLen = -1 - spans.forEach((s, i) => { const l = s[1] - s[0]; if (l > labelLen) { labelLen = l; labelIdx = i } }) - const els = [] - spans.forEach((s, i) => { - const x1 = nearestT(s[0]), x2 = nearestT(s[1]) - if (x1 != null && x2 != null) { - els.push( - : undefined} /> - ) - } - // Mark the paused stretch before the next span with a faint dashed strip + ⏸. - if (i < spans.length - 1) { - const g1 = nearestT(s[1]), g2 = nearestT(spans[i + 1][0]) - if (g1 != null && g2 != null && g1 !== g2) { - els.push( - } /> - ) - } - } - }) - return els - })} - - -
- -
- {presentTypes.map(type => ( -
-
- {BB_INFERRED_LABEL[type]} -
- ))} -
+
) } @@ -497,7 +356,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
)} - +
)}