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, { SPEED_GRADIENT } 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 RouteLeaderboard from '../components/activity/RouteLeaderboard' import StatCard from '../components/ui/StatCard' import { formatDuration, formatDistance, formatPace, formatElevation, formatHeartRate, formatDateTime, formatCadence, sportIcon, } from '../utils/format' // 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 (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 best } const METRICS = [ { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, { key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' }, { key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' }, { key: 'power', label: 'Power', unit: 'W', color: '#a855f7' }, { key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' }, ] export default function ActivityDetailPage() { const { id } = useParams() const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) const [hoveredDistance, setHoveredDistance] = useState(null) const [mapHeight, setMapHeight] = useState(420) 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], queryFn: () => api.get(`/activities/${id}`).then(r => r.data), }) const { data: dataPoints } = useQuery({ queryKey: ['activity-points', id], queryFn: () => api.get(`/activities/${id}/data-points?downsample=3`).then(r => r.data), enabled: !!activity, }) const { data: laps } = useQuery({ queryKey: ['activity-laps', id], queryFn: () => api.get(`/activities/${id}/laps`).then(r => r.data), enabled: !!activity, }) 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: routeBoard } = useQuery({ queryKey: ['route-leaderboard', id], queryFn: () => api.get(`/activities/${id}/route-leaderboard`).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 [segError, setSegError] = useState('') const createSegment = async () => { const [a, b] = segPoints setSegError('') try { 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] }) } catch (e) { setSegError(e.response?.data?.detail || 'Failed to create segment') } } const toggleMetric = (key) => { setActiveMetrics(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] ) } // Check which metrics have actual data const availableMetrics = useMemo(() => { if (!dataPoints?.length) return new Set() return new Set( METRICS .filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0)) .map(m => m.key) ) }, [dataPoints]) if (isLoading) { return
Loading activity…
} if (!activity) return null return (
{/* Header */}
{sportIcon(activity.sport_type)}

{activity.name}

{formatDateTime(activity.start_time)}

{/* Stats — all on one row */}
{/* HR Zones */} {activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (

Heart Rate Zones

)} {/* Map with controls */}
{/* Map toolbar */}
Map style: {['dark', 'street', 'satellite'].map(t => ( ))} {dataPoints?.length > 0 && ( )}
Route: {[['speed', 'Speed'], ['solid', 'Solid']].map(([mode, label]) => ( ))} Height: {[280, 420, 560].map(h => ( ))}
{segCreate && (
Click two points on the route to mark the segment start and end. Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'} {' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'} {segPoints.length === 2 && ( <> 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" /> )} {segPoints.length > 0 && ( )} {segError && {segError}}
)}
{colorMode === 'speed' && (
Slow
Fast
)}
{/* Metric timeline */}

Activity Timeline

{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => ( ))}
{dataPoints && dataPoints.length > 0 ? ( availableMetrics.has(m))} metrics={METRICS} onHoverDistance={setHoveredDistance} sportType={activity.sport_type} /> ) : (

No timeline data available for this activity

)}
{/* Laps + Route leaderboard + Segments side by side */} {((laps && laps.length > 0) || (actSegments && actSegments.length > 0) || (routeBoard && routeBoard.top?.length > 0)) && (
{laps && laps.length > 0 && (

Laps

)} {routeBoard && routeBoard.top?.length > 0 && (

Route — Top 10 Times

)} {actSegments && actSegments.length > 0 && (

Segments

)}
)}
) }