feat: avg pace over moving time (fixes elapsed-based pace on paused activities); show mid-activity pauses on Body Battery band via active_spans
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

This commit is contained in:
2026-06-20 20:04:53 +01:00
parent 0e1d35364f
commit e33946e270
6 changed files with 111 additions and 14 deletions
+50 -10
View File
@@ -159,6 +159,21 @@ function ActivityRefLabel({ viewBox, sport }) {
)
}
// 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
@@ -232,17 +247,42 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
))}
</Bar>
{(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
{(activities || []).flatMap(a => {
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 sport={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>