All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import {
formatDate, sportIcon, sportColor,
} from '../utils/format'
const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking']
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
export default function ActivitiesPage() {
const [sport, setSport] = useState('all')
+69 -30
View File
@@ -9,14 +9,14 @@ import LapTable from '../components/activity/LapTable'
import StatCard from '../components/ui/StatCard'
import {
formatDuration, formatDistance, formatPace, formatElevation,
formatHeartRate, formatDateTime, sportIcon,
formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format'
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: 'rpm', color: '#f97316' },
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
]
@@ -25,6 +25,8 @@ 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('dark')
const { data: activity, isLoading } = useQuery({
queryKey: ['activity', id],
@@ -49,19 +51,21 @@ export default function ActivityDetailPage() {
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Loading activity</div>
</div>
// 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 <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity</div></div>
}
if (!activity) return null
const speed = activity.avg_speed_ms
const pace = formatPace(speed, activity.sport_type)
return (
<div className="p-6 space-y-6">
{/* Header */}
@@ -75,12 +79,12 @@ export default function ActivityDetailPage() {
</div>
</div>
{/* Summary stats */}
{/* Primary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
<StatCard label="Pace" value={pace} />
<StatCard label="Elevation" value={`${formatElevation(activity.elevation_gain_m)}`} />
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
</div>
@@ -88,37 +92,71 @@ export default function ActivityDetailPage() {
{/* Secondary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
<StatCard label="Avg Cadence" value={activity.avg_cadence ? `${Math.round(activity.avg_cadence)} rpm` : '--'} />
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
<StatCard label="TSS" value={activity.training_stress_score ? Math.round(activity.training_stress_score) : '--'} />
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
</div>
{/* Map */}
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800" style={{ height: 420 }}>
<ActivityMap
polyline={activity.polyline}
dataPoints={dataPoints}
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
/>
{/* Map with controls */}
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Map style:</span>
{['dark', 'street', 'satellite'].map(t => (
<button
key={t}
onClick={() => setMapType(t)}
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{t}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Height:</span>
{[280, 420, 560].map(h => (
<button
key={h}
onClick={() => setMapHeight(h)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
</button>
))}
</div>
</div>
<div style={{ height: mapHeight }}>
<ActivityMap
polyline={activity.polyline}
dataPoints={dataPoints}
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
mapType={mapType}
/>
</div>
</div>
{/* HR Zones */}
{activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && (
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} />
</div>
)}
{/* Metric selector */}
{/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
<div className="flex flex-wrap gap-2">
{METRICS.map(({ key, label, color }) => (
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
<button
key={key}
onClick={() => toggleMetric(key)}
@@ -134,15 +172,16 @@ export default function ActivityDetailPage() {
))}
</div>
</div>
{dataPoints && (
{dataPoints && dataPoints.length > 0 ? (
<MetricTimeline
dataPoints={dataPoints}
activeMetrics={activeMetrics}
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
metrics={METRICS}
onHoverDistance={setHoveredDistance}
sportType={activity.sport_type}
/>
) : (
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
)}
</div>
+45 -71
View File
@@ -1,7 +1,7 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { format, subDays, startOfWeek } from 'date-fns'
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
import api from '../utils/api'
import StatCard from '../components/ui/StatCard'
import {
@@ -10,18 +10,29 @@ import {
} from '../utils/format'
function WeeklyChart({ activities }) {
if (!activities?.length) return null
if (!activities?.length) return (
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
)
// Build last 8 weeks of distance data
const weeks = {}
activities.forEach(a => {
const week = format(startOfWeek(new Date(a.start_time)), 'MMM d')
if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 }
weeks[week].km += (a.distance_m || 0) / 1000
weeks[week].runs++
// Build last 8 weeks in chronological order
const now = new Date()
const weeks = eachWeekOfInterval({
start: subWeeks(startOfWeek(now), 7),
end: startOfWeek(now),
})
const data = Object.values(weeks).slice(-8)
const data = weeks.map(weekStart => {
const weekKey = format(weekStart, 'MMM d')
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekEnd.getDate() + 7)
const km = activities
.filter(a => {
const t = new Date(a.start_time)
return t >= weekStart && t < weekEnd
})
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
return { week: weekKey, km: parseFloat(km.toFixed(2)) }
})
return (
<ResponsiveContainer width="100%" height={140}>
@@ -30,10 +41,8 @@ function WeeklyChart({ activities }) {
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
tickFormatter={v => `${v.toFixed(0)}`} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
formatter={(v, name) => [`${v.toFixed(1)} km`, 'Distance']}
/>
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
@@ -50,10 +59,7 @@ export default function DashboardPage() {
queryKey: ['activities-all-chart'],
queryFn: () =>
api.get('/activities/', {
params: {
per_page: 100,
from_date: subDays(new Date(), 60).toISOString(),
},
params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() },
}).then(r => r.data),
})
@@ -68,64 +74,45 @@ export default function DashboardPage() {
})
const latest = healthSummary?.latest
const totalActivities = recentActivities?.length ?? 0
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<Link
to="/upload"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
+ Import data
</Link>
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</div>
{/* Top stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Activities (10)" value={totalActivities} />
<StatCard label="Distance (10)" value={formatDistance(totalDistance)} accent="blue" />
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Weekly distance chart */}
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
<WeeklyChart activities={allActivities} />
</div>
{/* Health snapshot */}
<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>
{latest ? (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-500">HRV</span>
<span className="text-white">{latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Sleep score</span>
<span className="text-white">{latest.sleep_score ? Math.round(latest.sleep_score) : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Steps</span>
<span className="text-white">{latest.steps?.toLocaleString() ?? '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">VO2 Max</span>
<span className="text-white">{latest.vo2max ? latest.vo2max.toFixed(1) : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Stress</span>
<span className="text-white">{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}</span>
</div>
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">
View full health dashboard
</Link>
{[
['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]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span>
<span className="text-white">{val}</span>
</div>
))}
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">View full health dashboard </Link>
</>
) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
@@ -141,29 +128,17 @@ export default function DashboardPage() {
</div>
<div className="space-y-2">
{recentActivities?.slice(0, 5).map(activity => (
<Link
key={activity.id}
to={`/activities/${activity.id}`}
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors"
>
<Link key={activity.id} to={`/activities/${activity.id}`}
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div>
<div className="flex gap-4 text-sm text-right">
<div>
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
<p className="text-xs text-gray-600">dist</p>
</div>
<div>
<p className="text-gray-200">{formatDuration(activity.duration_s)}</p>
<p className="text-xs text-gray-600">time</p>
</div>
<div>
<p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
<p className="text-xs text-gray-600">HR</p>
</div>
<div><p className="text-gray-200">{formatDistance(activity.distance_m)}</p><p className="text-xs text-gray-600">dist</p></div>
<div><p className="text-gray-200">{formatDuration(activity.duration_s)}</p><p className="text-xs text-gray-600">time</p></div>
<div><p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p><p className="text-xs text-gray-600">HR</p></div>
</div>
</Link>
))}
@@ -175,7 +150,6 @@ export default function DashboardPage() {
</div>
</div>
{/* PRs snapshot */}
{records?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
+73 -132
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LineChart, Line, AreaChart, Area, BarChart, Bar,
@@ -10,6 +10,7 @@ import StatCard from '../components/ui/StatCard'
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
const RANGES = [
{ label: '1W', days: 7 },
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
@@ -17,7 +18,13 @@ const RANGES = [
{ label: '1Y', days: 365 },
]
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
)
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
@@ -28,36 +35,14 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={32}
tickFormatter={formatter}
/>
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={formatter} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
)
@@ -71,7 +56,8 @@ function SleepChart({ data }) {
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
}))
const hasData = chartData.some(d => d.deep || d.rem || d.light)
if (!hasData) return <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
@@ -80,9 +66,8 @@ function SleepChart({ data }) {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
tickFormatter={v => `${v}h`} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" radius={[0, 0, 0, 0]} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
@@ -92,21 +77,22 @@ function SleepChart({ data }) {
}
export default function HealthPage() {
const [rangeDays, setRangeDays] = useState(30)
const [rangeDays, setRangeDays] = useState(7) // default 1 week
const fromDate = subDays(new Date(), rangeDays).toISOString()
const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
const { data: summary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const { data: metrics } = useQuery({
const { data: metrics, isLoading } = useQuery({
queryKey: ['health-metrics', rangeDays],
queryFn: () =>
api.get('/health-metrics/', {
params: { from_date: fromDate, limit: rangeDays },
}).then(r => r.data.reverse()),
params: { from_date: fromDate, limit: rangeDays + 1 },
}).then(r => r.data.slice().reverse()), // oldest first for charts
keepPreviousData: true,
})
const latest = summary?.latest
@@ -118,132 +104,75 @@ export default function HealthPage() {
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard
label="Resting HR"
value={formatHeartRate(latest?.resting_hr)}
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined}
accent="red"
/>
<StatCard
label="HRV"
value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
sub={latest?.hrv_status || undefined}
/>
<StatCard
label="Sleep"
value={formatSleep(latest?.sleep_duration_s)}
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined}
/>
<StatCard
label="Weight"
value={formatWeight(latest?.weight_kg)}
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined}
/>
<StatCard
label="VO2 Max"
value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined}
accent="blue"
/>
<StatCard
label="Steps"
value={latest?.steps ? latest.steps.toLocaleString() : '--'}
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined}
/>
<StatCard
label="Avg Stress"
value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'}
/>
<StatCard
label="SpO2"
value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'}
/>
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
sub={latest?.hrv_status || undefined} />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
</div>
{/* Range selector */}
<div className="flex gap-2">
{RANGES.map(({ label, days }) => (
<button
key={label}
onClick={() => setRangeDays(days)}
<button key={label} onClick={() => setRangeDays(days)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
rangeDays === days
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}
>
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{label}
</button>
))}
</div>
{metrics && metrics.length > 0 ? (
{isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : metrics && metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Resting HR */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart
data={metrics}
dataKey="resting_hr"
color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`}
/>
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`} />
</div>
{/* HRV */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart
data={metrics}
dataKey="hrv_nightly_avg"
color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
/>
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`} />
</div>
{/* Sleep */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
<SleepChart data={metrics} />
<div className="flex gap-4 mt-2">
{[
{ label: 'Deep', color: '#6366f1' },
{ label: 'REM', color: '#8b5cf6' },
{ label: 'Light', color: '#a78bfa' },
{ label: 'Awake', color: '#374151' },
].map(({ label, color }) => (
<div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span>
</div>
))}
</div>
</div>
{/* Weight */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart
data={metrics}
dataKey="weight_kg"
color="#34d399"
formatter={v => `${v.toFixed(1)} kg`}
/>
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
formatter={v => `${v.toFixed(1)} kg`} />
</div>
{/* VO2 Max */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart
data={metrics}
dataKey="vo2max"
color="#3b82f6"
formatter={v => v.toFixed(1)}
/>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
</div>
{/* Steps */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}>
@@ -253,18 +182,30 @@ export default function HealthPage() {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
formatter={v => Math.round(v)} />
</div>
</div>
) : (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">📊</p>
<p className="text-lg">No health data yet</p>
<p className="text-sm mt-1">Import a Garmin export to see your health trends</p>
<p className="text-lg">No health data for this period</p>
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
</div>
)}
</div>
+266
View File
@@ -0,0 +1,266 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
function Section({ title, children }) {
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
{children}
</div>
)
}
function Field({ label, hint, children }) {
return (
<div>
<label className="text-xs text-gray-400 block mb-1">{label}</label>
{children}
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
</div>
)
}
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
return (
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
)
}
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
return (
<div className="flex items-center gap-3 pt-1">
<button onClick={onClick} disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{loading ? 'Saving…' : label}
</button>
{saved && <span className="text-green-400 text-sm"> Saved</span>}
</div>
)
}
export default function ProfilePage() {
const qc = useQueryClient()
const { user } = useAuthStore()
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data),
})
const { data: pocketidConfig } = useQuery({
queryKey: ['pocketid-config'],
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
enabled: !!user?.is_admin,
})
// HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
const [hrSaved, setHrSaved] = useState(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 || '',
})
}, [profile])
const updateProfile = useMutation({
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
})
// 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('')
const [pwSaved, setPwSaved] = useState(false)
const changePassword = useMutation({
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
})
// PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
const [pidSaved, setPidSaved] = useState(false)
useEffect(() => {
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
}, [pocketidConfig])
const savePocketID = useMutation({
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
})
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
return (
<div className="p-6 max-w-2xl space-y-6">
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
{/* HR & Measurements */}
<Section title="Heart Rate & Measurements">
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
{effectiveMaxHr && (
<div className="mt-2 text-white">
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
{!profile?.max_heart_rate && ' (estimated from age)'}
{' · '}Zones: Z1 &lt;{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}{Math.round(effectiveMaxHr * 0.9)}, Z5 &gt;{Math.round(effectiveMaxHr * 0.9)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
<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 }))} />
</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">
<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 }))} />
</Field>
<Field label="Height (cm)">
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
</Field>
</div>
<SaveButton
onClick={() => updateProfile.mutate(Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
))}
loading={updateProfile.isPending}
saved={hrSaved}
/>
</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 */}
<Section title="Change Password">
<div className="space-y-3">
<Field label="Current password">
<Input type="password" value={pwForm.current_password}
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
</Field>
<Field label="New password (min 8 characters)">
<Input type="password" value={pwForm.new_password}
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
</Field>
<Field label="Confirm new password">
<Input type="password" value={pwForm.confirm}
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
</Field>
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
</div>
<SaveButton
onClick={() => {
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
}}
loading={changePassword.isPending}
saved={pwSaved}
label="Change password"
/>
</Section>
{/* PocketID — admin only */}
{user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)">
<p className="text-xs text-gray-500">
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
</p>
<div className="space-y-3">
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
</Field>
<Field label="Client ID">
<Input value={pidForm.client_id} placeholder="milevault"
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
</Field>
<Field label="Client secret" hint="Leave blank to keep existing secret">
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
</Field>
{pocketidConfig?.enabled && (
<p className="text-xs text-green-400"> PocketID is currently active</p>
)}
</div>
<SaveButton
onClick={() => savePocketID.mutate(pidForm)}
loading={savePocketID.isPending}
saved={pidSaved}
label="Save PocketID config"
/>
</Section>
)}
</div>
)
}
+64 -83
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format'
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
@@ -20,18 +20,19 @@ export default function RoutesPage() {
enabled: !!selected,
})
const { data: segments } = useQuery({
queryKey: ['route-segments', selected?.id],
queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data),
enabled: !!selected,
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
onSuccess: () => {
onSuccess: (route) => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
},
})
@@ -40,55 +41,62 @@ export default function RoutesPage() {
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
<button
onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
<div>
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
<p className="text-xs text-gray-500 mt-1">
Routes are auto-detected when you run the same path twice. You can also create them manually.
</p>
</div>
<button onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ New route
</button>
</div>
{/* Create route modal */}
{/* Create route */}
{showCreate && (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Create named route</h3>
<p className="text-xs text-gray-500">
Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
<input
value={newRoute.name}
<input value={newRoute.name}
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. Morning park loop"
/>
placeholder="e.g. Morning park loop" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Reference activity ID</label>
<input
type="number"
value={newRoute.activity_id}
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Activity ID"
/>
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
{recentActivities?.length === 0 ? (
<p className="text-xs text-gray-600 py-2">No recent activities found.</p>
) : (
<select
value={newRoute.activity_id}
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select an activity</option>
{recentActivities?.map(a => (
<option key={a.id} value={a.id}>
{sportIcon(a.sport_type)} {a.name} {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
</option>
))}
</select>
)}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
disabled={!newRoute.name || !newRoute.activity_id}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Create
</button>
<button
onClick={() => setShowCreate(false)}
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
<button onClick={() => setShowCreate(false)}
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
@@ -98,23 +106,24 @@ export default function RoutesPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Route list */}
<div className="space-y-2">
{routes?.length === 0 && (
{routes?.length === 0 && !showCreate && (
<div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p>
<p className="text-sm">No named routes yet</p>
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
</div>
)}
{routes?.map(route => (
<button
key={route.id}
onClick={() => setSelected(route)}
<button key={route.id} onClick={() => setSelected(route)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selected?.id === route.id
? 'bg-blue-900/20 border-blue-700'
: 'bg-gray-900 border-gray-800 hover:border-gray-600'
}`}
>
<p className="font-medium text-white">{route.name}</p>
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
}`}>
<div className="flex items-start justify-between">
<p className="font-medium text-white">{route.name}</p>
{route.auto_detected && (
<span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
)}
</div>
<div className="flex gap-3 mt-1 text-xs text-gray-500">
<span>{formatDistance(route.distance_m)}</span>
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
@@ -128,19 +137,20 @@ export default function RoutesPage() {
{selected && (
<div className="lg:col-span-2 space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h2 className="text-lg font-semibold text-white mb-1">{selected.name}</h2>
{selected.description && (
<p className="text-sm text-gray-400 mb-3">{selected.description}</p>
)}
<div className="flex items-start justify-between mb-3">
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
{selected.auto_detected && (
<span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
Auto-detected
</span>
)}
</div>
{/* CR */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record</p>
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">
{formatDuration(fastest.duration_s)}
</span>
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
@@ -148,16 +158,12 @@ export default function RoutesPage() {
</div>
)}
{/* All runs on route */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All runs ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-2">
{routeActivities?.map((act, i) => (
<div
key={act.id}
className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm"
>
<div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
@@ -166,37 +172,12 @@ export default function RoutesPage() {
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">
CR
</span>
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
)}
</div>
))}
</div>
</div>
{/* Segments */}
{segments && segments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<div className="space-y-2">
{segments.map(seg => (
<div key={seg.id} className="flex items-center justify-between py-2 border-b border-gray-800/50">
<div>
<p className="text-sm font-medium text-white">{seg.name}</p>
{seg.description && (
<p className="text-xs text-gray-500">{seg.description}</p>
)}
</div>
<div className="text-xs text-gray-400 text-right">
<p>{formatDistance(seg.start_distance_m)} {formatDistance(seg.end_distance_m)}</p>
<p>{formatDistance(seg.end_distance_m - seg.start_distance_m)}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>