Fix follow-ups: lap bests, segments, charts, dashboard health
- Lap bests: compare against OTHER activities on the route (exclude self), so single-activity routes no longer show every lap as "best" - Segment create: POST to trailing-slash URL (was a 307 that dropped the body); surface errors in the UI - PR splits: scale GPS distance stream to the activity's official distance so over-measured GPS no longer yields bogus split PRs - Speed route colours: red->orange->green->blue->purple (slow->fast) with smooth interpolation + a Slow/Fast gradient key under the map - Health body battery: snap activity highlight to the categorical axis; white tooltip text + % suffix - Health weight: y-min = lowest weight - 20kg; st/lb hover shows total lbs too - Health sleep: move 8h/avg reference labels into the right margin - Dashboard: Health-today pulls latest non-null values (sleep score, VO2 max); body battery tile renders a condensed colour-graded intraday graph Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -214,9 +214,9 @@ function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
|
||||
|
||||
function ActivityRefLabel({ viewBox, icon }) {
|
||||
if (!viewBox) return null
|
||||
const { x, y } = viewBox
|
||||
const { x, y, width = 0 } = viewBox
|
||||
return (
|
||||
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
||||
<text x={x + width / 2} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
||||
{icon}
|
||||
</text>
|
||||
)
|
||||
@@ -245,6 +245,14 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
@@ -272,9 +280,9 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||
<YAxis domain={[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}
|
||||
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)}`, 'Battery']} />
|
||||
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]} />
|
||||
@@ -283,26 +291,20 @@ 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 (
|
||||
<ReferenceArea
|
||||
key={`area-${a.id}`}
|
||||
x1={start}
|
||||
x2={end}
|
||||
fill="rgba(255,255,255,0.12)"
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
x1={x1}
|
||||
x2={x2}
|
||||
fill="rgba(255,255,255,0.16)"
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth={1}
|
||||
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{(activities || []).map(a => (
|
||||
<ReferenceLine
|
||||
key={a.id}
|
||||
x={new Date(a.start_time).getTime()}
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth={1.5}
|
||||
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -741,7 +743,7 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||
margin={{ top: 4, right: 44, bottom: 4, left: 0 }}
|
||||
barSize={6}
|
||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||
onClick={evt => {
|
||||
@@ -759,10 +761,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
|
||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
)}
|
||||
<ReferenceLine y={8} stroke="#22c55e" strokeDasharray="4 3" strokeWidth={1.5}
|
||||
label={{ value: '8h', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 }} />
|
||||
label={{ value: '8h', position: 'right', fill: '#22c55e', fontSize: 9 }} />
|
||||
{avgSleep != null && (
|
||||
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
|
||||
label={{ value: `avg ${avgSleep}h`, position: 'insideBottomRight', fill: '#a855f7', fontSize: 9 }} />
|
||||
label={{ value: `avg ${avgSleep}h`, position: 'right', fill: '#a855f7', fontSize: 9 }} />
|
||||
)}
|
||||
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
||||
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
||||
@@ -819,11 +821,11 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
||||
}
|
||||
|
||||
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
|
||||
const minW = Math.min(...series.map(s => s.w))
|
||||
const minKg = Math.min(...withWeight.map(d => d.weight_kg))
|
||||
const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
|
||||
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent
|
||||
const yMin = Math.max(0, Math.floor(minW - (imperial ? 6 : 3)))
|
||||
const fmtVal = (v) => (imperial ? fmtStLb(v) : `${v.toFixed(1)} kg`)
|
||||
const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight − 20 kg equivalent
|
||||
const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -855,7 +857,7 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
||||
)}
|
||||
{goalU != null && (
|
||||
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
|
||||
label={{ value: `Goal ${fmtVal(goalU)}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
||||
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
||||
)}
|
||||
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
|
||||
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
|
||||
|
||||
Reference in New Issue
Block a user