Round 2: body battery redesign, profile cleanup, segment integration, route/segment records
- Body battery: replace circular ring with compact full-height colored bar chart,
level as line overlay, legend shows only types present in data
- Dashboard: add mini body battery summary card above health today panel
- Profile: remove editable resting HR and manual weight log; show 7-day avg
resting HR and latest Garmin weight as read-only
- Backend: add GET /routes/{id}/segment-bests bulk endpoint (fetches all matched
activity data points in one query, computes best segment time per segment)
- Backend: add GET /records/routes for fastest activity per named route
- Routes page: add Segments panel to route detail (grouped as 1km splits vs
hills/turns, best times, delete, theoretical best)
- Activity detail page: show segment times computed client-side from data points,
🏆 badge if new best
- Records page: add Route Records and Segment Records tabs alongside Distance PRs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,35 @@ async def list_records(
|
|||||||
return result.scalars().all()
|
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}")
|
@router.get("/history/{distance_label}")
|
||||||
async def record_history(
|
async def record_history(
|
||||||
distance_label: str,
|
distance_label: str,
|
||||||
|
|||||||
@@ -413,6 +413,93 @@ async def auto_generate_segments(
|
|||||||
return new_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])
|
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
|
||||||
async def get_segment_times(
|
async def get_segment_times(
|
||||||
route_id: int,
|
route_id: int,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
@@ -12,6 +12,16 @@ import {
|
|||||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||||
} from '../utils/format'
|
} 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 = [
|
const METRICS = [
|
||||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||||
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||||
@@ -45,6 +55,18 @@ export default function ActivityDetailPage() {
|
|||||||
enabled: !!activity,
|
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) => {
|
const toggleMetric = (key) => {
|
||||||
setActiveMetrics(prev =>
|
setActiveMetrics(prev =>
|
||||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||||
@@ -192,6 +214,42 @@ export default function ActivityDetailPage() {
|
|||||||
<LapTable laps={laps} sportType={activity.sport_type} />
|
<LapTable laps={laps} sportType={activity.sport_type} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Segments */}
|
||||||
|
{segments && segments.length > 0 && dataPoints && (
|
||||||
|
<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="space-y-1">
|
||||||
|
{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 (
|
||||||
|
<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="font-mono text-xs w-14 text-right">
|
||||||
|
{t != null ? (
|
||||||
|
<span className={isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}>
|
||||||
|
{formatDuration(t)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">--</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isNewBest && <span className="text-xs" title="New best">🏆</span>}
|
||||||
|
{!isNewBest && best?.best_s != null && (
|
||||||
|
<span className="text-gray-600 text-xs w-14 text-right">/{formatDuration(best.best_s)}</span>
|
||||||
|
)}
|
||||||
|
{!isNewBest && !best?.best_s && <span className="w-14" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,42 @@ import {
|
|||||||
formatDate, sportIcon, formatSleep,
|
formatDate, sportIcon, formatSleep,
|
||||||
} from '../utils/format'
|
} 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 (
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
||||||
|
<Link to="/health" className="text-xs text-blue-400 hover:underline">View →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-3 flex-wrap">
|
||||||
|
{end_level != null && (
|
||||||
|
<span className="text-3xl font-bold" style={{ color }}>{Math.round(end_level)}</span>
|
||||||
|
)}
|
||||||
|
{charged != null && (
|
||||||
|
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
||||||
|
)}
|
||||||
|
{drained != null && (
|
||||||
|
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{start_level != null && end_level != null && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{start_level} → {end_level} today</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function WeeklyChart({ activities }) {
|
function WeeklyChart({ activities }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -115,6 +151,8 @@ export default function DashboardPage() {
|
|||||||
<WeeklyChart activities={allActivities} />
|
<WeeklyChart activities={allActivities} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<MiniBodyBattery bb={latest?.body_battery} />
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
|
||||||
{latest ? (
|
{latest ? (
|
||||||
@@ -138,6 +176,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent activities */}
|
{/* Recent activities */}
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
|||||||
@@ -71,88 +71,66 @@ function bbLevelColor(level) {
|
|||||||
return '#ef4444'
|
return '#ef4444'
|
||||||
}
|
}
|
||||||
|
|
||||||
function BatteryRing({ level }) {
|
|
||||||
if (level == null) return <span className="text-3xl font-bold text-gray-600">--</span>
|
|
||||||
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 (
|
|
||||||
<svg width="96" height="96" viewBox="0 0 96 96">
|
|
||||||
<circle cx="48" cy="48" r={r} fill="none" stroke="#1f2937" strokeWidth={stroke} />
|
|
||||||
<circle cx="48" cy="48" r={r} fill="none" stroke={color} strokeWidth={stroke}
|
|
||||||
strokeDasharray={`${filled} ${c - filled}`} strokeLinecap="round"
|
|
||||||
transform="rotate(-90 48 48)" />
|
|
||||||
<text x="48" y="44" textAnchor="middle" dominantBaseline="middle"
|
|
||||||
fill="white" fontSize="20" fontWeight="bold">{Math.round(level)}</text>
|
|
||||||
<text x="48" y="62" textAnchor="middle" fill="#6b7280" fontSize="11">/ 100</text>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BodyBatteryChart({ bb }) {
|
function BodyBatteryChart({ bb }) {
|
||||||
if (!bb) return null
|
if (!bb) return null
|
||||||
const { charged, drained, start_level, end_level, values } = bb
|
const { charged, drained, start_level, end_level, values } = bb
|
||||||
if (!values?.length && end_level == null) return null
|
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,
|
t: ts,
|
||||||
level,
|
level,
|
||||||
type: type ?? 4,
|
type: type ?? 4,
|
||||||
bar: stress > 0 ? stress : (type === 2 ? 8 : type === 0 ? 20 : 35),
|
bar: 100,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4 h-full">
|
const levelColor = bbLevelColor(end_level)
|
||||||
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-8">
|
return (
|
||||||
<BatteryRing level={end_level} />
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
|
||||||
<div className="space-y-3">
|
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
||||||
|
{end_level != null && (
|
||||||
|
<span className="text-3xl font-bold" style={{ color: levelColor }}>{Math.round(end_level)}</span>
|
||||||
|
)}
|
||||||
{charged != null && (
|
{charged != null && (
|
||||||
<div>
|
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
||||||
<p className="text-xs text-gray-500">Charged</p>
|
|
||||||
<span className="text-xl font-semibold text-blue-400">+{charged}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{drained != null && (
|
{drained != null && (
|
||||||
<div>
|
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
||||||
<p className="text-xs text-gray-500">Drained</p>
|
|
||||||
<span className="text-xl font-semibold text-orange-400">-{drained}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{start_level != null && end_level != null && (
|
{start_level != null && end_level != null && (
|
||||||
<p className="text-xs text-gray-500">{start_level} → {end_level}</p>
|
<span className="text-xs text-gray-500">{start_level} → {end_level}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{chartData.length > 0 && (
|
{chartData.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={110}>
|
<div className="flex-1">
|
||||||
<ComposedChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<ComposedChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 0 }}>
|
||||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
|
||||||
width={28} domain={[0, 100]} />
|
|
||||||
<Tooltip contentStyle={tooltipStyle}
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : [Math.round(v), 'Stress']} />
|
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
|
||||||
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={8}>
|
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
|
||||||
{chartData.map((d, i) => (
|
{chartData.map((d, i) => (
|
||||||
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.7} />
|
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.8} />
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={2}
|
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
|
||||||
dot={false} isAnimationActive={false} connectNulls />
|
dot={false} isAnimationActive={false} connectNulls />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
</div>
|
||||||
{Object.entries(BB_TYPE_LABEL).map(([code, label]) => (
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
<div key={code} className="flex items-center gap-1.5">
|
{presentTypes.map(code => (
|
||||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
|
<div key={code} className="flex items-center gap-1">
|
||||||
<span className="text-xs text-gray-400">{label}</span>
|
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
|
||||||
|
<span className="text-xs text-gray-500">{BB_TYPE_LABEL[code]}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuthStore } from '../hooks/useAuth'
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
@@ -56,15 +56,31 @@ export default function ProfilePage() {
|
|||||||
enabled: !!user?.is_admin,
|
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
|
// 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 [hrSaved, setHrSaved] = useState(false)
|
||||||
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
|
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
|
||||||
const maxHrChangedRef = useRef(false)
|
const maxHrChangedRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile) setHrForm({
|
if (profile) setHrForm({
|
||||||
max_heart_rate: profile.max_heart_rate || '',
|
max_heart_rate: profile.max_heart_rate || '',
|
||||||
resting_heart_rate: profile.resting_heart_rate || '',
|
|
||||||
birth_year: profile.birth_year || '',
|
birth_year: profile.birth_year || '',
|
||||||
height_cm: profile.height_cm || '',
|
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
|
// Password change
|
||||||
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
|
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
|
||||||
const [pwError, setPwError] = useState('')
|
const [pwError, setPwError] = useState('')
|
||||||
@@ -221,10 +221,6 @@ export default function ProfilePage() {
|
|||||||
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||||
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
|
|
||||||
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
|
|
||||||
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
||||||
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
||||||
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
||||||
@@ -235,6 +231,23 @@ export default function ProfilePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
<SaveButton
|
<SaveButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const data = Object.fromEntries(
|
const data = Object.fromEntries(
|
||||||
@@ -251,51 +264,6 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Weight log */}
|
|
||||||
<Section title="Weight Log">
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<Field label="Weight (kg)">
|
|
||||||
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
|
|
||||||
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Body fat % (optional)">
|
|
||||||
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
|
|
||||||
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Date">
|
|
||||||
<Input type="datetime-local" value={weightForm.date}
|
|
||||||
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<SaveButton
|
|
||||||
onClick={() => 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 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
|
|
||||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
||||||
{weightLog.slice(0, 20).map(entry => (
|
|
||||||
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
|
|
||||||
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
|
||||||
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
|
|
||||||
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
|
|
||||||
<button onClick={() => deleteWeight.mutate(entry.id)}
|
|
||||||
className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Password change */}
|
{/* Password change */}
|
||||||
<Section title="Change Password">
|
<Section title="Change Password">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatDuration, formatDate } from '../utils/format'
|
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
||||||
|
|
||||||
const SPORTS = ['running', 'cycling', 'swimming']
|
const SPORTS = ['running', 'cycling', 'swimming']
|
||||||
|
|
||||||
@@ -13,7 +13,9 @@ const DISTANCE_ORDER = [
|
|||||||
'Half marathon', 'Marathon', '50k', '100k',
|
'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 [sport, setSport] = useState('running')
|
||||||
const [selectedDistance, setSelectedDistance] = useState(null)
|
const [selectedDistance, setSelectedDistance] = useState(null)
|
||||||
|
|
||||||
@@ -31,7 +33,6 @@ export default function RecordsPage() {
|
|||||||
enabled: !!selectedDistance,
|
enabled: !!selectedDistance,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort by standard distance order
|
|
||||||
const sortedRecords = records?.slice().sort((a, b) => {
|
const sortedRecords = records?.slice().sort((a, b) => {
|
||||||
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
||||||
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
||||||
@@ -39,10 +40,7 @@ export default function RecordsPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
|
|
||||||
|
|
||||||
{/* Sport selector */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{SPORTS.map(s => (
|
{SPORTS.map(s => (
|
||||||
<button
|
<button
|
||||||
@@ -67,7 +65,6 @@ export default function RecordsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Records table */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -84,9 +81,7 @@ export default function RecordsPage() {
|
|||||||
key={rec.id}
|
key={rec.id}
|
||||||
onClick={() => setSelectedDistance(rec.distance_label)}
|
onClick={() => setSelectedDistance(rec.distance_label)}
|
||||||
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
||||||
selectedDistance === rec.distance_label
|
selectedDistance === rec.distance_label ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
|
||||||
? 'bg-blue-900/20'
|
|
||||||
: 'hover:bg-gray-800/40'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
||||||
@@ -111,52 +106,29 @@ export default function RecordsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress chart */}
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
{selectedDistance && history ? (
|
{selectedDistance && history ? (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-1">
|
<h3 className="text-sm font-medium text-gray-300 mb-1">{selectedDistance} progression</h3>
|
||||||
{selectedDistance} progression
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
||||||
{history.length > 1 ? (
|
{history.length > 1 ? (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<LineChart
|
<LineChart
|
||||||
data={history.map(h => ({
|
data={history.map(h => ({ date: h.achieved_at, time: h.duration_s }))}
|
||||||
date: h.achieved_at,
|
|
||||||
time: h.duration_s,
|
|
||||||
}))}
|
|
||||||
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis
|
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
dataKey="date"
|
tickFormatter={d => format(new Date(d), 'MMM yy')} />
|
||||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
axisLine={false}
|
width={40} tickFormatter={formatDuration} reversed />
|
||||||
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
|
|
||||||
/>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||||
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
formatter={v => [formatDuration(v), 'Time']}
|
formatter={v => [formatDuration(v), 'Time']}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line type="monotone" dataKey="time" stroke="#fbbf24" strokeWidth={2}
|
||||||
type="monotone"
|
dot={{ fill: '#fbbf24', r: 4 }} isAnimationActive={false} />
|
||||||
dataKey="time"
|
|
||||||
stroke="#fbbf24"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ fill: '#fbbf24', r: 4 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
@@ -175,3 +147,190 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteRecords() {
|
||||||
|
const { data: records, isLoading } = useQuery({
|
||||||
|
queryKey: ['route-records'],
|
||||||
|
queryFn: () => api.get('/records/routes').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-gray-500 text-sm">Loading…</p>
|
||||||
|
|
||||||
|
if (!records?.length) return (
|
||||||
|
<div className="text-center py-16 text-gray-600">
|
||||||
|
<p className="text-4xl mb-3">🗺️</p>
|
||||||
|
<p>No route records yet — create named routes and complete activities on them</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">Route</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Distance</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Best time</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Pace</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">Date</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.map(rec => (
|
||||||
|
<tr key={rec.route_id} className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium text-white">
|
||||||
|
<span className="capitalize text-xs text-gray-500 mr-2">{rec.sport_type}</span>
|
||||||
|
{rec.route_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-400 text-xs">
|
||||||
|
{formatDistance(rec.distance_m)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-yellow-400 font-semibold">
|
||||||
|
{formatDuration(rec.duration_s)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-400 text-xs">
|
||||||
|
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-400 text-xs">
|
||||||
|
{formatDate(rec.start_time)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<Link to={`/activities/${rec.activity_id}`} className="text-xs text-blue-400 hover:underline">
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 theoreticalBest = bests?.length && bests.every(b => b.best_s != null)
|
||||||
|
? bests.reduce((sum, b) => sum + b.best_s, 0)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<label className="block text-xs text-gray-500 mb-2">Select a route</label>
|
||||||
|
{!routes?.length ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedRouteId ?? ''}
|
||||||
|
onChange={e => setSelectedRouteId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">— choose a route —</option>
|
||||||
|
{routes.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}{r.distance_m ? ` (${(r.distance_m / 1000).toFixed(1)} km)` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</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 (sum of all segment bests)</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')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Records</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`text-sm px-4 py-1.5 rounded-full border transition-colors ${
|
||||||
|
tab === t
|
||||||
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
|
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'Distance PRs' && <DistancePRs />}
|
||||||
|
{tab === 'Route Records' && <RouteRecords />}
|
||||||
|
{tab === 'Segment Records' && <SegmentRecords />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,89 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
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 (
|
||||||
|
<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 (sum of segment bests)</span>
|
||||||
|
<span className="font-mono text-xs font-semibold text-blue-400">{formatDuration(theoreticalBest)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Decode Google encoded polyline to [[lat,lng], ...]
|
// Decode Google encoded polyline to [[lat,lng], ...]
|
||||||
function decodePolyline(encoded) {
|
function decodePolyline(encoded) {
|
||||||
if (!encoded) return []
|
if (!encoded) return []
|
||||||
@@ -288,6 +371,8 @@ export default function RoutesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user