Batch 1: dashboard, maps, segments rewrite, health, sync UX
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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user