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 (
-
- )
-}
-
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 && (
-
- )}
- {drained != null && (
-
- )}
- {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 => (
+
+
)}