Round 2: body battery redesign, profile cleanup, segment integration, route/segment records
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 31s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Successful in 34s

- Body battery: replace circular ring with compact full-height colored bar chart,
  level as line overlay, legend shows only types present in data
- Dashboard: add mini body battery summary card above health today panel
- Profile: remove editable resting HR and manual weight log; show 7-day avg
  resting HR and latest Garmin weight as read-only
- Backend: add GET /routes/{id}/segment-bests bulk endpoint (fetches all matched
  activity data points in one query, computes best segment time per segment)
- Backend: add GET /records/routes for fastest activity per named route
- Routes page: add Segments panel to route detail (grouped as 1km splits vs
  hills/turns, best times, delete, theoretical best)
- Activity detail page: show segment times computed client-side from data points,
  🏆 badge if new best
- Records page: add Route Records and Segment Records tabs alongside Distance PRs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:14:00 +01:00
parent 02eccad578
commit 568dc31e97
8 changed files with 602 additions and 199 deletions
+45 -67
View File
@@ -71,88 +71,66 @@ function bbLevelColor(level) {
return '#ef4444'
}
function BatteryRing({ level }) {
if (level == null) return <span className="text-3xl font-bold text-gray-600">--</span>
const r = 38, stroke = 8
const c = 2 * Math.PI * r
const filled = c * (Math.min(100, Math.max(0, level)) / 100)
const color = bbLevelColor(level)
return (
<svg width="96" height="96" viewBox="0 0 96 96">
<circle cx="48" cy="48" r={r} fill="none" stroke="#1f2937" strokeWidth={stroke} />
<circle cx="48" cy="48" r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={`${filled} ${c - filled}`} strokeLinecap="round"
transform="rotate(-90 48 48)" />
<text x="48" y="44" textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize="20" fontWeight="bold">{Math.round(level)}</text>
<text x="48" y="62" textAnchor="middle" fill="#6b7280" fontSize="11">/ 100</text>
</svg>
)
}
function BodyBatteryChart({ bb }) {
if (!bb) return null
const { charged, drained, start_level, end_level, values } = bb
if (!values?.length && end_level == null) return null
const chartData = (values || []).map(([ts, level, type, stress]) => ({
const chartData = (values || []).map(([ts, level, type]) => ({
t: ts,
level,
type: type ?? 4,
bar: stress > 0 ? stress : (type === 2 ? 8 : type === 0 ? 20 : 35),
bar: 100,
}))
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4 h-full">
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
const presentTypes = [...new Set(chartData.map(d => d.type))]
const levelColor = bbLevelColor(end_level)
<div className="flex items-center gap-8">
<BatteryRing level={end_level} />
<div className="space-y-3">
{charged != null && (
<div>
<p className="text-xs text-gray-500">Charged</p>
<span className="text-xl font-semibold text-blue-400">+{charged}</span>
</div>
)}
{drained != null && (
<div>
<p className="text-xs text-gray-500">Drained</p>
<span className="text-xl font-semibold text-orange-400">-{drained}</span>
</div>
)}
{start_level != null && end_level != null && (
<p className="text-xs text-gray-500">{start_level} {end_level}</p>
)}
</div>
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>
<div className="flex items-baseline gap-3 flex-wrap mb-3">
{end_level != null && (
<span className="text-3xl font-bold" style={{ color: levelColor }}>{Math.round(end_level)}</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>
)}
{start_level != null && end_level != null && (
<span className="text-xs text-gray-500">{start_level} {end_level}</span>
)}
</div>
{chartData.length > 0 && (
<>
<ResponsiveContainer width="100%" height={110}>
<ComposedChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 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 tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={28} domain={[0, 100]} />
<Tooltip contentStyle={tooltipStyle}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : [Math.round(v), 'Stress']} />
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={8}>
{chartData.map((d, i) => (
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.7} />
))}
</Bar>
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={2}
dot={false} isAnimationActive={false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{Object.entries(BB_TYPE_LABEL).map(([code, label]) => (
<div key={code} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
<span className="text-xs text-gray-400">{label}</span>
<div className="flex-1">
<ResponsiveContainer width="100%" height={100}>
<ComposedChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 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))} />
<Tooltip contentStyle={tooltipStyle}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
{chartData.map((d, i) => (
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.8} />
))}
</Bar>
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
dot={false} isAnimationActive={false} connectNulls />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{presentTypes.map(code => (
<div key={code} className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
<span className="text-xs text-gray-500">{BB_TYPE_LABEL[code]}</span>
</div>
))}
</div>