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
{formatDateTime(activity.start_time)}
No timeline data available for this activity
)}