diff --git a/backend/app/api/records.py b/backend/app/api/records.py index fdee950..49822d9 100644 --- a/backend/app/api/records.py +++ b/backend/app/api/records.py @@ -44,6 +44,35 @@ async def list_records( return result.scalars().all() +@router.get("/routes") +async def route_records( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Fastest activity per named route (course records).""" + from sqlalchemy import text + rows = await db.execute( + text(""" + SELECT DISTINCT ON (nr.id) + nr.id AS route_id, + nr.name AS route_name, + nr.sport_type, + nr.distance_m, + a.id AS activity_id, + a.name AS activity_name, + a.duration_s, + a.start_time, + a.avg_speed_ms + FROM named_routes nr + JOIN activities a ON a.named_route_id = nr.id AND a.user_id = nr.user_id + WHERE nr.user_id = :uid AND a.duration_s IS NOT NULL + ORDER BY nr.id, a.duration_s ASC + """), + {"uid": current_user.id}, + ) + return [dict(r._mapping) for r in rows] + + @router.get("/history/{distance_label}") async def record_history( distance_label: str, diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index c448cdd..1087659 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -413,6 +413,93 @@ async def auto_generate_segments( return new_segments +class SegmentBestOut(BaseModel): + segment_id: int + name: str + start_distance_m: float + end_distance_m: float + auto_generated: bool + best_s: Optional[float] + best_activity_id: Optional[int] + count: int + + +@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut]) +async def get_segment_bests( + route_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return best time per segment across all matched activities for a route.""" + from app.services.route_matcher import find_segment_times + from app.models.user import ActivityDataPoint + from collections import defaultdict + + await _get_owned_route(route_id, current_user.id, db) + + segs_result = await db.execute( + select(RouteSegment) + .where(RouteSegment.route_id == route_id) + .order_by(RouteSegment.start_distance_m) + ) + segments = segs_result.scalars().all() + if not segments: + return [] + + acts_result = await db.execute( + select(Activity) + .where(Activity.named_route_id == route_id, Activity.user_id == current_user.id) + .order_by(desc(Activity.start_time)) + .limit(20) + ) + activities = acts_result.scalars().all() + if not activities: + return [ + SegmentBestOut( + segment_id=s.id, name=s.name, + start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m, + auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0, + ) + for s in segments + ] + + act_ids = [a.id for a in activities] + + dp_result = await db.execute( + select(ActivityDataPoint) + .where(ActivityDataPoint.activity_id.in_(act_ids)) + .order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp) + ) + all_dps = dp_result.scalars().all() + + # Group data points by activity_id + dp_by_act = defaultdict(list) + for dp in all_dps: + if dp.distance_m is not None: + dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp}) + + bests = [] + for seg in segments: + best_s = None + best_act_id = None + count = 0 + for act_id in act_ids: + dp_list = dp_by_act.get(act_id, []) + duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m) + if duration is not None: + count += 1 + if best_s is None or duration < best_s: + best_s = duration + best_act_id = act_id + bests.append(SegmentBestOut( + segment_id=seg.id, name=seg.name, + start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m, + auto_generated=bool(seg.auto_generated), + best_s=best_s, best_activity_id=best_act_id, count=count, + )) + return bests + + @router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry]) async def get_segment_times( route_id: int, diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index e8c92ea..c8176f7 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -1,4 +1,4 @@ -import { useParams } from 'react-router-dom' +import { useParams, Link } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { useState, useMemo } from 'react' import api from '../utils/api' @@ -12,6 +12,16 @@ import { formatHeartRate, formatDateTime, formatCadence, sportIcon, } from '../utils/format' +function segmentTime(points, startM, endM) { + let t0 = null + 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 + } + return null +} + const METRICS = [ { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, @@ -45,6 +55,18 @@ 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), + 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 toggleMetric = (key) => { setActiveMetrics(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] @@ -192,6 +214,42 @@ export default function ActivityDetailPage() { )} + + {/* Segments */} + {segments && segments.length > 0 && dataPoints && ( +
+
+

Segments

+ Manage β†’ +
+
+ {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 + return ( +
+ {seg.name} + + {t != null ? ( + + {formatDuration(t)} + + ) : ( + -- + )} + + {isNewBest && πŸ†} + {!isNewBest && best?.best_s != null && ( + /{formatDuration(best.best_s)} + )} + {!isNewBest && !best?.best_s && } +
+ ) + })} +
+
+ )} ) } diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index df14566..80e8f92 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -9,6 +9,42 @@ import { formatDate, sportIcon, formatSleep, } from '../utils/format' +function bbLevelColor(level) { + if (level == null) return '#6b7280' + if (level >= 75) return '#3b82f6' + if (level >= 50) return '#22c55e' + if (level >= 25) return '#f59e0b' + return '#ef4444' +} + +function MiniBodyBattery({ bb }) { + if (!bb?.end_level && !bb?.charged) return null + const { charged, drained, start_level, end_level } = bb + const color = bbLevelColor(end_level) + return ( +
+
+

Body Battery

+ View β†’ +
+
+ {end_level != null && ( + {Math.round(end_level)} + )} + {charged != null && ( + +{charged} + )} + {drained != null && ( + -{drained} + )} +
+ {start_level != null && end_level != null && ( +

{start_level} β†’ {end_level} today

+ )} +
+ ) +} + function WeeklyChart({ activities }) { const navigate = useNavigate() @@ -115,27 +151,30 @@ export default function DashboardPage() { -
-

Health today

- {latest ? ( - <> - {[ - ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'], - ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'], - ['Steps', latest.steps?.toLocaleString() ?? '--'], - ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'], - ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'], - ].map(([label, val]) => ( -
- {label} - {val} -
- ))} - View full health dashboard β†’ - - ) : ( -

No health data. Import a Garmin export.

- )} +
+ +
+

Health today

+ {latest ? ( + <> + {[ + ['HRV', latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'], + ['Sleep score', latest.sleep_score ? Math.round(latest.sleep_score) : '--'], + ['Steps', latest.steps?.toLocaleString() ?? '--'], + ['VO2 Max', latest.vo2max ? latest.vo2max.toFixed(1) : '--'], + ['Stress', latest.avg_stress ? Math.round(latest.avg_stress) : '--'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} + View full health dashboard β†’ + + ) : ( +

No health data. Import a Garmin export.

+ )} +
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 1eff81c..88d7667 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -71,88 +71,66 @@ function bbLevelColor(level) { return '#ef4444' } -function BatteryRing({ level }) { - if (level == null) return -- - const r = 38, stroke = 8 - const c = 2 * Math.PI * r - const filled = c * (Math.min(100, Math.max(0, level)) / 100) - const color = bbLevelColor(level) - return ( - - - - {Math.round(level)} - / 100 - - ) -} - function BodyBatteryChart({ bb }) { if (!bb) return null const { charged, drained, start_level, end_level, values } = bb if (!values?.length && end_level == null) return null - const chartData = (values || []).map(([ts, level, type, stress]) => ({ + const chartData = (values || []).map(([ts, level, type]) => ({ t: ts, level, type: type ?? 4, - bar: stress > 0 ? stress : (type === 2 ? 8 : type === 0 ? 20 : 35), + bar: 100, })) - return ( -
-

Body Battery

+ const presentTypes = [...new Set(chartData.map(d => d.type))] + const levelColor = bbLevelColor(end_level) -
- -
- {charged != null && ( -
-

Charged

- +{charged} -
- )} - {drained != null && ( -
-

Drained

- -{drained} -
- )} - {start_level != null && end_level != null && ( -

{start_level} β†’ {end_level}

- )} -
+ return ( +
+

Body Battery

+ +
+ {end_level != null && ( + {Math.round(end_level)} + )} + {charged != null && ( + +{charged} + )} + {drained != null && ( + -{drained} + )} + {start_level != null && end_level != null && ( + {start_level} β†’ {end_level} + )}
{chartData.length > 0 && ( <> - - - format(new Date(ts), 'HH:mm')} - interval={Math.max(1, Math.floor(chartData.length / 6))} /> - - format(new Date(ts), 'HH:mm')} - formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : [Math.round(v), 'Stress']} /> - - {chartData.map((d, i) => ( - - ))} - - - - -
- {Object.entries(BB_TYPE_LABEL).map(([code, label]) => ( -
-
- {label} +
+ + + format(new Date(ts), 'HH:mm')} + interval={Math.max(1, Math.floor(chartData.length / 6))} /> + format(new Date(ts), 'HH:mm')} + formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} /> + + {chartData.map((d, i) => ( + + ))} + + + + +
+
+ {presentTypes.map(code => ( +
+
+ {BB_TYPE_LABEL[code]}
))}
diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index ac9e824..ee76b4d 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import { useAuthStore } from '../hooks/useAuth' @@ -56,15 +56,31 @@ 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: '', resting_heart_rate: '', birth_year: '', height_cm: '' }) + const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '' }) const [hrSaved, setHrSaved] = useState(false) const [hrZoneRecalc, setHrZoneRecalc] = useState(false) const maxHrChangedRef = useRef(false) useEffect(() => { if (profile) setHrForm({ max_heart_rate: profile.max_heart_rate || '', - resting_heart_rate: profile.resting_heart_rate || '', birth_year: profile.birth_year || '', height_cm: profile.height_cm || '', }) @@ -84,22 +100,6 @@ export default function ProfilePage() { }, }) - // Weight log - const { data: weightLog } = useQuery({ - queryKey: ['weight-log'], - queryFn: () => api.get('/profile/weight').then(r => r.data), - }) - const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) }) - const [weightSaved, setWeightSaved] = useState(false) - const addWeight = useMutation({ - mutationFn: data => api.post('/profile/weight', data).then(r => r.data), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) }, - }) - const deleteWeight = useMutation({ - mutationFn: id => api.delete(`/profile/weight/${id}`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }), - }) - // Password change const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' }) const [pwError, setPwError] = useState('') @@ -221,10 +221,6 @@ export default function ProfilePage() { setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} /> - - setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} /> - setHrForm(f => ({ ...f, birth_year: e.target.value }))} /> @@ -235,6 +231,23 @@ export default function ProfilePage() {
+ {(avgRestingHr || healthSummary?.latest?.weight_kg) && ( +
+ {avgRestingHr && ( +
+

Resting HR (7-day avg, from Garmin)

+ {avgRestingHr} bpm +
+ )} + {healthSummary?.latest?.weight_kg && ( +
+

Weight (from Garmin)

+ {healthSummary.latest.weight_kg.toFixed(1)} kg +
+ )} +
+ )} + { const data = Object.fromEntries( @@ -251,51 +264,6 @@ export default function ProfilePage() { )} - {/* Weight log */} -
-
- - setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} /> - - - setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} /> - - - setWeightForm(f => ({ ...f, date: e.target.value }))} /> - -
- addWeight.mutate({ - weight_kg: parseFloat(weightForm.weight_kg), - body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null, - date: new Date(weightForm.date).toISOString(), - })} - loading={addWeight.isPending} - saved={weightSaved} - label="Log weight" - /> - - {weightLog && weightLog.length > 0 && ( -
-

Recent entries

-
- {weightLog.slice(0, 20).map(entry => ( -
- {new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} - {entry.weight_kg.toFixed(1)} kg - {entry.body_fat_pct && {entry.body_fat_pct.toFixed(1)}% fat} - -
- ))} -
-
- )} -
- {/* Password change */}
diff --git a/frontend/src/pages/RecordsPage.jsx b/frontend/src/pages/RecordsPage.jsx index 98e4c2e..f8e25be 100644 --- a/frontend/src/pages/RecordsPage.jsx +++ b/frontend/src/pages/RecordsPage.jsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { format } from 'date-fns' import api from '../utils/api' -import { formatDuration, formatDate } from '../utils/format' +import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format' const SPORTS = ['running', 'cycling', 'swimming'] @@ -13,7 +13,9 @@ const DISTANCE_ORDER = [ 'Half marathon', 'Marathon', '50k', '100k', ] -export default function RecordsPage() { +const TABS = ['Distance PRs', 'Route Records', 'Segment Records'] + +function DistancePRs() { const [sport, setSport] = useState('running') const [selectedDistance, setSelectedDistance] = useState(null) @@ -31,7 +33,6 @@ export default function RecordsPage() { enabled: !!selectedDistance, }) - // Sort by standard distance order const sortedRecords = records?.slice().sort((a, b) => { const ai = DISTANCE_ORDER.indexOf(a.distance_label) const bi = DISTANCE_ORDER.indexOf(b.distance_label) @@ -39,10 +40,7 @@ export default function RecordsPage() { }) return ( -
-

Personal Records

- - {/* Sport selector */} +
{SPORTS.map(s => ( + ))} +
+ + {tab === 'Distance PRs' && } + {tab === 'Route Records' && } + {tab === 'Segment Records' && } +
+ ) +} diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index f5e831a..856e74b 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -4,6 +4,89 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' 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 theoreticalBest = bests?.every(b => b.best_s != null) + ? bests.reduce((sum, b) => sum + b.best_s, 0) + : null + + const renderGroup = (group, title) => { + if (!group.length) return null + return ( +
+

{title}

+ {group.map(seg => { + const best = bestMap[seg.id] + return ( +
+ {seg.name} + {formatSegDist(seg.end_distance_m - seg.start_distance_m)} + {best?.best_s != null ? ( + {formatDuration(best.best_s)} + ) : ( + -- + )} + +
+ ) + })} +
+ ) + } + + return ( +
+
+

Segments

+ Manage β†’ +
+ {renderGroup(kmSplits, '1km Splits')} + {renderGroup(hillsTurns, 'Hills & Turns')} + {theoreticalBest != null && ( +
+ Theoretical best (sum of segment bests) + {formatDuration(theoreticalBest)} +
+ )} +
+ ) +} + // Decode Google encoded polyline to [[lat,lng], ...] function decodePolyline(encoded) { if (!encoded) return [] @@ -288,6 +371,8 @@ export default function RoutesPage() { ))}
+ +
)}