frontend: global km/mi distance-unit toggle across dashboard, activities, routes, records
This commit is contained in:
@@ -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.
|
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
|
## Environment variables
|
||||||
|
|
||||||
Required in `.env` (or passed to Docker Compose):
|
Required in `.env` (or passed to Docker Compose):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function modalDistance(laps) {
|
|||||||
return best
|
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 showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
|
||||||
const hasBests = lapBests && Object.keys(lapBests).length > 0
|
const hasBests = lapBests && Object.keys(lapBests).length > 0
|
||||||
const modal = modalDistance(laps)
|
const modal = modalDistance(laps)
|
||||||
@@ -69,7 +69,7 @@ export default function LapTable({ laps, sportType, lapBests, records }) {
|
|||||||
{isPR && <span title="Personal best">🥇</span>}
|
{isPR && <span title="Personal best">🥇</span>}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m, unit)}</td>
|
||||||
<td className={`py-2 text-right ${isPR || isLapBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
<td className={`py-2 text-right ${isPR || isLapBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
|
||||||
{hasBests && (
|
{hasBests && (
|
||||||
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
|
||||||
@@ -81,7 +81,7 @@ export default function LapTable({ laps, sportType, lapBests, records }) {
|
|||||||
{delta == null ? '--' : isLapBest ? <span title="Fastest on this route">🏆</span> : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`}
|
{delta == null ? '--' : isLapBest ? <span title="Fastest on this route">🏆</span> : `${delta > 0 ? '+' : '−'}${formatDuration(Math.abs(delta))}`}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType, unit)}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
|
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} 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 cadence colour bands (steps per minute). Cadence is stored halved for
|
||||||
// running, so spm = stored × 2.
|
// 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 (!active || !payload?.length) return null
|
||||||
if (onHover) onHover(label)
|
if (onHover) onHover(label)
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
||||||
<p className="text-gray-400 mb-1">{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}</p>
|
<p className="text-gray-400 mb-1">{useTimeAxis ? fmtSeconds(label) : formatDistance(label, unit)}</p>
|
||||||
{payload.map(entry => {
|
{payload.map(entry => {
|
||||||
const metric = metrics.find(m => m.key === entry.dataKey)
|
const metric = metrics.find(m => m.key === entry.dataKey)
|
||||||
if (!metric || entry.value == null) return null
|
if (!metric || entry.value == null) return null
|
||||||
let display = entry.value.toFixed(1)
|
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 === 'heart_rate') display = `${Math.round(entry.value)} bpm`
|
||||||
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
|
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 === 'power') display = `${Math.round(entry.value)} W`
|
||||||
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
|
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 (
|
return (
|
||||||
<div key={entry.dataKey} className="flex items-center gap-2">
|
<div key={entry.dataKey} className="flex items-center gap-2">
|
||||||
<span style={{ color: entry.color }}>●</span>
|
<span style={{ color: entry.color }}>●</span>
|
||||||
@@ -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
|
// Stationary/indoor activities (HIIT, strength, trainer) record no distance, so
|
||||||
// plotting against distance collapses every sample onto x=0. Fall back to an
|
// plotting against distance collapses every sample onto x=0. Fall back to an
|
||||||
// elapsed-time X-axis when there's no distance spread.
|
// elapsed-time X-axis when there's no distance spread.
|
||||||
@@ -142,7 +142,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
dataKey="x"
|
dataKey="x"
|
||||||
type="number"
|
type="number"
|
||||||
domain={['dataMin', 'dataMax']}
|
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' }}
|
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -157,8 +157,8 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
tickFormatter={v => {
|
tickFormatter={v => {
|
||||||
if (metric.key === 'speed_ms') {
|
if (metric.key === 'speed_ms') {
|
||||||
if (v <= 0 || v > 25) return ''
|
if (v <= 0 || v > 25) return ''
|
||||||
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
if (sportType === 'cycling') return `${(unit === 'mi' ? v * 2.2369363 : v * 3.6).toFixed(0)}`
|
||||||
const spm = 1000 / v
|
const spm = (unit === 'mi' ? 1609.344 : 1000) / v
|
||||||
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
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))
|
if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
|
||||||
@@ -166,7 +166,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
|
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} unit={unit} />}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
{metric.key === 'cadence' && sportType === 'running' ? (
|
{metric.key === 'cadence' && sportType === 'running' ? (
|
||||||
@@ -191,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}</p>
|
<p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : `Distance (${distanceUnitLabel(unit)})`}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import { formatDuration, formatDistance } from '../../utils/format'
|
import { formatDuration, formatDistance } from '../../utils/format'
|
||||||
|
import { useUnit } from '../../hooks/useUnits'
|
||||||
|
|
||||||
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
||||||
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||||
@@ -72,6 +73,7 @@ function Leaderboard({ segmentId, activityId }) {
|
|||||||
|
|
||||||
export default function SegmentsPanel({ segments, activityId }) {
|
export default function SegmentsPanel({ segments, activityId }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const unit = useUnit()
|
||||||
const [open, setOpen] = useState(null)
|
const [open, setOpen] = useState(null)
|
||||||
|
|
||||||
const remove = async (id) => {
|
const remove = async (id) => {
|
||||||
@@ -109,7 +111,7 @@ export default function SegmentsPanel({ segments, activityId }) {
|
|||||||
>
|
>
|
||||||
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
|
||||||
{seg.name}
|
{seg.name}
|
||||||
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m)}</span>
|
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m, unit)}</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../hooks/useAuth'
|
import { useAuthStore } from '../../hooks/useAuth'
|
||||||
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
||||||
|
import UnitToggle from './UnitToggle'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
||||||
@@ -106,6 +107,14 @@ export default function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Distance-unit toggle (km / mi) */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-800 px-4 py-3">
|
||||||
|
<span className="text-xs text-gray-500">Units</span>
|
||||||
|
<UnitToggle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Logged-in user + privilege level */}
|
{/* Logged-in user + privilege level */}
|
||||||
<div className="border-t border-gray-800 p-3">
|
<div className="border-t border-gray-800 p-3">
|
||||||
{user ? (
|
{user ? (
|
||||||
@@ -146,6 +155,7 @@ export default function Layout() {
|
|||||||
<span className="text-blue-400">Mile</span>Vault
|
<span className="text-blue-400">Mile</span>Vault
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<UnitToggle />
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse"
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse"
|
||||||
title={`Garmin sync: ${status || 'starting…'}`} />
|
title={`Garmin sync: ${status || 'starting…'}`} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={`inline-flex items-center rounded-full bg-gray-800 p-0.5 text-xs ${className}`}>
|
||||||
|
{['km', 'mi'].map((u) => (
|
||||||
|
<button
|
||||||
|
key={u}
|
||||||
|
onClick={() => setUnit(u)}
|
||||||
|
className={`px-2.5 py-1 rounded-full transition-colors ${
|
||||||
|
unit === u ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -4,9 +4,10 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
||||||
formatDate, sportIcon, sportColor,
|
formatDate, sportIcon, sportColor, convertKm, distanceUnitLabel,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
import { useUnit } from '../hooks/useUnits'
|
||||||
|
|
||||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ export default function ActivitiesPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [sport, setSport] = useState('all')
|
const [sport, setSport] = useState('all')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const unit = useUnit()
|
||||||
|
const distLabel = distanceUnitLabel(unit)
|
||||||
|
|
||||||
const fromParam = searchParams.get('from')
|
const fromParam = searchParams.get('from')
|
||||||
const toParam = searchParams.get('to')
|
const toParam = searchParams.get('to')
|
||||||
@@ -56,10 +59,10 @@ export default function ActivitiesPage() {
|
|||||||
{ytdStats && (
|
{ytdStats && (
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
|
||||||
{ytdStats.running_km > 0 && (
|
{ytdStats.running_km > 0 && (
|
||||||
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
<span className="text-blue-400">🏃 {convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year</span>
|
||||||
)}
|
)}
|
||||||
{ytdStats.cycling_km > 0 && (
|
{ytdStats.cycling_km > 0 && (
|
||||||
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
|
<span className="text-orange-400">🚴 {convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -118,14 +121,14 @@ export default function ActivitiesPage() {
|
|||||||
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
||||||
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
||||||
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
||||||
{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)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* Metrics */}
|
||||||
<div className="hidden sm:flex items-center gap-6 text-sm">
|
<div className="hidden sm:flex items-center gap-6 text-sm">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m)}</p>
|
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m, unit)}</p>
|
||||||
<p className="text-xs text-gray-600">distance</p>
|
<p className="text-xs text-gray-600">distance</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -133,7 +136,7 @@ export default function ActivitiesPage() {
|
|||||||
<p className="text-xs text-gray-600">time</p>
|
<p className="text-xs text-gray-600">time</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type)}</p>
|
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type, unit)}</p>
|
||||||
<p className="text-xs text-gray-600">pace</p>
|
<p className="text-xs text-gray-600">pace</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -142,7 +145,7 @@ export default function ActivitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-gray-200 font-medium">
|
<p className="text-gray-200 font-medium">
|
||||||
{activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'}
|
{activity.elevation_gain_m ? `↑ ${formatElevation(activity.elevation_gain_m, unit)}` : '--'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-600">elev</p>
|
<p className="text-xs text-gray-600">elev</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
formatDuration, formatDistance, formatPace, formatElevation,
|
formatDuration, formatDistance, formatPace, formatElevation,
|
||||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
import { useUnit } from '../hooks/useUnits'
|
||||||
|
|
||||||
import { projectToTrack } from '../utils/track'
|
import { projectToTrack } from '../utils/track'
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const METRICS = [
|
|||||||
|
|
||||||
export default function ActivityDetailPage() {
|
export default function ActivityDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
const unit = useUnit()
|
||||||
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||||
const [hoveredDistance, setHoveredDistance] = useState(null)
|
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||||
const [mapHeight, setMapHeight] = useState(420)
|
const [mapHeight, setMapHeight] = useState(420)
|
||||||
@@ -155,18 +157,18 @@ export default function ActivityDetailPage() {
|
|||||||
|
|
||||||
{/* Stats — all on one row */}
|
{/* Stats — all on one row */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-10 gap-3">
|
||||||
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
<StatCard label="Distance" value={formatDistance(activity.distance_m, unit)} />
|
||||||
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
|
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
|
||||||
sub={activity.moving_time_s ? 'moving' : undefined} />
|
sub={activity.moving_time_s ? 'moving' : undefined} />
|
||||||
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
|
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
|
||||||
<StatCard label="Elapsed" value={formatDuration(activity.duration_s)} />
|
<StatCard label="Elapsed" value={formatDuration(activity.duration_s)} />
|
||||||
)}
|
)}
|
||||||
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type, unit)} />
|
||||||
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m, unit)} />
|
||||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||||
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||||
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m, unit)} />
|
||||||
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||||
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -245,8 +247,8 @@ export default function ActivityDetailPage() {
|
|||||||
Click two points on the route to mark the segment start and end.
|
Click two points on the route to mark the segment start and end.
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400">
|
||||||
Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'}
|
Start: {segPoints[0] ? formatDistance(segPoints[0].distance_m, unit) : '—'}
|
||||||
{' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'}
|
{' · '}End: {segPoints[1] ? formatDistance(segPoints[1].distance_m, unit) : '—'}
|
||||||
</span>
|
</span>
|
||||||
{segPoints.length === 2 && (
|
{segPoints.length === 2 && (
|
||||||
<>
|
<>
|
||||||
@@ -318,9 +320,10 @@ export default function ActivityDetailPage() {
|
|||||||
<MetricTimeline
|
<MetricTimeline
|
||||||
dataPoints={dataPoints}
|
dataPoints={dataPoints}
|
||||||
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
||||||
metrics={METRICS}
|
metrics={METRICS.map(m => m.key === 'altitude_m' ? { ...m, unit: unit === 'mi' ? 'ft' : 'm' } : m)}
|
||||||
onHoverDistance={setHoveredDistance}
|
onHoverDistance={setHoveredDistance}
|
||||||
sportType={activity.sport_type}
|
sportType={activity.sport_type}
|
||||||
|
unit={unit}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||||
@@ -335,7 +338,7 @@ export default function ActivityDetailPage() {
|
|||||||
{laps && laps.length > 0 && (
|
{laps && laps.length > 0 && (
|
||||||
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||||
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} records={activityRecords} />
|
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} records={activityRecords} unit={unit} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{routeBoard && routeBoard.top?.length > 0 && (
|
{routeBoard && routeBoard.top?.length > 0 && (
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import SleepHypnogram from '../components/health/SleepHypnogram'
|
|||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||||
formatDate, sportIcon, sportColor, formatSleep,
|
formatDate, sportIcon, sportColor, formatSleep, convertKm, distanceUnitLabel,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
import { useUnit } from '../hooks/useUnits'
|
||||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||||
|
|
||||||
const Grid = WidthProvider(GridLayout)
|
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_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_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 ? <HrvBadge status={h.hrv_status} /> : 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 ? <HrvBadge status={h.hrv_status} /> : undefined },
|
||||||
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_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) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
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_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_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 : '--' },
|
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 }) {
|
function WeeklyChart({ activities }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const unit = useUnit()
|
||||||
|
const distLabel = distanceUnitLabel(unit)
|
||||||
const { data, sports } = useMemo(() => {
|
const { data, sports } = useMemo(() => {
|
||||||
if (!activities?.length) return { data: [], sports: [] }
|
if (!activities?.length) return { data: [], sports: [] }
|
||||||
// Sports present, ordered by total distance (largest stacks at the bottom).
|
// 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)
|
const t = new Date(a.start_time)
|
||||||
if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000
|
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 row
|
||||||
})
|
})
|
||||||
return { data, sports }
|
return { data, sports }
|
||||||
}, [activities])
|
}, [activities, unit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Weekly distance (km)">
|
<Card title={`Weekly distance (${distLabel})`}>
|
||||||
{data.length ? (
|
{data.length ? (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@@ -323,7 +326,7 @@ function WeeklyChart({ activities }) {
|
|||||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
<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)}`} />
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} tickFormatter={v => `${v.toFixed(0)}`} />
|
||||||
<Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
|
<Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
|
||||||
formatter={(v, name) => [`${(+v).toFixed(1)} km`, sportLabel(name)]} />
|
formatter={(v, name) => [`${(+v).toFixed(1)} ${distLabel}`, sportLabel(name)]} />
|
||||||
{sports.map((s, i) => (
|
{sports.map((s, i) => (
|
||||||
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
|
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
|
||||||
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
|
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
|
||||||
@@ -348,6 +351,7 @@ function WeeklyChart({ activities }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FeaturedActivity({ activity, segments }) {
|
function FeaturedActivity({ activity, segments }) {
|
||||||
|
const unit = useUnit()
|
||||||
if (!activity) return (
|
if (!activity) return (
|
||||||
<Card title="Latest activity"><div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div></Card>
|
<Card title="Latest activity"><div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div></Card>
|
||||||
)
|
)
|
||||||
@@ -370,8 +374,8 @@ function FeaturedActivity({ activity, segments }) {
|
|||||||
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
|
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
|
||||||
<Stat label="Distance" value={formatDistance(activity.distance_m)} />
|
<Stat label="Distance" value={formatDistance(activity.distance_m, unit)} />
|
||||||
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m, unit)} />
|
||||||
<Stat label="Moving time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} />
|
<Stat label="Moving time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} />
|
||||||
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +410,7 @@ function FeaturedActivity({ activity, segments }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RecentActivities({ activities }) {
|
function RecentActivities({ activities }) {
|
||||||
|
const unit = useUnit()
|
||||||
return (
|
return (
|
||||||
<Card title="Recent activities" viewHref="/activities">
|
<Card title="Recent activities" viewHref="/activities">
|
||||||
<div className="space-y-2 overflow-auto h-full">
|
<div className="space-y-2 overflow-auto h-full">
|
||||||
@@ -421,7 +426,7 @@ function RecentActivities({ activities }) {
|
|||||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-sm">
|
<div className="text-right text-sm">
|
||||||
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
|
<p className="text-gray-200">{formatDistance(activity.distance_m, unit)}</p>
|
||||||
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -523,6 +528,7 @@ export default function DashboardPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Layout state ──────────────────────────────────────────────────────────
|
// ── Layout state ──────────────────────────────────────────────────────────
|
||||||
|
const unit = useUnit()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
@@ -576,7 +582,7 @@ export default function DashboardPage() {
|
|||||||
const renderWidget = (id) => {
|
const renderWidget = (id) => {
|
||||||
if (STAT_DEFS[id]) {
|
if (STAT_DEFS[id]) {
|
||||||
const d = STAT_DEFS[id]
|
const d = STAT_DEFS[id]
|
||||||
return <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats)}
|
return <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats, unit)}
|
||||||
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
|
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
|
||||||
}
|
}
|
||||||
switch (id) {
|
switch (id) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
|
||||||
|
import { useUnit } from '../hooks/useUnits'
|
||||||
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
import RouteMiniMap from '../components/ui/RouteMiniMap'
|
||||||
|
|
||||||
const SPORTS = ['running', 'cycling']
|
const SPORTS = ['running', 'cycling']
|
||||||
@@ -155,6 +156,7 @@ function DistancePRs() {
|
|||||||
|
|
||||||
function RouteRecords() {
|
function RouteRecords() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const unit = useUnit()
|
||||||
const { data: records, isLoading } = useQuery({
|
const { data: records, isLoading } = useQuery({
|
||||||
queryKey: ['route-records'],
|
queryKey: ['route-records'],
|
||||||
queryFn: () => api.get('/records/routes').then(r => r.data),
|
queryFn: () => api.get('/records/routes').then(r => r.data),
|
||||||
@@ -198,13 +200,13 @@ function RouteRecords() {
|
|||||||
{rec.route_name}
|
{rec.route_name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 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)}
|
{formatDistance(rec.distance_m, unit)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 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)}
|
{formatDuration(rec.duration_s)}
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
{formatPace(rec.avg_speed_ms, rec.sport_type)}
|
{formatPace(rec.avg_speed_ms, rec.sport_type, unit)}
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
<td className="hidden sm:table-cell px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
{formatDate(rec.start_time)}
|
{formatDate(rec.start_time)}
|
||||||
@@ -243,6 +245,7 @@ function SegmentLeaderboard({ segmentId }) {
|
|||||||
|
|
||||||
function SegmentRecords() {
|
function SegmentRecords() {
|
||||||
const [open, setOpen] = useState(null)
|
const [open, setOpen] = useState(null)
|
||||||
|
const unit = useUnit()
|
||||||
const { data: segments, isLoading } = useQuery({
|
const { data: segments, isLoading } = useQuery({
|
||||||
queryKey: ['segments'],
|
queryKey: ['segments'],
|
||||||
queryFn: () => api.get('/segments/').then(r => r.data),
|
queryFn: () => api.get('/segments/').then(r => r.data),
|
||||||
@@ -287,7 +290,7 @@ function SegmentRecords() {
|
|||||||
{seg.name}
|
{seg.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
<td className="px-3 py-3 text-right text-gray-400 text-xs">
|
||||||
{formatDistance(seg.distance_m)}
|
{formatDistance(seg.distance_m, unit)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 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">
|
||||||
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||||
|
import { useUnit } from '../hooks/useUnits'
|
||||||
|
|
||||||
// Decode Google encoded polyline to [[lat,lng], ...]
|
// Decode Google encoded polyline to [[lat,lng], ...]
|
||||||
function decodePolyline(encoded) {
|
function decodePolyline(encoded) {
|
||||||
@@ -61,6 +62,7 @@ const MEDALS = ['🥇', '🥈', '🥉']
|
|||||||
|
|
||||||
function RouteDetail({ selected, setSelected }) {
|
function RouteDetail({ selected, setSelected }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const unit = useUnit()
|
||||||
const [merging, setMerging] = useState(false)
|
const [merging, setMerging] = useState(false)
|
||||||
const [mergeTarget, setMergeTarget] = useState('')
|
const [mergeTarget, setMergeTarget] = useState('')
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
@@ -141,7 +143,7 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||||
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
|
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
|
||||||
<span>{formatDistance(selected.distance_m)}</span>
|
<span>{formatDistance(selected.distance_m, unit)}</span>
|
||||||
{selected.auto_detected && (
|
{selected.auto_detected && (
|
||||||
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
|
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
|
||||||
)}
|
)}
|
||||||
@@ -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">
|
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">
|
||||||
<option value="">Select route to merge in…</option>
|
<option value="">Select route to merge in…</option>
|
||||||
{otherRoutes.map(r => (
|
{otherRoutes.map(r => (
|
||||||
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
|
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m, unit)})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
@@ -218,7 +220,7 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
|
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||||
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
|
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type, unit)}</span>
|
||||||
{act.avg_heart_rate
|
{act.avg_heart_rate
|
||||||
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
|
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
|
||||||
: <span className="w-16" />}
|
: <span className="w-16" />}
|
||||||
@@ -232,6 +234,7 @@ function RouteDetail({ selected, setSelected }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RoutesPage() {
|
export default function RoutesPage() {
|
||||||
|
const unit = useUnit()
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||||
@@ -297,7 +300,7 @@ export default function RoutesPage() {
|
|||||||
<option value="">Select an activity…</option>
|
<option value="">Select an activity…</option>
|
||||||
{recentActivities?.map(a => (
|
{recentActivities?.map(a => (
|
||||||
<option key={a.id} value={a.id}>
|
<option key={a.id} value={a.id}>
|
||||||
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
|
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m, unit)} on {formatDate(a.start_time)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -338,7 +341,7 @@ export default function RoutesPage() {
|
|||||||
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
||||||
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
||||||
<div className="flex items-center justify-between mt-0.5 gap-1">
|
<div className="flex items-center justify-between mt-0.5 gap-1">
|
||||||
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
|
<span className="text-xs text-gray-500">{formatDistance(route.distance_m, unit)}</span>
|
||||||
{route.activity_count > 0 && (
|
{route.activity_count > 0 && (
|
||||||
<span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
|
<span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
// Unit conversion constants. Distances/elevations are stored in metres (and some
|
||||||
|
// API stats in kilometres); imperial display is derived on the fly — nothing is
|
||||||
|
// stored in miles. The active unit ('km' | 'mi') comes from the useUnits store.
|
||||||
|
const M_PER_MI = 1609.344
|
||||||
|
const KM_PER_MI = 1.609344
|
||||||
|
const FT_PER_M = 3.280839895
|
||||||
|
const MS_TO_MPH = 2.2369363
|
||||||
|
|
||||||
export function formatDuration(seconds) {
|
export function formatDuration(seconds) {
|
||||||
if (!seconds) return '--'
|
if (!seconds) return '--'
|
||||||
const h = Math.floor(seconds / 3600)
|
const h = Math.floor(seconds / 3600)
|
||||||
@@ -7,28 +15,48 @@ export function formatDuration(seconds) {
|
|||||||
return `${m}:${String(s).padStart(2, '0')}`
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPace(speedMs, sportType = 'running') {
|
export function formatPace(speedMs, sportType = 'running', unit = 'km') {
|
||||||
if (!speedMs || speedMs <= 0) return '--'
|
if (!speedMs || speedMs <= 0) return '--'
|
||||||
if (sportType === 'cycling') {
|
if (sportType === 'cycling') {
|
||||||
|
if (unit === 'mi') return `${(speedMs * MS_TO_MPH).toFixed(1)} mph`
|
||||||
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
||||||
}
|
}
|
||||||
const secsPerKm = 1000 / speedMs
|
const distPerUnit = unit === 'mi' ? M_PER_MI : 1000
|
||||||
const mins = Math.floor(secsPerKm / 60)
|
const secsPerUnit = distPerUnit / speedMs
|
||||||
const secs = Math.floor(secsPerKm % 60)
|
const mins = Math.floor(secsPerUnit / 60)
|
||||||
return `${mins}:${String(secs).padStart(2, '0')} /km`
|
const secs = Math.floor(secsPerUnit % 60)
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')} ${unit === 'mi' ? '/mi' : '/km'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDistance(metres) {
|
export function formatDistance(metres, unit = 'km') {
|
||||||
if (!metres) return '--'
|
if (!metres) return '--'
|
||||||
|
if (unit === 'mi') {
|
||||||
|
const mi = metres / M_PER_MI
|
||||||
|
if (mi < 0.1) return `${Math.round(metres * FT_PER_M)} ft`
|
||||||
|
return `${mi.toFixed(2)} mi`
|
||||||
|
}
|
||||||
if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km`
|
if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km`
|
||||||
return `${Math.round(metres)} m`
|
return `${Math.round(metres)} m`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatElevation(metres) {
|
export function formatElevation(metres, unit = 'km') {
|
||||||
if (metres == null) return '--'
|
if (metres == null) return '--'
|
||||||
|
if (unit === 'mi') return `${Math.round(metres * FT_PER_M)} ft`
|
||||||
return `${Math.round(metres)} m`
|
return `${Math.round(metres)} m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a value already expressed in kilometres (e.g. API YTD/weekly stats) to
|
||||||
|
// the active unit. Returns a number so callers can format/chart it themselves.
|
||||||
|
export function convertKm(km, unit = 'km') {
|
||||||
|
if (km == null) return null
|
||||||
|
return unit === 'mi' ? km / KM_PER_MI : km
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short label for the active distance unit ('km' | 'mi'), for axis/column titles.
|
||||||
|
export function distanceUnitLabel(unit = 'km') {
|
||||||
|
return unit === 'mi' ? 'mi' : 'km'
|
||||||
|
}
|
||||||
|
|
||||||
export function formatHeartRate(bpm) {
|
export function formatHeartRate(bpm) {
|
||||||
if (!bpm) return '--'
|
if (!bpm) return '--'
|
||||||
return `${Math.round(bpm)} bpm`
|
return `${Math.round(bpm)} bpm`
|
||||||
|
|||||||
Reference in New Issue
Block a user