diff --git a/CLAUDE.md b/CLAUDE.md index f93da23..77ba706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,28 @@ docker compose -f docker-compose.deploy.yml up -d The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time. +### Request routing & browser caching + +There are **two** nginx configs and they serve different roles — don't confuse them: + +- `nginx.conf` (repo root) is the **dev/CI** reverse proxy (`docker-compose.yml`): one nginx that proxies `/api/`→`backend:8000` and `/`→`frontend:80`. +- `frontend/nginx-spa.conf` runs *inside the `frontend` image* and only serves the built SPA (the `milevault_frontend` container). It has no `/api` proxy. + +**In production (`docker-compose.deploy.yml` / `~/milevault_docker`) there is no nginx reverse-proxy service — Traefik does the routing via container labels:** `Host(...) && PathPrefix(/api)` → backend, `Host(...)` → frontend SPA. The empty `~/milevault_docker/nginx.conf` is an unused leftover. So if a request reaches the SPA nginx with an `/api` path it falls through to `index.html` (returns HTML, not JSON) — a sign Traefik routing, not nginx, is the thing to debug. + +**Caching policy (set deliberately; a wrong change here strands users on stale builds):** +- Hashed assets (`*.js`/`*.css`) → `Cache-Control: public, immutable` (1y) in `nginx-spa.conf`. +- `index.html` → `no-cache` (must revalidate) in `nginx-spa.conf`, so new deploys are picked up without a manual cache clear. +- All `/api/*` responses → `no-store`, added by an HTTP middleware in `backend/app/main.py`, so browsers can't cache authenticated state (e.g. a stale "Garmin not connected"). + +### Deploy verification + +After `./deploy.sh`, the Gitea Actions build (`validate` → `build-backend`/`build-worker`/`build-frontend`) must finish before `docker compose pull && up -d` picks up new `:latest` images. Poll completion via the API rather than guessing: +```bash +curl -s https://gitea.jarrett.eu/api/v1/repos/owain/MileVault/actions/tasks +``` +The runner builds images on-host, so local image digests won't match the registry — verify a deploy landed by `docker exec`-ing into the running container and grepping the changed source, not by comparing digests. + ## Environment variables Required in `.env` (or passed to Docker Compose): diff --git a/frontend/src/components/activity/LapTable.jsx b/frontend/src/components/activity/LapTable.jsx index b2d6824..a843a21 100644 --- a/frontend/src/components/activity/LapTable.jsx +++ b/frontend/src/components/activity/LapTable.jsx @@ -18,7 +18,7 @@ function modalDistance(laps) { return best } -export default function LapTable({ laps, sportType, lapBests, records }) { +export default function LapTable({ laps, sportType, lapBests, records, unit = 'km' }) { const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase()) const hasBests = lapBests && Object.keys(lapBests).length > 0 const modal = modalDistance(laps) @@ -69,7 +69,7 @@ export default function LapTable({ laps, sportType, lapBests, records }) { {isPR && 🥇} - {formatDistance(lap.distance_m)} + {formatDistance(lap.distance_m, unit)} {formatDuration(lap.duration_s)} {hasBests && ( {best != null ? formatDuration(best) : '--'} @@ -81,7 +81,7 @@ export default function LapTable({ laps, sportType, lapBests, records }) { {delta == null ? '--' : isLapBest ? 🏆 : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`} )} - {formatPace(lap.avg_speed_ms, sportType)} + {formatPace(lap.avg_speed_ms, sportType, unit)} {formatHeartRate(lap.avg_heart_rate)} diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx index ebf124a..75f5605 100644 --- a/frontend/src/components/activity/MetricTimeline.jsx +++ b/frontend/src/components/activity/MetricTimeline.jsx @@ -3,7 +3,7 @@ import { ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts' -import { formatPace, formatCadence } from '../../utils/format' +import { formatPace, formatCadence, formatDistance, formatElevation, distanceUnitLabel } from '../../utils/format' // Running cadence colour bands (steps per minute). Cadence is stored halved for // running, so spm = stored × 2. @@ -51,22 +51,22 @@ function buildChartData(dataPoints, activeMetrics, useTimeAxis) { }) } -const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis }) => { +const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis, unit = 'km' }) => { if (!active || !payload?.length) return null if (onHover) onHover(label) return (
-

{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}

+

{useTimeAxis ? fmtSeconds(label) : formatDistance(label, unit)}

{payload.map(entry => { const metric = metrics.find(m => m.key === entry.dataKey) if (!metric || entry.value == null) return null let display = entry.value.toFixed(1) - if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType) + if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType, unit) else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm` else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType) else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W` else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C` - else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m` + else if (entry.dataKey === 'altitude_m') display = formatElevation(entry.value, unit) return (
@@ -79,7 +79,7 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, us ) } -export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) { +export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType, unit = 'km' }) { // Stationary/indoor activities (HIIT, strength, trainer) record no distance, so // plotting against distance collapses every sample onto x=0. Fall back to an // elapsed-time X-axis when there's no distance spread. @@ -142,7 +142,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH dataKey="x" type="number" domain={['dataMin', 'dataMax']} - tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`} + tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(unit === 'mi' ? v / 1609.344 : v / 1000).toFixed(1)}`} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} @@ -157,8 +157,8 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH tickFormatter={v => { if (metric.key === 'speed_ms') { if (v <= 0 || v > 25) return '' - if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}` - const spm = 1000 / v + if (sportType === 'cycling') return `${(unit === 'mi' ? v * 2.2369363 : v * 3.6).toFixed(0)}` + const spm = (unit === 'mi' ? 1609.344 : 1000) / v return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}` } if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1)) @@ -166,7 +166,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH }} /> } + content={} isAnimationActive={false} /> {metric.key === 'cadence' && sportType === 'running' ? ( @@ -191,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
) })} -

{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}

+

{useTimeAxis ? 'Elapsed time (mm:ss)' : `Distance (${distanceUnitLabel(unit)})`}

) } diff --git a/frontend/src/components/activity/SegmentsPanel.jsx b/frontend/src/components/activity/SegmentsPanel.jsx index 904ad73..bab1639 100644 --- a/frontend/src/components/activity/SegmentsPanel.jsx +++ b/frontend/src/components/activity/SegmentsPanel.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import api from '../../utils/api' import { formatDuration, formatDistance } from '../../utils/format' +import { useUnit } from '../../hooks/useUnits' const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' } const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' } @@ -72,6 +73,7 @@ function Leaderboard({ segmentId, activityId }) { export default function SegmentsPanel({ segments, activityId }) { const qc = useQueryClient() + const unit = useUnit() const [open, setOpen] = useState(null) const remove = async (id) => { @@ -109,7 +111,7 @@ export default function SegmentsPanel({ segments, activityId }) { > {isOpen ? '▾' : '▸'} {seg.name} - {formatDistance(seg.distance_m)} + {formatDistance(seg.distance_m, unit)} diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx index 0b03099..ec5842f 100644 --- a/frontend/src/components/ui/Layout.jsx +++ b/frontend/src/components/ui/Layout.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../hooks/useAuth' import { useSyncStore, syncProgressPct } from '../../hooks/useSync' +import UnitToggle from './UnitToggle' const nav = [ { to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true }, @@ -106,6 +107,14 @@ export default function Layout() { )} + {/* Distance-unit toggle (km / mi) */} + {!collapsed && ( +
+ Units + +
+ )} + {/* Logged-in user + privilege level */}
{user ? ( @@ -146,6 +155,7 @@ export default function Layout() { MileVault
+ {inProgress && ( diff --git a/frontend/src/components/ui/UnitToggle.jsx b/frontend/src/components/ui/UnitToggle.jsx new file mode 100644 index 0000000..22b9670 --- /dev/null +++ b/frontend/src/components/ui/UnitToggle.jsx @@ -0,0 +1,24 @@ +import { useUnitsStore } from '../../hooks/useUnits' + +// Compact km / mi segmented toggle. Controls the global distance unit used +// across the dashboard, activities, routes and records. +export default function UnitToggle({ className = '' }) { + const unit = useUnitsStore((s) => s.unit) + const setUnit = useUnitsStore((s) => s.setUnit) + + return ( +
+ {['km', 'mi'].map((u) => ( + + ))} +
+ ) +} diff --git a/frontend/src/hooks/useUnits.js b/frontend/src/hooks/useUnits.js new file mode 100644 index 0000000..61d4e85 --- /dev/null +++ b/frontend/src/hooks/useUnits.js @@ -0,0 +1,25 @@ +import { create } from 'zustand' + +// Global distance-unit preference ('km' | 'mi'). Distances are always stored +// canonically (metres / kilometres); this only controls display, converted on +// the fly by the format helpers. Persisted to localStorage so the choice sticks +// across reloads and is shared by every page. +const initial = localStorage.getItem('distanceUnit') === 'mi' ? 'mi' : 'km' + +export const useUnitsStore = create((set) => ({ + unit: initial, + setUnit: (u) => { + const next = u === 'mi' ? 'mi' : 'km' + localStorage.setItem('distanceUnit', next) + set({ unit: next }) + }, + toggle: () => + set((s) => { + const next = s.unit === 'km' ? 'mi' : 'km' + localStorage.setItem('distanceUnit', next) + return { unit: next } + }), +})) + +// Convenience hook: subscribe to just the active unit string. +export const useUnit = () => useUnitsStore((s) => s.unit) diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx index 5e4d7f0..cfb2086 100644 --- a/frontend/src/pages/ActivitiesPage.jsx +++ b/frontend/src/pages/ActivitiesPage.jsx @@ -4,9 +4,10 @@ import { useQuery } from '@tanstack/react-query' import { format } from 'date-fns' import api from '../utils/api' import { - formatDuration, formatDistance, formatPace, formatHeartRate, - formatDate, sportIcon, sportColor, + formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation, + formatDate, sportIcon, sportColor, convertKm, distanceUnitLabel, } from '../utils/format' +import { useUnit } from '../hooks/useUnits' const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking'] @@ -15,6 +16,8 @@ export default function ActivitiesPage() { const navigate = useNavigate() const [sport, setSport] = useState('all') const [page, setPage] = useState(1) + const unit = useUnit() + const distLabel = distanceUnitLabel(unit) const fromParam = searchParams.get('from') const toParam = searchParams.get('to') @@ -56,10 +59,10 @@ export default function ActivitiesPage() { {ytdStats && (
{ytdStats.running_km > 0 && ( - 🏃 {ytdStats.running_km.toFixed(0)} km this year + 🏃 {convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year )} {ytdStats.cycling_km > 0 && ( - 🚴 {ytdStats.cycling_km.toFixed(0)} km this year + 🚴 {convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year )}
)} @@ -118,14 +121,14 @@ export default function ActivitiesPage() {

{formatDate(activity.start_time)}

{/* Compact metrics line — the full metrics column is hidden below sm */}

- {formatDistance(activity.distance_m)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type)} + {formatDistance(activity.distance_m, unit)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type, unit)}

{/* Metrics */}
-

{formatDistance(activity.distance_m)}

+

{formatDistance(activity.distance_m, unit)}

distance

@@ -133,7 +136,7 @@ export default function ActivitiesPage() {

time

-

{formatPace(activity.avg_speed_ms, activity.sport_type)}

+

{formatPace(activity.avg_speed_ms, activity.sport_type, unit)}

pace

@@ -142,7 +145,7 @@ export default function ActivitiesPage() {

- {activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'} + {activity.elevation_gain_m ? `↑ ${formatElevation(activity.elevation_gain_m, unit)}` : '--'}

elev

diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index 5552c0a..124962e 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -13,6 +13,7 @@ import { formatDuration, formatDistance, formatPace, formatElevation, formatHeartRate, formatDateTime, formatCadence, sportIcon, } from '../utils/format' +import { useUnit } from '../hooks/useUnits' import { projectToTrack } from '../utils/track' @@ -27,6 +28,7 @@ const METRICS = [ export default function ActivityDetailPage() { const { id } = useParams() + const unit = useUnit() const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) const [hoveredDistance, setHoveredDistance] = useState(null) const [mapHeight, setMapHeight] = useState(420) @@ -155,18 +157,18 @@ export default function ActivityDetailPage() { {/* Stats — all on one row */}
- + {activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && ( )} - - + + - +
@@ -245,8 +247,8 @@ export default function ActivityDetailPage() { Click two points on the route to mark the segment start and end. - Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'} - {' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'} + Start: {segPoints[0] ? formatDistance(segPoints[0].distance_m, unit) : '—'} + {' · '}End: {segPoints[1] ? formatDistance(segPoints[1].distance_m, unit) : '—'} {segPoints.length === 2 && ( <> @@ -318,9 +320,10 @@ export default function ActivityDetailPage() { availableMetrics.has(m))} - metrics={METRICS} + metrics={METRICS.map(m => m.key === 'altitude_m' ? { ...m, unit: unit === 'mi' ? 'ft' : 'm' } : m)} onHoverDistance={setHoveredDistance} sportType={activity.sport_type} + unit={unit} /> ) : (

No timeline data available for this activity

@@ -335,7 +338,7 @@ export default function ActivityDetailPage() { {laps && laps.length > 0 && (

Laps

- +
)} {routeBoard && routeBoard.top?.length > 0 && ( diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 4dc3d2c..78fb935 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -16,8 +16,9 @@ import SleepHypnogram from '../components/health/SleepHypnogram' import ActivityMap from '../components/activity/ActivityMap' import { formatDuration, formatDistance, formatHeartRate, formatElevation, - formatDate, sportIcon, sportColor, formatSleep, + formatDate, sportIcon, sportColor, formatSleep, convertKm, distanceUnitLabel, } from '../utils/format' +import { useUnit } from '../hooks/useUnits' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' const Grid = WidthProvider(GridLayout) @@ -32,8 +33,8 @@ const STAT_DEFS = { stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) }, stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined }, stat_hrv: { label: 'HRV status', accent: 'purple', val: h => (h.hrv_weekly_avg ?? h.hrv_nightly_avg) != null ? `${Math.round(h.hrv_weekly_avg ?? h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status ? : undefined }, - stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' }, - stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' }, + stat_running: { label: 'Running this year', accent: 'blue', val: (h, y, u) => y ? `${convertKm(y.running_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' }, + stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y, u) => y ? `${convertKm(y.cycling_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' }, stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' }, stat_calories: { label: 'Active calories', accent: 'orange', val: h => h.active_calories != null ? Math.round(h.active_calories).toLocaleString() : '--' }, stat_floors: { label: 'Floors climbed', accent: 'green', val: h => h.floors_climbed != null ? h.floors_climbed : '--' }, @@ -288,6 +289,8 @@ const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other') function WeeklyChart({ activities }) { const navigate = useNavigate() + const unit = useUnit() + const distLabel = distanceUnitLabel(unit) const { data, sports } = useMemo(() => { if (!activities?.length) return { data: [], sports: [] } // Sports present, ordered by total distance (largest stacks at the bottom). @@ -304,14 +307,14 @@ function WeeklyChart({ activities }) { const t = new Date(a.start_time) if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000 } - for (const s of sports) row[s] = +row[s].toFixed(2) + for (const s of sports) row[s] = +convertKm(row[s], unit).toFixed(2) return row }) return { data, sports } - }, [activities]) + }, [activities, unit]) return ( - + {data.length ? (
@@ -323,7 +326,7 @@ function WeeklyChart({ activities }) { `${v.toFixed(0)}`} /> [`${(+v).toFixed(1)} km`, sportLabel(name)]} /> + formatter={(v, name) => [`${(+v).toFixed(1)} ${distLabel}`, sportLabel(name)]} /> {sports.map((s, i) => ( @@ -348,6 +351,7 @@ function WeeklyChart({ activities }) { } function FeaturedActivity({ activity, segments }) { + const unit = useUnit() if (!activity) return (
No activities yet
) @@ -370,8 +374,8 @@ function FeaturedActivity({ activity, segments }) { :
No GPS track
}
- - + +
@@ -406,6 +410,7 @@ function FeaturedActivity({ activity, segments }) { } function RecentActivities({ activities }) { + const unit = useUnit() return (
@@ -421,7 +426,7 @@ function RecentActivities({ activities }) {

{formatDate(activity.start_time)}

-

{formatDistance(activity.distance_m)}

+

{formatDistance(activity.distance_m, unit)}

{formatHeartRate(activity.avg_heart_rate)}

@@ -523,6 +528,7 @@ export default function DashboardPage() { }) // ── Layout state ────────────────────────────────────────────────────────── + const unit = useUnit() const isMobile = useIsMobile() const [editMode, setEditMode] = useState(false) const [addOpen, setAddOpen] = useState(false) @@ -576,7 +582,7 @@ export default function DashboardPage() { const renderWidget = (id) => { if (STAT_DEFS[id]) { const d = STAT_DEFS[id] - return } switch (id) { diff --git a/frontend/src/pages/RecordsPage.jsx b/frontend/src/pages/RecordsPage.jsx index 5fffb2b..43a797f 100644 --- a/frontend/src/pages/RecordsPage.jsx +++ b/frontend/src/pages/RecordsPage.jsx @@ -5,6 +5,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai import { format } from 'date-fns' import api from '../utils/api' import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format' +import { useUnit } from '../hooks/useUnits' import RouteMiniMap from '../components/ui/RouteMiniMap' const SPORTS = ['running', 'cycling'] @@ -155,6 +156,7 @@ function DistancePRs() { function RouteRecords() { const navigate = useNavigate() + const unit = useUnit() const { data: records, isLoading } = useQuery({ queryKey: ['route-records'], queryFn: () => api.get('/records/routes').then(r => r.data), @@ -198,13 +200,13 @@ function RouteRecords() { {rec.route_name} - {formatDistance(rec.distance_m)} + {formatDistance(rec.distance_m, unit)} {formatDuration(rec.duration_s)} - {formatPace(rec.avg_speed_ms, rec.sport_type)} + {formatPace(rec.avg_speed_ms, rec.sport_type, unit)} {formatDate(rec.start_time)} @@ -243,6 +245,7 @@ function SegmentLeaderboard({ segmentId }) { function SegmentRecords() { const [open, setOpen] = useState(null) + const unit = useUnit() const { data: segments, isLoading } = useQuery({ queryKey: ['segments'], queryFn: () => api.get('/segments/').then(r => r.data), @@ -287,7 +290,7 @@ function SegmentRecords() { {seg.name} - {formatDistance(seg.distance_m)} + {formatDistance(seg.distance_m, unit)} {seg.best_s != null ? formatDuration(seg.best_s) : '--'} diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index b7db925..adbb651 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import ActivityMap from '../components/activity/ActivityMap' import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' +import { useUnit } from '../hooks/useUnits' // Decode Google encoded polyline to [[lat,lng], ...] function decodePolyline(encoded) { @@ -61,6 +62,7 @@ const MEDALS = ['🥇', '🥈', '🥉'] function RouteDetail({ selected, setSelected }) { const qc = useQueryClient() + const unit = useUnit() const [merging, setMerging] = useState(false) const [mergeTarget, setMergeTarget] = useState('') const [editingName, setEditingName] = useState(false) @@ -141,7 +143,7 @@ function RouteDetail({ selected, setSelected }) { )}
{selected.sport_type && {selected.sport_type}} - {formatDistance(selected.distance_m)} + {formatDistance(selected.distance_m, unit)} {selected.auto_detected && ( Auto-detected )} @@ -171,7 +173,7 @@ function RouteDetail({ selected, setSelected }) { className="flex-1 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-yellow-500"> {otherRoutes.map(r => ( - + ))}