Implemented all 9 UI fixes across health charts and activity detail pages. Changes are ready to push to git for the Docker build to pick them up.
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 1m9s
Build and push images / build-worker (push) Successful in 1m8s
Build and push images / build-frontend (push) Successful in 49s

This commit is contained in:
2026-06-07 19:57:25 +01:00
parent 67fd4b3c96
commit 45ff4c26aa
11 changed files with 1548 additions and 378 deletions
@@ -1,6 +1,9 @@
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
export default function LapTable({ laps, sportType }) {
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -12,7 +15,7 @@ export default function LapTable({ laps, sportType }) {
<th className="text-right pb-2 font-medium">Pace</th>
<th className="text-right pb-2 font-medium">Avg HR</th>
<th className="text-right pb-2 font-medium">Cadence</th>
<th className="text-right pb-2 font-medium">Power</th>
{showPower && <th className="text-right pb-2 font-medium">Power</th>}
</tr>
</thead>
<tbody>
@@ -28,9 +31,11 @@ export default function LapTable({ laps, sportType }) {
<td className="py-2 text-right text-gray-400">
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
</td>
{showPower && (
<td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
</td>
)}
</tr>
))}
</tbody>
+57 -62
View File
@@ -101,26 +101,28 @@ export default function ActivityDetailPage() {
</div>
</div>
{/* Primary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
{/* Stats — all on one row */}
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
</div>
{/* Secondary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
</div>
{/* HR Zones */}
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} />
</div>
)}
{/* Map with controls */}
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */}
@@ -165,14 +167,6 @@ export default function ActivityDetailPage() {
</div>
</div>
{/* HR Zones */}
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} />
</div>
)}
{/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
@@ -207,52 +201,53 @@ export default function ActivityDetailPage() {
)}
</div>
{/* Laps */}
{laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} />
</div>
)}
{/* Segments */}
{segments && segments.length > 0 && dataPoints && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
{/* Column headers */}
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Best</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Δ</span>
</div>
<div className="space-y-0.5">
{segments.map(seg => {
const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
const best = segmentBests?.find(b => b.segment_id === seg.id)
const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5
const delta = t != null && best?.best_s != null ? t - best.best_s : null
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/40 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className={`font-mono text-xs w-14 text-right ${isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{t != null ? formatDuration(t) : <span className="text-gray-700">--</span>}
</span>
<span className="font-mono text-xs w-14 text-right text-gray-500">
{best?.best_s != null ? formatDuration(best.best_s) : '--'}
</span>
<span className={`font-mono text-xs w-14 text-right ${
isNewBest ? 'text-yellow-400' : delta == null ? 'text-gray-700' : delta <= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</span>
</div>
)
})}
</div>
{/* Laps + Segments side by side */}
{((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} />
</div>
)}
{segments && segments.length > 0 && dataPoints && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Best</span>
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Δ</span>
</div>
<div className="space-y-0.5">
{segments.map(seg => {
const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
const best = segmentBests?.find(b => b.segment_id === seg.id)
const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5
const delta = t != null && best?.best_s != null ? t - best.best_s : null
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/40 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className={`font-mono text-xs w-14 text-right ${isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{t != null ? formatDuration(t) : <span className="text-gray-700">--</span>}
</span>
<span className="font-mono text-xs w-14 text-right text-gray-500">
{best?.best_s != null ? formatDuration(best.best_s) : '--'}
</span>
<span className={`font-mono text-xs w-14 text-right ${
isNewBest ? 'text-yellow-400' : delta == null ? 'text-gray-700' : delta <= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
</div>
+96 -41
View File
@@ -6,7 +6,7 @@ import {
} from 'recharts'
import { format, subDays } from 'date-fns'
import api from '../utils/api'
import { formatSleep } from '../utils/format'
import { formatSleep, sportIcon } from '../utils/format'
const RANGES = [
{ label: '1W', days: 7 },
@@ -91,7 +91,17 @@ function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
return 'stable'
}
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
function ActivityRefLabel({ viewBox, icon }) {
if (!viewBox) return null
const { x, y } = viewBox
return (
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
{icon}
</text>
)
}
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
@@ -112,14 +122,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
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
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>
{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>
@@ -127,18 +138,19 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
{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>
{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: 0 }} barCategoryGap={0}>
<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={[0, 100]} hide />
<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}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)}`, 'Battery']} />
@@ -147,6 +159,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
))}
</Bar>
{(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>
@@ -237,6 +258,26 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
)
}
function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
if (!total) return null
const segments = [
{ label: 'Deep', s: deepS || 0, color: '#6366f1' },
{ label: 'REM', s: remS || 0, color: '#8b5cf6' },
{ label: 'Light', s: lightS || 0, color: '#a78bfa' },
{ label: 'Awake', s: awakeS || 0, color: '#eab308' },
].filter(seg => seg.s > 0)
return (
<div className="mt-2">
<div className="flex h-5 rounded-sm overflow-hidden">
{segments.map(seg => (
<div key={seg.label} style={{ width: `${(seg.s / total) * 100}%`, backgroundColor: seg.color }} />
))}
</div>
</div>
)
}
function HrvBadge({ status }) {
if (!status) return null
const palette = {
@@ -263,7 +304,7 @@ function NavArrow({ onClick, disabled, children }) {
)
}
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, onOlder, onNewer, hasOlder, hasNewer }) {
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return (
<div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p>
@@ -323,10 +364,17 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
</div>
{hasSleepStages ? (
<>
<SleepHypnogram
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
stages={sleepStages}
/>
{sleepStages?.length ? (
<SleepHypnogram
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
stages={sleepStages}
/>
) : (
<SleepStageFallbackBar
deepS={day.sleep_deep_s} remS={day.sleep_rem_s}
lightS={day.sleep_light_s} awakeS={day.sleep_awake_s}
/>
)}
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
{[
['Deep', day.sleep_deep_s, '#6366f1'],
@@ -417,7 +465,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
</div>
</div>
)}
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} />
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
</div>
)}
@@ -472,28 +520,13 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
{day.spo2_avg ? (
<>
<p className="text-xs text-gray-500 mb-1">SpO2</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-sky-400">{day.spo2_avg.toFixed(1)}</span>
<span className="text-xs text-gray-500">%</span>
</div>
</>
) : day.vo2max ? (
<>
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-400">{day.vo2max.toFixed(1)}</span>
</div>
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
</>
) : (
<>
<p className="text-xs text-gray-500 mb-1">SpO2</p>
<span className="text-2xl font-bold text-white">--</span>
</>
)}
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-400">
{day.vo2max ? day.vo2max.toFixed(1) : '--'}
</span>
</div>
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
</div>
</div>
</div>
@@ -502,7 +535,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
// ── Trend Charts ────────────────────────────────────────────────────────────
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false }) {
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
@@ -528,12 +561,15 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={formatter} />
tickFormatter={formatter} domain={domain} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
{(referenceLines || []).map((rl, i) => (
<ReferenceLine key={i} {...rl} />
))}
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
@@ -643,6 +679,16 @@ export default function HealthPage() {
enabled: !!selectedDay?.date,
})
const { data: dayActivities } = useQuery({
queryKey: ['activities-day', selectedDay?.date],
queryFn: () => api.get('/activities/', { params: {
from_date: selectedDay.date + 'T00:00:00',
to_date: selectedDay.date + 'T23:59:59',
per_page: 20,
}}).then(r => r.data),
enabled: !!selectedDay?.date,
})
const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr))
const goOlder = () => {
if (selectedIdx < allDaysSorted.length - 1)
@@ -667,6 +713,7 @@ export default function HealthPage() {
bodyBattery={intradayData?.body_battery}
bbHires={intradayData?.body_battery_hires}
sleepStages={intradayData?.sleep_stages}
activities={dayActivities}
onOlder={goOlder}
onNewer={goNewer}
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
@@ -703,7 +750,8 @@ export default function HealthPage() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`}
formatter={v => Math.round(v)}
domain={[0, 200]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
@@ -711,7 +759,12 @@ export default function HealthPage() {
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
selectedDate={selDateForCharts} onDayClick={handleDayClick}
referenceLines={[
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
]}
/>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -769,13 +822,15 @@ export default function HealthPage() {
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
formatter={v => Math.round(v)}
domain={[0, 100]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`}
formatter={v => Math.round(v)}
domain={[0, 200]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
+4 -3
View File
@@ -226,8 +226,9 @@ function SegmentRecords() {
enabled: !!selectedRouteId,
})
const theoreticalBest = bests?.length && bests.every(b => b.best_s != null)
? bests.reduce((sum, b) => sum + b.best_s, 0)
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
: null
if (!routes?.length) return (
@@ -314,7 +315,7 @@ function SegmentRecords() {
</table>
{theoreticalBest != null && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-800 bg-gray-900/60">
<span className="text-xs text-gray-500">Theoretical best (sum of all segment bests)</span>
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
+4 -3
View File
@@ -37,8 +37,9 @@ function SegmentsPanel({ routeId, sportType }) {
const kmSplits = segments.filter(s => s.name.startsWith('km '))
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
const theoreticalBest = bests?.every(b => b.best_s != null)
? bests.reduce((sum, b) => sum + b.best_s, 0)
const kmBests = (bests || []).filter(b => b.name?.startsWith('km '))
const theoreticalBest = kmBests.length && kmBests.every(b => b.best_s != null)
? kmBests.reduce((sum, b) => sum + b.best_s, 0)
: null
const renderGroup = (group, title) => {
@@ -79,7 +80,7 @@ function SegmentsPanel({ routeId, sportType }) {
{renderGroup(hillsTurns, 'Hills & Turns')}
{theoreticalBest != null && (
<div className="flex items-center justify-between pt-1 border-t border-gray-800/50">
<span className="text-xs text-gray-500">Theoretical best (sum of segment bests)</span>
<span className="text-xs text-gray-500">Theoretical best (1km splits only)</span>
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}