Round 2: body battery redesign, profile cleanup, segment integration, route/segment records
- 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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user