frontend: share one BodyBatteryChart component across Health + Dashboard (Dashboard now shows mid-activity pause markers too)
This commit is contained in:
@@ -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 (
|
||||||
|
<SportIcon sport={sport} size={size} color="#fff"
|
||||||
|
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
|
||||||
|
style={{ pointerEvents: 'none' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<g style={{ pointerEvents: 'none' }}>
|
||||||
|
<rect x={cx - gap - barW} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
||||||
|
<rect x={cx + gap} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-baseline gap-3 flex-wrap">
|
||||||
|
{peak != null && (
|
||||||
|
<span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>
|
||||||
|
)}
|
||||||
|
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
||||||
|
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
|
||||||
|
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasGraph ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-h-0 mt-2">
|
||||||
|
<ResponsiveContainer width="100%" height={fill ? '100%' : height} minHeight={minHeight}>
|
||||||
|
<BarChart data={data} margin={{ top: 2, right: 4, bottom: 0, left: leftMargin }} barCategoryGap={0}>
|
||||||
|
<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={[hasActivities ? ACTIVITY_BAND_BOTTOM : 0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }}
|
||||||
|
axisLine={false} tickLine={false} width={yAxisWidth} ticks={yTicks} />
|
||||||
|
<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.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(
|
||||||
|
<ReferenceArea key={`area-${a.id}-${i}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
||||||
|
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
||||||
|
label={i === labelIdx ? <ActivityRefLabel sport={a.sport_type} size={iconSize} /> : 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(
|
||||||
|
<ReferenceArea key={`pause-${a.id}-${i}`} x1={g1} x2={g2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
||||||
|
fill={color} fillOpacity={0.12} stroke={color} strokeOpacity={0.7} strokeDasharray="3 3"
|
||||||
|
label={<PauseRefLabel />} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return els
|
||||||
|
})}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
|
{presentTypes.map(type => (
|
||||||
|
<div key={type} className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
||||||
|
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
emptyText ? <p className="text-xs text-gray-600 mt-3">{emptyText}</p> : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, ReferenceArea,
|
BarChart, Bar, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
} 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'
|
||||||
@@ -13,6 +13,7 @@ import { useIsMobile } from '../hooks/useMediaQuery'
|
|||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import HrvBadge from '../components/ui/HrvBadge'
|
import HrvBadge from '../components/ui/HrvBadge'
|
||||||
import SleepHypnogram from '../components/health/SleepHypnogram'
|
import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||||
|
import BodyBatteryChart from '../components/health/BodyBatteryChart'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||||
@@ -21,7 +22,6 @@ import {
|
|||||||
import { useUnit } from '../hooks/useUnits'
|
import { useUnit } from '../hooks/useUnits'
|
||||||
import SportIcon from '../components/ui/SportIcon'
|
import SportIcon from '../components/ui/SportIcon'
|
||||||
import { vo2Color } from '../utils/vo2'
|
import { vo2Color } from '../utils/vo2'
|
||||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
|
||||||
|
|
||||||
const Grid = WidthProvider(GridLayout)
|
const Grid = WidthProvider(GridLayout)
|
||||||
|
|
||||||
@@ -107,105 +107,15 @@ function Stat({ label, value }) {
|
|||||||
|
|
||||||
// ── Chart widgets ────────────────────────────────────────────────────────────
|
// ── Chart widgets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Sport icon centred inside the activity band that sits below the battery bars.
|
// Body Battery widget — wraps the shared BodyBatteryChart in a dashboard Card.
|
||||||
function BBActivityRefLabel({ viewBox, sport }) {
|
// The dashboard variant fills its grid cell (fill) and uses a compact y-axis.
|
||||||
if (!viewBox) return null
|
|
||||||
const { x, y, width = 0, height = 0 } = viewBox
|
|
||||||
const size = 13
|
|
||||||
return (
|
|
||||||
<SportIcon sport={sport} size={size} color="#fff"
|
|
||||||
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
|
|
||||||
style={{ pointerEvents: 'none' }} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 }) {
|
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 (
|
return (
|
||||||
<Card title="Body Battery" viewHref="/health">
|
<Card title="Body Battery" viewHref="/health">
|
||||||
<div className="flex flex-col h-full">
|
<BodyBatteryChart
|
||||||
<div className="flex items-baseline gap-3 flex-wrap">
|
bb={bb} hires={hires} sleepStart={sleepStart} sleepEnd={sleepEnd} activities={activities}
|
||||||
{peak != null && <span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>}
|
fill yTicks={[0, 50, 100]} yAxisWidth={26} leftMargin={0} iconSize={13}
|
||||||
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
|
emptyText="No body battery data today" />
|
||||||
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
|
|
||||||
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
|
|
||||||
</div>
|
|
||||||
{hasGraph ? (
|
|
||||||
<>
|
|
||||||
<div className="flex-1 min-h-0 mt-2">
|
|
||||||
<ResponsiveContainer width="100%" height="100%" minHeight={80}>
|
|
||||||
<BarChart data={data} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={0}>
|
|
||||||
<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={[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 sport={a.sport_type} />} />
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
|
||||||
{presentTypes.map(type => (
|
|
||||||
<div key={type} className="flex items-center gap-1">
|
|
||||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
|
||||||
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-600 mt-3">No body battery data today</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea,
|
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine,
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
} 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, sportColor } from '../utils/format'
|
import { formatSleep } from '../utils/format'
|
||||||
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 SportIcon from '../components/ui/SportIcon'
|
|
||||||
import { VO2_CATEGORIES, getVo2Category, vo2Thresholds, vo2Color } from '../utils/vo2'
|
import { VO2_CATEGORIES, getVo2Category, vo2Thresholds, vo2Color } from '../utils/vo2'
|
||||||
import SleepHypnogram from '../components/health/SleepHypnogram'
|
import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||||
|
import BodyBatteryChart from '../components/health/BodyBatteryChart'
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ label: '1W', days: 7 },
|
{ label: '1W', days: 7 },
|
||||||
@@ -148,154 +147,14 @@ function IntradayHrChart({ values }) {
|
|||||||
|
|
||||||
// ── Body Battery ─────────────────────────────────────────────────────────────
|
// ── Body Battery ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActivityRefLabel({ viewBox, sport }) {
|
// Health-page Body Battery panel — the shared BodyBatteryChart in this page's
|
||||||
if (!viewBox) return null
|
// card frame. Uses a fixed chart height (the panel has no intrinsic height).
|
||||||
const { x, y, width = 0, height = 0 } = viewBox
|
function BodyBatteryPanel({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
||||||
const size = 14
|
|
||||||
return (
|
|
||||||
<SportIcon sport={sport} size={size} color="#fff"
|
|
||||||
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
|
|
||||||
style={{ pointerEvents: 'none' }} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<g style={{ pointerEvents: 'none' }}>
|
|
||||||
<rect x={cx - gap - barW} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
|
||||||
<rect x={cx + gap} y={cy - barH / 2} width={barW} height={barH} fill="#fff" opacity={0.85} />
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
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>
|
||||||
|
<BodyBatteryChart
|
||||||
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
bb={bb} hires={hiresValues} sleepStart={sleepStart} sleepEnd={sleepEnd} activities={activities} />
|
||||||
{maxLevel != null && (
|
|
||||||
<span className="text-3xl font-bold" style={{ color: bbLevelColor(maxLevel) }}>{Math.round(maxLevel)}</span>
|
|
||||||
)}
|
|
||||||
{charged != null && (
|
|
||||||
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
|
||||||
)}
|
|
||||||
{drained != null && (
|
|
||||||
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
|
||||||
)}
|
|
||||||
{end_level != null && (
|
|
||||||
<span className="text-xs text-gray-500">now {Math.round(end_level)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
|
||||||
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 28 }} barCategoryGap={0}>
|
|
||||||
<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(chartData.length / 6))} />
|
|
||||||
<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]} />
|
|
||||||
<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}>
|
|
||||||
{chartData.map((d, i) => (
|
|
||||||
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
{(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(
|
|
||||||
<ReferenceArea key={`area-${a.id}-${i}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
|
||||||
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
|
||||||
label={i === labelIdx ? <ActivityRefLabel sport={a.sport_type} /> : 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(
|
|
||||||
<ReferenceArea key={`pause-${a.id}-${i}`} x1={g1} x2={g2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
|
||||||
fill={color} fillOpacity={0.12} stroke={color} strokeOpacity={0.7} strokeDasharray="3 3"
|
|
||||||
label={<PauseRefLabel />} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return els
|
|
||||||
})}
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
|
||||||
{presentTypes.map(type => (
|
|
||||||
<div key={type} className="flex items-center gap-1">
|
|
||||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
|
||||||
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -497,7 +356,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
<BodyBatteryPanel bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user