Batch 1: dashboard, maps, segments rewrite, health, sync UX
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s

Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
  weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar

Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
  1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows

Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:59:06 +01:00
parent e5feeb1178
commit bc437cce92
24 changed files with 1339 additions and 1445 deletions
+101 -55
View File
@@ -1,25 +1,27 @@
import { useParams, Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useMemo } from 'react'
import api from '../utils/api'
import ActivityMap from '../components/activity/ActivityMap'
import MetricTimeline from '../components/activity/MetricTimeline'
import HRZoneBar from '../components/activity/HRZoneBar'
import LapTable from '../components/activity/LapTable'
import SegmentsPanel from '../components/activity/SegmentsPanel'
import StatCard from '../components/ui/StatCard'
import {
formatDuration, formatDistance, formatPace, formatElevation,
formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format'
function segmentTime(points, startM, endM) {
let t0 = null
// Find the cumulative distance along the track nearest a clicked lat/lng.
function nearestDistance(points, lat, lng) {
let best = null, bestD = Infinity
for (const p of points) {
if (t0 === null && p.distance_m >= startM) t0 = new Date(p.timestamp).getTime()
if (t0 !== null && p.distance_m >= endM)
return (new Date(p.timestamp).getTime() - t0) / 1000
if (p.latitude == null || p.longitude == null || p.distance_m == null) continue
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
if (d < bestD) { bestD = d; best = p.distance_m }
}
return null
return best
}
const METRICS = [
@@ -36,7 +38,12 @@ export default function ActivityDetailPage() {
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
const [hoveredDistance, setHoveredDistance] = useState(null)
const [mapHeight, setMapHeight] = useState(420)
const [mapType, setMapType] = useState('dark')
const [mapType, setMapType] = useState('street')
const [colorMode, setColorMode] = useState('speed')
const [segCreate, setSegCreate] = useState(false)
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
const [segName, setSegName] = useState('')
const qc = useQueryClient()
const { data: activity, isLoading } = useQuery({
queryKey: ['activity', id],
@@ -55,17 +62,36 @@ export default function ActivityDetailPage() {
enabled: !!activity,
})
const { data: segments } = useQuery({
queryKey: ['segments', activity?.named_route_id],
queryFn: () => api.get(`/routes/${activity.named_route_id}/segments`).then(r => r.data),
const { data: actSegments } = useQuery({
queryKey: ['activity-segments', id],
queryFn: () => api.get(`/segments/by-activity/${id}`).then(r => r.data),
enabled: !!activity,
})
const { data: lapBests } = useQuery({
queryKey: ['lap-bests', id],
queryFn: () => api.get(`/activities/${id}/lap-bests`).then(r => r.data),
enabled: !!activity?.named_route_id,
})
const { data: segmentBests } = useQuery({
queryKey: ['segment-bests', activity?.named_route_id],
queryFn: () => api.get(`/routes/${activity.named_route_id}/segment-bests`).then(r => r.data),
enabled: !!activity?.named_route_id,
})
const handleMapClick = ({ lat, lng }) => {
if (!segCreate || !dataPoints) return
const dist = nearestDistance(dataPoints, lat, lng)
if (dist == null) return
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
}
const createSegment = async () => {
const [a, b] = segPoints
await api.post('/segments', {
name: segName.trim() || 'Segment',
activity_id: Number(id),
start_distance_m: a.distance_m,
end_distance_m: b.distance_m,
})
setSegCreate(false); setSegPoints([]); setSegName('')
qc.invalidateQueries({ queryKey: ['activity-segments', id] })
}
const toggleMetric = (key) => {
setActiveMetrics(prev =>
@@ -140,9 +166,31 @@ export default function ActivityDetailPage() {
{t}
</button>
))}
{dataPoints?.length > 0 && (
<button
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ml-2 ${
segCreate ? 'bg-green-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
+ Segment
</button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Height:</span>
<span className="text-xs text-gray-500">Route:</span>
{[['speed', 'Speed'], ['solid', 'Solid']].map(([mode, label]) => (
<button
key={mode}
onClick={() => setColorMode(mode)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
colorMode === mode ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{label}
</button>
))}
<span className="text-xs text-gray-500 ml-2">Height:</span>
{[280, 420, 560].map(h => (
<button
key={h}
@@ -156,6 +204,34 @@ export default function ActivityDetailPage() {
))}
</div>
</div>
{segCreate && (
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
<span className="text-green-400">
Click two points on the route to mark the segment start and end.
</span>
<span className="text-gray-400">
Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'}
{' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'}
</span>
{segPoints.length === 2 && (
<>
<input
value={segName}
onChange={e => setSegName(e.target.value)}
placeholder="Segment name"
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-white focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<button onClick={createSegment} disabled={!segName.trim()}
className="bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white px-3 py-1 rounded-lg">
Create
</button>
</>
)}
{segPoints.length > 0 && (
<button onClick={() => setSegPoints([])} className="text-gray-400 hover:text-white">Reset</button>
)}
</div>
)}
<div style={{ height: mapHeight }}>
<ActivityMap
polyline={activity.polyline}
@@ -163,6 +239,8 @@ export default function ActivityDetailPage() {
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
mapType={mapType}
colorMode={colorMode}
onMapClick={segCreate ? handleMapClick : undefined}
/>
</div>
</div>
@@ -202,50 +280,18 @@ export default function ActivityDetailPage() {
</div>
{/* Laps + Segments side by side */}
{((laps && laps.length > 0) || (segments && segments.length > 0 && dataPoints)) && (
{((laps && laps.length > 0) || (actSegments && actSegments.length > 0)) && (
<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} />
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
</div>
)}
{segments && segments.length > 0 && dataPoints && (
{actSegments && actSegments.length > 0 && (
<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>
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<SegmentsPanel segments={actSegments} />
</div>
)}
</div>
+43 -1
View File
@@ -4,11 +4,21 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContaine
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api'
import StatCard from '../components/ui/StatCard'
import ActivityMap from '../components/activity/ActivityMap'
import {
formatDuration, formatDistance, formatPace, formatHeartRate,
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
formatDate, sportIcon, formatSleep,
} from '../utils/format'
function Stat({ label, value }) {
return (
<div className="bg-gray-900 px-4 py-3 flex flex-col justify-center">
<p className="text-xs text-gray-500">{label}</p>
<p className="text-lg font-semibold text-white">{value}</p>
</div>
)
}
function bbLevelColor(level) {
if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6'
@@ -154,6 +164,7 @@ export default function DashboardPage() {
})
const latest = healthSummary?.latest
const featured = recentActivities?.[0]
return (
<div className="p-6 space-y-6">
@@ -203,6 +214,37 @@ export default function DashboardPage() {
</div>
</div>
{/* Featured most-recent activity */}
{featured && (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl">{sportIcon(featured.sport_type)}</span>
<div className="min-w-0">
<Link to={`/activities/${featured.id}`} className="text-sm font-semibold text-white hover:text-blue-400 transition-colors truncate block">
{featured.name}
</Link>
<p className="text-xs text-gray-500">{formatDate(featured.start_time)}</p>
</div>
</div>
<Link to={`/activities/${featured.id}`} className="text-xs text-blue-400 hover:underline flex-shrink-0">Open </Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3">
<div className="lg:col-span-2 h-64 bg-gray-950">
{featured.polyline
? <ActivityMap polyline={featured.polyline} sportType={featured.sport_type} colorMode="solid" />
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
</div>
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50">
<Stat label="Distance" value={formatDistance(featured.distance_m)} />
<Stat label="Elevation ↑" value={formatElevation(featured.elevation_gain_m)} />
<Stat label="Moving time" value={formatDuration(featured.duration_s)} />
<Stat label="Calories" value={featured.calories ? `${Math.round(featured.calories)} kcal` : '--'} />
</div>
</div>
</div>
)}
{/* Recent activities */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
+140 -12
View File
@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import {
AreaChart, Area, BarChart, Bar, ReferenceLine,
AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'
import { format, subDays } from 'date-fns'
@@ -280,6 +280,20 @@ 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
return (
<ReferenceArea
key={`area-${a.id}`}
x1={start}
x2={end}
fill="rgba(255,255,255,0.12)"
stroke="rgba(255,255,255,0.25)"
strokeWidth={1}
/>
)
})}
{(activities || []).map(a => (
<ReferenceLine
key={a.id}
@@ -425,7 +439,7 @@ function NavArrow({ onClick, disabled, children }) {
)
}
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return (
<div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p>
@@ -562,11 +576,14 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-xl font-semibold text-emerald-400">
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
{snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
</span>
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>}
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
{snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
{snapshotWeight?.fat && !snapshotWeight.carried && <span className="text-xs text-gray-500">{snapshotWeight.fat.toFixed(1)}% fat</span>}
</div>
{snapshotWeight?.carried && (
<p className="text-xs text-gray-600 mt-0.5">as of {format(new Date(snapshotWeight.date), 'd MMM')}</p>
)}
</div>
</div>
</div>
@@ -716,6 +733,10 @@ function SleepChart({ data, selectedDate, onDayClick }) {
if (!hasData) return (
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
)
const totals = chartData
.map(d => (d.deep || 0) + (d.rem || 0) + (d.light || 0) + (d.awake || 0))
.filter(t => t > 0)
const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart
@@ -737,6 +758,12 @@ function SleepChart({ data, selectedDate, onDayClick }) {
{selectedDate && (
<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 }} />
{avgSleep != null && (
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
label={{ value: `avg ${avgSleep}h`, position: 'insideBottomRight', fill: '#a855f7', fontSize: 9 }} />
)}
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
@@ -746,6 +773,99 @@ function SleepChart({ data, selectedDate, onDayClick }) {
)
}
// ── Weight (with goal line + kg ⇄ st/lb toggle) ──────────────────────────────
const KG_TO_LB = 2.2046226218
function fmtStLb(lb) {
let st = Math.floor(lb / 14)
let r = Math.round(lb - st * 14)
if (r === 14) { st += 1; r = 0 }
return `${st} st ${r} lb`
}
function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
const [unit, setUnit] = useState(() => localStorage.getItem('weightUnit') || 'kg')
const choose = (u) => { setUnit(u); localStorage.setItem('weightUnit', u) }
const imperial = unit === 'lb'
const toU = (kg) => (imperial ? kg * KG_TO_LB : kg)
const withWeight = data.filter(d => d.weight_kg != null)
const series = withWeight.map(d => ({ date: d.date, w: +toU(d.weight_kg).toFixed(2) }))
const title = imperial ? 'Weight (st & lb)' : 'Weight (kg)'
const toggle = (
<div className="flex gap-1">
{[['kg', 'kg'], ['lb', 'st/lb']].map(([u, label]) => (
<button key={u} onClick={() => choose(u)}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
unit === u ? 'bg-blue-600 text-white' : 'text-gray-400 bg-gray-800 hover:text-white'
}`}>
{label}
</button>
))}
</div>
)
if (!series.length) {
return (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No weight data</div>
</>
)
}
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
const minW = Math.min(...series.map(s => s.w))
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`)
return (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => {
const p = evt?.activePayload?.[0]?.payload
if (p?.date && onDayClick) onDayClick(p.date)
}}>
<defs>
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis domain={[yMin, yMax]} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={36} tickFormatter={v => Math.round(v)} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [fmtVal(v), 'Weight']} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
{goalU != null && (
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
label={{ value: `Goal ${fmtVal(goalU)}`, 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 }}
connectNulls isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</>
)
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function HealthPage() {
@@ -809,6 +929,15 @@ export default function HealthPage() {
return found ? found.vo2max : null
}, [allDaysSorted])
// Weight for the snapshot: the selected day's, or the most recent earlier reading.
const snapshotWeight = useMemo(() => {
if (!selectedDay) return null
if (selectedDay.weight_kg != null)
return { kg: selectedDay.weight_kg, fat: selectedDay.body_fat_pct, carried: false }
const earlier = allDaysSorted.find(d => d.weight_kg != null && d.date <= selectedDay.date)
return earlier ? { kg: earlier.weight_kg, fat: earlier.body_fat_pct, carried: true, date: earlier.date } : null
}, [selectedDay, allDaysSorted])
const { data: intradayData } = useQuery({
queryKey: ['health-intraday', selectedDay?.date],
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
@@ -844,6 +973,7 @@ export default function HealthPage() {
<DailySnapshot
day={selectedDay}
snapshotWeight={snapshotWeight}
avg30={summary?.avg_30d}
intradayHr={intradayData?.hr_values}
bodyBattery={intradayData?.body_battery}
@@ -921,13 +1051,10 @@ export default function HealthPage() {
</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">Weight</h3>
<MetricChart
data={metrics.filter(d => d.weight_kg != null)}
dataKey="weight_kg" color="#34d399"
formatter={v => `${v.toFixed(1)} kg`}
selectedDate={selDateForCharts} onDayClick={handleDayClick}
connectNulls showDots />
<WeightChart
data={metrics}
goalKg={profile?.goal_weight_kg}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -989,6 +1116,7 @@ export default function HealthPage() {
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
formatter={v => v.toFixed(1)}
domain={[30, 70]}
connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
+20 -87
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
function Section({ title, children }) {
return (
@@ -77,25 +78,13 @@ export default function ProfilePage() {
enabled: !!user?.is_admin,
})
const { data: recentMetrics } = useQuery({
queryKey: ['health-metrics-recent'],
queryFn: () => api.get('/health-metrics/', { params: { limit: 7 } }).then(r => r.data),
})
const { data: healthSummary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const avgRestingHr = useMemo(() => {
if (!recentMetrics?.length) return null
const vals = recentMetrics.filter(m => m.resting_hr != null).map(m => m.resting_hr)
if (!vals.length) return null
return Math.round(vals.reduce((s, v) => s + v, 0) / vals.length)
}, [recentMetrics])
// HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '' })
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '', goal_weight_kg: '' })
const [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false)
@@ -105,6 +94,7 @@ export default function ProfilePage() {
birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '',
biological_sex: profile.biological_sex || '',
goal_weight_kg: profile.goal_weight_kg || '',
})
}, [profile])
@@ -140,10 +130,8 @@ export default function ProfilePage() {
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('')
const [gcSyncing, setGcSyncing] = useState(false)
const syncPollRef = useRef(null)
const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync } = useSyncStore()
const gcFormLoaded = useRef(false)
useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, [])
useEffect(() => {
if (garminConfig?.connected && !gcFormLoaded.current) {
gcFormLoaded.current = true
@@ -177,54 +165,6 @@ export default function ProfilePage() {
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
},
})
const triggerGarminSync = async () => {
setGcSyncing(true)
try {
await api.post('/garmin-sync/trigger')
// Poll every 3s: wait until we've seen an in-progress status, then wait for terminal
let seenInProgress = false
syncPollRef.current = setInterval(async () => {
const result = await refetchGarmin()
const status = result.data?.last_sync_status ?? ''
const terminal = status.startsWith('OK') || status.startsWith('Partial') || status.startsWith('Auth error')
if (!terminal) seenInProgress = true
if (seenInProgress && terminal) {
clearInterval(syncPollRef.current)
syncPollRef.current = null
setGcSyncing(false)
}
}, 3000)
// Absolute safety: stop polling after 4 hours but keep bar visible — sync may still be running
setTimeout(() => {
if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null }
}, 4 * 60 * 60 * 1000)
} catch {
setGcSyncing(false)
}
}
const syncProgressPct = status => {
if (!status) return 3
if (status.startsWith('Connecting')) return 10
if (status.startsWith('Syncing activities')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 15 + Math.round(done / total * 30)
}
return 20
}
if (status.startsWith('Syncing wellness')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m) {
const done = parseInt(m[1], 10), total = parseInt(m[2], 10)
if (total > 0) return 45 + Math.round(done / total * 45)
}
return 50
}
return 3
}
// PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
const [pidSaved, setPidSaved] = useState(false)
@@ -285,22 +225,18 @@ export default function ProfilePage() {
</Field>
</div>
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
<div className="flex gap-6 pt-3 border-t border-gray-800">
{avgRestingHr && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Resting HR (7-day avg, from Garmin)</p>
<span className="text-lg font-semibold text-rose-400">{avgRestingHr} bpm</span>
</div>
)}
{healthSummary?.latest?.weight_kg && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Weight (from Garmin)</p>
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800">
<Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
<Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
</Field>
{healthSummary?.latest?.weight_kg && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
</div>
)}
</div>
<SaveButton
onClick={() => {
@@ -449,7 +385,7 @@ export default function ProfilePage() {
{garminConfig?.connected && (
<>
<button
onClick={triggerGarminSync}
onClick={triggerSync}
disabled={gcSyncing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
@@ -464,12 +400,9 @@ export default function ProfilePage() {
</div>
{gcSyncing && (() => {
const status = garminConfig?.last_sync_status || ''
const status = syncStatus || ''
const pct = syncProgressPct(status)
const phase = status.startsWith('Connecting') ? 0
: status.startsWith('Syncing activities') ? 1
: status.startsWith('Syncing wellness') ? 2
: status.startsWith('OK') || status.startsWith('Partial') ? 3 : -1
const phase = syncPhase(status)
return (
<div className="space-y-2 pt-1">
<div className="flex items-center gap-1 text-xs">
+2 -117
View File
@@ -14,7 +14,7 @@ const DISTANCE_ORDER = [
'Half marathon', 'Marathon', '50k', '100k',
]
const TABS = ['Distance PRs', 'Route Records', 'Segment Records']
const TABS = ['Distance PRs', 'Route Records']
function DistancePRs() {
const [sport, setSport] = useState('running')
@@ -122,7 +122,7 @@ function DistancePRs() {
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM yy')} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={40} tickFormatter={formatDuration} reversed />
width={40} tickFormatter={formatDuration} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
@@ -212,120 +212,6 @@ function RouteRecords() {
)
}
function SegmentRecords() {
const [selectedRouteId, setSelectedRouteId] = useState(null)
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const { data: bests, isLoading } = useQuery({
queryKey: ['segment-bests', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segment-bests`).then(r => r.data),
enabled: !!selectedRouteId,
})
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 (
<p className="text-sm text-gray-600">
No named routes yet.{' '}
<Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link>
</p>
)
return (
<div className="space-y-4">
{/* Route tile grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
</button>
))}
</div>
{selectedRouteId && (
isLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : !bests?.length ? (
<p className="text-gray-600 text-sm">
No segments for this route.{' '}
<Link to="/segments" className="text-blue-400 hover:underline">Create some on the Segments page.</Link>
</p>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="text-left px-4 py-3 font-medium">Segment</th>
<th className="text-right px-4 py-3 font-medium">Length</th>
<th className="text-right px-4 py-3 font-medium">Best time</th>
<th className="text-right px-4 py-3 font-medium">Runs</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{bests.map(b => (
<tr key={b.segment_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
<td className="px-4 py-3 text-gray-200">
{b.name}
{b.auto_generated && <span className="ml-2 text-xs text-gray-600">(auto)</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">
{formatDistance(b.end_distance_m - b.start_distance_m)}
</td>
<td className="px-4 py-3 text-right font-mono font-semibold">
{b.best_s != null
? <span className="text-yellow-400">{formatDuration(b.best_s)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="px-4 py-3 text-right text-gray-500 text-xs">{b.count}</td>
<td className="px-4 py-3 text-right">
{b.best_activity_id && (
<Link to={`/activities/${b.best_activity_id}`} className="text-xs text-blue-400 hover:underline">
View
</Link>
)}
</td>
</tr>
))}
</tbody>
</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 (1km splits only)</span>
<span className="font-mono text-sm font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
)}
</div>
)
}
export default function RecordsPage() {
const [tab, setTab] = useState('Distance PRs')
@@ -351,7 +237,6 @@ export default function RecordsPage() {
{tab === 'Distance PRs' && <DistancePRs />}
{tab === 'Route Records' && <RouteRecords />}
{tab === 'Segment Records' && <SegmentRecords />}
</div>
)
}
+179 -223
View File
@@ -2,92 +2,9 @@ import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import ActivityMap from '../components/activity/ActivityMap'
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
function formatSegDist(m) {
if (m == null) return '--'
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
function SegmentsPanel({ routeId, sportType }) {
const qc = useQueryClient()
const { data: segments } = useQuery({
queryKey: ['segments', routeId],
queryFn: () => api.get(`/routes/${routeId}/segments`).then(r => r.data),
})
const { data: bests } = useQuery({
queryKey: ['segment-bests', routeId],
queryFn: () => api.get(`/routes/${routeId}/segment-bests`).then(r => r.data),
})
const deleteSeg = useMutation({
mutationFn: segId => api.delete(`/routes/${routeId}/segments/${segId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['segments', routeId] })
qc.invalidateQueries({ queryKey: ['segment-bests', routeId] })
},
})
if (!segments?.length) return null
const bestMap = Object.fromEntries((bests || []).map(b => [b.segment_id, b]))
const kmSplits = segments.filter(s => s.name.startsWith('km '))
const hillsTurns = segments.filter(s => !s.name.startsWith('km '))
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) => {
if (!group.length) return null
return (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{title}</p>
{group.map(seg => {
const best = bestMap[seg.id]
return (
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className="text-gray-600 text-xs">{formatSegDist(seg.end_distance_m - seg.start_distance_m)}</span>
{best?.best_s != null ? (
<span className="font-mono text-yellow-400 text-xs w-14 text-right">{formatDuration(best.best_s)}</span>
) : (
<span className="text-gray-700 text-xs w-14 text-right">--</span>
)}
<button
onClick={() => { if (confirm(`Delete "${seg.name}"?`)) deleteSeg.mutate(seg.id) }}
className="text-gray-700 hover:text-red-400 transition-colors text-xs ml-1"
title="Delete segment"
></button>
</div>
)
})}
</div>
)
}
return (
<div className="border-t border-gray-800 pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-400">Segments</h3>
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage </Link>
</div>
{renderGroup(kmSplits, '1km Splits')}
{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 (1km splits only)</span>
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
</div>
)}
</div>
)
}
// Decode Google encoded polyline to [[lat,lng], ...]
function decodePolyline(encoded) {
if (!encoded) return []
@@ -134,47 +51,37 @@ function RouteMap({ polyline, className = '', sportType = '' }) {
function routeSportStyle(sportType) {
const t = (sportType || '').toLowerCase()
if (t.includes('cycl') || t.includes('bike') || t.includes('ride'))
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400', color: '#f97316' }
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400' }
if (t.includes('run') || t.includes('jog') || t.includes('walk'))
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400', color: '#3b82f6' }
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400', color: '#6b7280' }
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400' }
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' }
}
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
const MEDALS = ['🥇', '🥈', '🥉']
function RouteDetail({ selected, setSelected }) {
const qc = useQueryClient()
const [merging, setMerging] = useState(false)
const [mergeTarget, setMergeTarget] = useState('')
const qc = useQueryClient()
const [editingName, setEditingName] = useState(false)
const [nameInput, setNameInput] = useState(selected.name)
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
// Sort by most completions first
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
const { data: routeActivities } = useQuery({
queryKey: ['route-activities', selected?.id],
queryKey: ['route-activities', selected.id],
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
enabled: !!selected,
})
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
const renameRoute = useMutation({
mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data),
onSuccess: updated => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
setSelected(updated)
setEditingName(false)
},
})
@@ -198,7 +105,161 @@ export default function RoutesPage() {
})
const fastest = routeActivities?.[0]
const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? []
const crTime = fastest?.duration_s
const otherRoutes = (routes || []).filter(r => r.id !== selected.id && r.sport_type === selected.sport_type)
return (
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex gap-4 items-start min-w-0">
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
{selected.reference_polyline
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
</div>
<div className="min-w-0">
{editingName ? (
<div className="flex items-center gap-2">
<input
value={nameInput}
onChange={e => setNameInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && nameInput.trim()) renameRoute.mutate(nameInput.trim()) }}
autoFocus
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button onClick={() => renameRoute.mutate(nameInput.trim())} disabled={!nameInput.trim() || renameRoute.isPending}
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-2 py-1 rounded-lg">Save</button>
<button onClick={() => { setEditingName(false); setNameInput(selected.name) }}
className="text-xs text-gray-400 hover:text-white px-1">Cancel</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white truncate">{selected.name}</h2>
<button onClick={() => { setNameInput(selected.name); setEditingName(true) }}
className="text-gray-500 hover:text-white text-sm" title="Rename route"></button>
</div>
)}
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Podium */}
{routeActivities?.length > 0 && (
<div className="grid grid-cols-3 gap-3">
{routeActivities.slice(0, 3).map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="bg-gray-800/50 hover:bg-gray-800 rounded-lg p-3 text-center transition-colors">
<p className="text-xl">{MEDALS[i]}</p>
<p className="font-mono text-lg font-bold text-white">{formatDuration(act.duration_s)}</p>
<p className="text-xs text-gray-500">{formatDate(act.start_time)}</p>
{i > 0 && crTime != null && (
<p className="text-xs text-red-400">+{formatDuration(act.duration_s - crTime)}</p>
)}
</Link>
))}
</div>
)}
{/* All completions */}
<h3 className="text-sm font-medium text-gray-400">All completions ({routeActivities?.length ?? 0})</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => {
const delta = crTime != null ? act.duration_s - crTime : null
return (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
</span>
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
: <span className="w-16" />}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
)
})}
</div>
</div>
)
}
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
const qc = useQueryClient()
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
// Sort by most completions first
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
},
})
return (
<div className="p-6 space-y-6">
@@ -256,7 +317,7 @@ export default function RoutesPage() {
</div>
)}
{/* Route tile grid */}
{/* Route tile grid — selected route's detail expands inline under its row */}
{routes?.length === 0 && !showCreate ? (
<div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p>
@@ -268,9 +329,9 @@ export default function RoutesPage() {
{sortedRoutes.map(route => {
const style = routeSportStyle(route.sport_type)
const isSelected = selected?.id === route.id
return (
return [
<button key={route.id}
onClick={() => { setSelected(isSelected ? null : route); setMerging(false) }}
onClick={() => setSelected(isSelected ? null : route)}
className={`text-left rounded-xl border p-2 transition-all ${
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
}`}>
@@ -279,121 +340,16 @@ export default function RoutesPage() {
<div className="flex items-center justify-between mt-0.5 gap-1">
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
{route.activity_count > 0 && (
<span className={`text-xs font-medium ${style.accent}`}>
{route.activity_count}×
</span>
<span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
)}
</div>
{route.auto_detected && (
<span className="text-xs text-gray-600">auto</span>
)}
</button>
)
{route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
</button>,
isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
]
})}
</div>
)}
{/* Route detail — shown below the tile grid when a route is selected */}
{selected && (
<div className="space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex gap-4 items-start">
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
<div>
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="mb-4 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
<button onClick={() => setMerging(false)}
className="text-gray-400 hover:text-white text-sm px-3 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Course record */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
</div>
</div>
)}
{/* Activity list */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All completions ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate && (
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
)}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
))}
</div>
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
</div>
</div>
)}
</div>
)
}
-365
View File
@@ -1,365 +0,0 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import api from '../utils/api'
import { formatDuration, formatDistance } from '../utils/format'
import RouteMiniMap from '../components/ui/RouteMiniMap'
function formatSegmentDist(m) {
if (m == null) return '--'
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
}
function SegmentRow({ seg, routeId, routePolyline, sportType }) {
const [expanded, setExpanded] = useState(false)
const queryClient = useQueryClient()
const { data: times, isLoading: timesLoading } = useQuery({
queryKey: ['segment-times', routeId, seg.id],
queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data),
})
const deleteMut = useMutation({
mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }),
})
const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null
const lastTime = times?.[0]?.duration_s ?? null
return (
<div className="border border-gray-800 rounded-lg overflow-hidden">
{/* Main row */}
<div className="flex items-center gap-3 p-3">
{/* Segment mini-map */}
<div className="flex-shrink-0">
<RouteMiniMap
polyline={routePolyline}
sportType={sportType}
width={72}
height={56}
segmentStartM={seg.start_distance_m}
segmentEndM={seg.end_distance_m}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
{seg.auto_generated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">
{seg.auto_generated_type || 'auto'}
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
{formatSegmentDist(seg.start_distance_m)} {formatSegmentDist(seg.end_distance_m)}
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
</p>
{/* Times preview row */}
{!timesLoading && (
<div className="flex items-center gap-3 mt-1">
{bestTime && (
<span className="text-xs font-mono text-yellow-400">
Best {formatDuration(bestTime)}
</span>
)}
{lastTime && lastTime !== bestTime && (
<span className="text-xs font-mono text-gray-400">
Last {formatDuration(lastTime)}
</span>
)}
{times?.length > 0 && (
<span className="text-xs text-gray-600">
{times.length} run{times.length !== 1 ? 's' : ''}
</span>
)}
{times?.length === 0 && (
<span className="text-xs text-gray-600">No times yet</span>
)}
</div>
)}
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times</p>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{times?.length > 0 && (
<button
onClick={() => setExpanded(v => !v)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
>
{expanded ? 'Hide' : 'All'}
</button>
)}
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
title="Delete segment"
>
</button>
</div>
</div>
{/* Expanded times list */}
{expanded && times?.length > 0 && (
<div className="border-t border-gray-800 px-3 pb-3 pt-2 space-y-1">
{times.map((t, i) => (
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
<span className={`font-mono font-semibold w-14 ${t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
{formatDuration(t.duration_s)}
</span>
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
{t.name}
</Link>
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
</div>
))}
</div>
)}
</div>
)
}
function NewSegmentForm({ routeId, onCreated }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [startKm, setStartKm] = useState('')
const [endKm, setEndKm] = useState('')
const [open, setOpen] = useState(false)
const mut = useMutation({
mutationFn: (data) => api.post(`/routes/${routeId}/segments`, data).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
setName(''); setStartKm(''); setEndKm(''); setOpen(false)
if (onCreated) onCreated()
},
})
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="w-full text-left text-xs text-blue-400 hover:text-blue-300 border border-dashed border-blue-500/30 hover:border-blue-400/50 rounded-lg px-3 py-2 transition-colors"
>
+ Add segment manually
</button>
)
}
const handleSubmit = (e) => {
e.preventDefault()
const start = parseFloat(startKm) * 1000
const end = parseFloat(endKm) * 1000
if (!name || isNaN(start) || isNaN(end) || end <= start) return
mut.mutate({ name, start_distance_m: start, end_distance_m: end })
}
return (
<form onSubmit={handleSubmit} className="border border-gray-700 rounded-lg p-3 space-y-2">
<p className="text-xs text-gray-400 font-medium">New segment</p>
<input
type="text" placeholder="Name (e.g. The big hill)"
value={name} onChange={e => setName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
<div className="flex gap-2">
<input
type="number" placeholder="Start (km)" step="0.01" min="0"
value={startKm} onChange={e => setStartKm(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
<input
type="number" placeholder="End (km)" step="0.01" min="0"
value={endKm} onChange={e => setEndKm(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500"
required
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={mut.isPending}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm py-1.5 rounded transition-colors">
{mut.isPending ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={() => setOpen(false)}
className="px-4 text-sm text-gray-500 hover:text-gray-300 transition-colors">
Cancel
</button>
</div>
</form>
)
}
export default function SegmentsPage() {
const [selectedRouteId, setSelectedRouteId] = useState(null)
const [autoGenLoading, setAutoGenLoading] = useState(null)
const [hillGradient, setHillGradient] = useState(5)
const queryClient = useQueryClient()
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const selectedRoute = routes?.find(r => r.id === selectedRouteId)
const { data: segments, isLoading: segsLoading } = useQuery({
queryKey: ['segments', selectedRouteId],
queryFn: () => api.get(`/routes/${selectedRouteId}/segments`).then(r => r.data),
enabled: !!selectedRouteId,
})
const autoGenMut = useMutation({
mutationFn: ({ type, opts }) =>
api.post(`/routes/${selectedRouteId}/segments/auto`, { type, ...opts }).then(r => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['segments', selectedRouteId] })
setAutoGenLoading(null)
},
onError: (err) => {
alert(err?.response?.data?.detail || 'Auto-generate failed')
setAutoGenLoading(null)
},
})
const handleAutoGen = (type, opts = {}) => {
setAutoGenLoading(type)
autoGenMut.mutate({ type, opts })
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Segments</h1>
</div>
{/* Route tile grid */}
{!routes?.length ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{routes.map(r => (
<button
key={r.id}
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
className={`text-left rounded-xl border p-2 transition-colors ${
selectedRouteId === r.id
? 'border-blue-500 bg-blue-900/20'
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
}`}
>
<RouteMiniMap
polyline={r.reference_polyline}
sportType={r.sport_type}
width="100%"
height={80}
/>
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
<div className="flex items-center justify-between mt-0.5">
{r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)}
{r.activity_count > 0 && (
<p className="text-xs text-gray-500">{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}</p>
)}
</div>
</button>
))}
</div>
)}
{selectedRoute && (
<div className="space-y-4">
{/* Route info */}
<div className="flex items-center gap-3">
<div>
<h2 className="text-lg font-semibold text-white">{selectedRoute.name}</h2>
<p className="text-xs text-gray-500">
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
{selectedRoute.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
</p>
</div>
</div>
{/* Auto-generate controls */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<p className="text-xs font-medium text-gray-400">Auto-generate segments</p>
<div className="flex flex-wrap gap-2 items-center">
<button
onClick={() => handleAutoGen('1km')}
disabled={autoGenLoading === '1km'}
className="text-sm px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === '1km' ? 'Generating…' : '📏 1 km splits'}
</button>
<button
onClick={() => handleAutoGen('turns')}
disabled={autoGenLoading === 'turns'}
className="text-sm px-3 py-1.5 rounded-lg bg-purple-600/20 text-purple-300 border border-purple-500/30 hover:bg-purple-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'turns' ? 'Generating…' : '↩️ Detect turns'}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => handleAutoGen('hills', { gradient_pct: hillGradient })}
disabled={autoGenLoading === 'hills'}
className="text-sm px-3 py-1.5 rounded-lg bg-green-600/20 text-green-300 border border-green-500/30 hover:bg-green-600/30 disabled:opacity-50 transition-colors"
>
{autoGenLoading === 'hills' ? 'Generating…' : '⛰️ Detect hills'}
</button>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500"></span>
<input
type="number" min="1" max="30" step="1"
value={hillGradient}
onChange={e => setHillGradient(parseInt(e.target.value) || 5)}
className="w-12 bg-gray-800 border border-gray-700 text-white text-xs rounded px-2 py-1 text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-xs text-gray-500">%</span>
</div>
</div>
</div>
<p className="text-xs text-gray-600">Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.</p>
</div>
{/* Segments list */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
{segments?.length > 0 && (
<span className="text-xs text-gray-600">{segments.length} segment{segments.length !== 1 ? 's' : ''}</span>
)}
</div>
{segsLoading && <p className="text-sm text-gray-600">Loading</p>}
{!segsLoading && !segments?.length && (
<p className="text-sm text-gray-600">No segments yet. Use auto-generate above or add one manually.</p>
)}
{segments?.map(seg => (
<SegmentRow
key={seg.id}
seg={seg}
routeId={selectedRouteId}
routePolyline={selectedRoute.reference_polyline}
sportType={selectedRoute.sport_type}
/>
))}
<NewSegmentForm routeId={selectedRouteId} />
</div>
</div>
)}
</div>
)
}