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
)}
-
+
)}