Fix pace sentinel, route map thumbnails, tiled segments, health/dashboard layout
- Pace: FIT 0xFFFF sentinel (65.535 m/s) was stored as avg_speed_ms on every activity and lap; add _sanitize_speed() to parser falling back to dist/dur, plus a startup SQL migration that fixed 120 activities and 688 laps in-place - Records: remove swimming from Distance PRs; Route Records rows are clickable (navigate to activity), View button removed, small SVG route map per row; Segment Records uses same tiled route-card layout as Segments page - Segments: replace route dropdown with responsive tile grid showing SVG map thumbnails; selecting a tile reveals the segment management panel below - RouteMiniMap: new pure-SVG component (no Leaflet) for route thumbnails, decodes polyline and normalises coords into a fixed viewBox - Health: rename "Avg Heart Rate (day)" → "Heart Rate"; weight chart now filters to non-null rows and enables connectNulls + dots for sparse data - Dashboard: 4-col layout at lg breakpoint so Body Battery sits between weekly chart and Health Today; Body Battery card gains a 24-hr sparkline from the values[] already present in the health summary response Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -103,3 +103,13 @@ Required in `.env` (or passed to Docker Compose):
|
||||
| `BASE_URL` | Used for PocketID OAuth callback redirect URI |
|
||||
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer |
|
||||
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
|
||||
|
||||
## Debugging and troubleshooting
|
||||
- The latest build will always be running in docker at ~/milevault_docker with the following container names:
|
||||
`milevault_backend`
|
||||
`milevault_db`
|
||||
`milevault_frontend`
|
||||
`milevault_redis`
|
||||
`milevault_worker`
|
||||
- When an issue is highlighted by the user, check the logs on these containers for the error, do not spin up new containers, rectify the issues in ~/milevault where the development is happening
|
||||
- Do NOT patch the running files under any circumstances, fix the development files.
|
||||
@@ -58,6 +58,7 @@ async def route_records(
|
||||
nr.name AS route_name,
|
||||
nr.sport_type,
|
||||
nr.distance_m,
|
||||
nr.reference_polyline,
|
||||
a.id AS activity_id,
|
||||
a.name AS activity_name,
|
||||
a.duration_s,
|
||||
|
||||
@@ -106,6 +106,20 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"FK migration skipped: {e}")
|
||||
|
||||
# Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"UPDATE activities SET avg_speed_ms = distance_m / duration_s "
|
||||
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s "
|
||||
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"avg_speed_ms fix skipped: {e}")
|
||||
|
||||
# Seed admin user (only if password is configured)
|
||||
if not settings.admin_password:
|
||||
print("ADMIN_PASSWORD not set - skipping admin user seed")
|
||||
|
||||
@@ -34,6 +34,16 @@ def _safe_float(val) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
|
||||
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
|
||||
fv = _safe_float(val)
|
||||
if fv is None or fv >= 65.0:
|
||||
if dist_m and dur_s and float(dur_s) > 0:
|
||||
return float(dist_m) / float(dur_s)
|
||||
return None
|
||||
return fv
|
||||
|
||||
|
||||
def _bounding_box(coords):
|
||||
if not coords:
|
||||
return None
|
||||
@@ -180,15 +190,19 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
normalized_laps = []
|
||||
for i, lap in enumerate(laps):
|
||||
ls = _to_dt(get(lap, "startTime", "start_time"))
|
||||
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
|
||||
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
|
||||
normalized_laps.append({
|
||||
"lap_number": i + 1,
|
||||
"start_time": ls.isoformat() if ls else None,
|
||||
"duration_s": _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time")),
|
||||
"distance_m": _safe_float(get(lap, "totalDistance", "total_distance")),
|
||||
"duration_s": lap_dur,
|
||||
"distance_m": lap_dist,
|
||||
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
|
||||
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
|
||||
"avg_speed_ms": _safe_float(get(lap, "avgSpeed", "avg_speed",
|
||||
"enhancedAvgSpeed", "enhanced_avg_speed")),
|
||||
"avg_speed_ms": _sanitize_speed(
|
||||
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
|
||||
dist_m=lap_dist, dur_s=lap_dur,
|
||||
),
|
||||
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
|
||||
})
|
||||
|
||||
@@ -209,8 +223,11 @@ def parse_fit_file(filepath: str) -> dict:
|
||||
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
|
||||
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
|
||||
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
|
||||
"avg_speed_ms": _safe_float(get(session_data, "avgSpeed", "avg_speed",
|
||||
"enhancedAvgSpeed", "enhanced_avg_speed")),
|
||||
"avg_speed_ms": _sanitize_speed(
|
||||
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
|
||||
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
|
||||
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
|
||||
),
|
||||
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
|
||||
"enhancedMaxSpeed", "enhanced_max_speed")),
|
||||
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
|
||||
|
||||
@@ -10,7 +10,7 @@ git commit -m "$MESSAGE"
|
||||
git push
|
||||
|
||||
cd ../milevault_docker
|
||||
docker compose down -v
|
||||
docker compose down
|
||||
|
||||
echo ""
|
||||
echo "Done. Run 'docker compose pull && docker compose up -d' when the build completes."
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react'
|
||||
import { sportColor } from '../../utils/format'
|
||||
|
||||
function decodePolyline(encoded) {
|
||||
const coords = []
|
||||
let index = 0, lat = 0, lng = 0
|
||||
while (index < encoded.length) {
|
||||
let b, shift = 0, result = 0
|
||||
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
||||
shift = 0; result = 0
|
||||
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
||||
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
||||
coords.push([lat / 1e5, lng / 1e5])
|
||||
}
|
||||
return coords
|
||||
}
|
||||
|
||||
// Internal viewBox dimensions — path is always drawn into this space, SVG scales it
|
||||
const VW = 100
|
||||
const VH = 80
|
||||
const PAD = 6
|
||||
|
||||
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60 }) {
|
||||
const pathD = useMemo(() => {
|
||||
if (!polyline) return null
|
||||
const coords = decodePolyline(polyline)
|
||||
if (coords.length < 2) return null
|
||||
|
||||
const lats = coords.map(c => c[0])
|
||||
const lngs = coords.map(c => c[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
const latRange = maxLat - minLat || 0.001
|
||||
const lngRange = maxLng - minLng || 0.001
|
||||
|
||||
const drawW = VW - PAD * 2
|
||||
const drawH = VH - PAD * 2
|
||||
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
||||
const offX = PAD + (drawW - lngRange * scale) / 2
|
||||
const offY = PAD + (drawH - latRange * scale) / 2
|
||||
|
||||
return coords.map((c, i) => {
|
||||
const x = offX + (c[1] - minLng) * scale
|
||||
const y = offY + (maxLat - c[0]) * scale
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}, [polyline])
|
||||
|
||||
const svgProps = {
|
||||
viewBox: `0 0 ${VW} ${VH}`,
|
||||
preserveAspectRatio: 'xMidYMid meet',
|
||||
className: 'rounded overflow-hidden block',
|
||||
style: { background: '#111827', width, height },
|
||||
}
|
||||
|
||||
if (!pathD) return (
|
||||
<svg {...svgProps}>
|
||||
<text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10">—</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path d={pathD} fill="none" stroke={sportColor(sportType)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
@@ -19,10 +19,13 @@ function bbLevelColor(level) {
|
||||
|
||||
function MiniBodyBattery({ bb }) {
|
||||
if (!bb?.end_level && !bb?.charged) return null
|
||||
const { charged, drained, start_level, end_level } = bb
|
||||
const { charged, drained, start_level, end_level, values } = bb
|
||||
const color = bbLevelColor(end_level)
|
||||
const sparkData = Array.isArray(values)
|
||||
? values.map(([ts, level]) => ({ ts, level }))
|
||||
: []
|
||||
return (
|
||||
<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 h-full">
|
||||
<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>
|
||||
@@ -41,6 +44,27 @@ function MiniBodyBattery({ bb }) {
|
||||
{start_level != null && end_level != null && (
|
||||
<p className="text-xs text-gray-500 mt-1">{start_level} → {end_level} today</p>
|
||||
)}
|
||||
{sparkData.length >= 2 && (
|
||||
<div className="mt-3">
|
||||
<ResponsiveContainer width="100%" height={60}>
|
||||
<AreaChart data={sparkData} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="bbGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="level" stroke={color} strokeWidth={1.5}
|
||||
fill="url(#bbGrad)" dot={false} isAnimationActive={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11 }}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)}`, 'Battery']}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -145,36 +169,37 @@ export default function DashboardPage() {
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="lg:col-span-1">
|
||||
<MiniBodyBattery bb={latest?.body_battery} />
|
||||
<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 ? (
|
||||
<>
|
||||
{[
|
||||
['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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1 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 ? (
|
||||
<>
|
||||
{[
|
||||
['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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
|
||||
|
||||
// ── Trend Charts ────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick }) {
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false }) {
|
||||
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>
|
||||
@@ -465,7 +465,9 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
|
||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
)}
|
||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
|
||||
fill={`url(#grad-${dataKey})`}
|
||||
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
|
||||
connectNulls={connectNulls} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
@@ -657,9 +659,12 @@ export default function HealthPage() {
|
||||
|
||||
<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"
|
||||
<MetricChart
|
||||
data={metrics.filter(d => d.weight_kg != null)}
|
||||
dataKey="weight_kg" color="#34d399"
|
||||
formatter={v => `${v.toFixed(1)} kg`}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||
connectNulls showDots />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
@@ -697,7 +702,7 @@ export default function HealthPage() {
|
||||
</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>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||
formatter={v => `${Math.round(v)} bpm`}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
||||
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||
|
||||
const SPORTS = ['running', 'cycling', 'swimming']
|
||||
const SPORTS = ['running', 'cycling']
|
||||
|
||||
const DISTANCE_ORDER = [
|
||||
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
|
||||
@@ -149,6 +150,7 @@ function DistancePRs() {
|
||||
}
|
||||
|
||||
function RouteRecords() {
|
||||
const navigate = useNavigate()
|
||||
const { data: records, isLoading } = useQuery({
|
||||
queryKey: ['route-records'],
|
||||
queryFn: () => api.get('/records/routes').then(r => r.data),
|
||||
@@ -168,38 +170,40 @@ function RouteRecords() {
|
||||
<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" />
|
||||
<th className="px-3 py-3" />
|
||||
<th className="text-left px-3 py-3 font-medium">Route</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Distance</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Best time</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Pace</th>
|
||||
<th className="text-right px-3 py-3 font-medium">Date</th>
|
||||
</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">
|
||||
<tr
|
||||
key={rec.route_id}
|
||||
onClick={() => navigate(`/activities/${rec.activity_id}`)}
|
||||
className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<RouteMiniMap polyline={rec.reference_polyline} sportType={rec.sport_type} width={72} height={50} />
|
||||
</td>
|
||||
<td className="px-3 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">
|
||||
<td className="px-3 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">
|
||||
<td className="px-3 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">
|
||||
<td className="px-3 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">
|
||||
<td className="px-3 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>
|
||||
@@ -226,33 +230,49 @@ function SegmentRecords() {
|
||||
? bests.reduce((sum, b) => sum + b.best_s, 0)
|
||||
: null
|
||||
|
||||
if (!routes?.length) return (
|
||||
<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>
|
||||
)
|
||||
|
||||
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"
|
||||
{/* Route tile grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{routes.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
|
||||
className={`text-left rounded-xl border p-2 transition-colors ${
|
||||
selectedRouteId === r.id
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<RouteMiniMap
|
||||
polyline={r.reference_polyline}
|
||||
sportType={r.sport_type}
|
||||
width="100%"
|
||||
height={80}
|
||||
/>
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
|
||||
{r.distance_m && (
|
||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
<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">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, formatDistance } from '../utils/format'
|
||||
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||
|
||||
function formatSegmentDist(m) {
|
||||
if (m == null) return '--'
|
||||
@@ -218,26 +219,37 @@ export default function SegmentsPage() {
|
||||
<h1 className="text-2xl font-bold text-white">Segments</h1>
|
||||
</div>
|
||||
|
||||
{/* Route selector */}
|
||||
<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 ? (
|
||||
{/* Route tile grid */}
|
||||
{!routes?.length ? (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{routes.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setSelectedRouteId(r.id === selectedRouteId ? null : r.id)}
|
||||
className={`text-left rounded-xl border p-2 transition-colors ${
|
||||
selectedRouteId === r.id
|
||||
? 'border-blue-500 bg-blue-900/20'
|
||||
: 'border-gray-800 bg-gray-900 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<RouteMiniMap
|
||||
polyline={r.reference_polyline}
|
||||
sportType={r.sport_type}
|
||||
width="100%"
|
||||
height={80}
|
||||
/>
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
|
||||
{r.distance_m && (
|
||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRoute && (
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user