All tweaks added
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
ARG VITE_API_URL=/api
|
||||
ARG VITE_MAPBOX_TOKEN=
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MileVault</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "milevault-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.12.7",
|
||||
"date-fns": "^3.6.0",
|
||||
"clsx": "^2.1.1",
|
||||
"zustand": "^4.5.2",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"axios": "^1.7.2",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"@polyline-codec/core": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.2.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthStore } from './hooks/useAuth'
|
||||
import Layout from './components/ui/Layout'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import ActivitiesPage from './pages/ActivitiesPage'
|
||||
import ActivityDetailPage from './pages/ActivityDetailPage'
|
||||
import HealthPage from './pages/HealthPage'
|
||||
import RoutesPage from './pages/RoutesPage'
|
||||
import RecordsPage from './pages/RecordsPage'
|
||||
import UploadPage from './pages/UploadPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { token, fetchUser } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fetchUser()
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlToken = params.get('token')
|
||||
if (urlToken) {
|
||||
localStorage.setItem('token', urlToken)
|
||||
useAuthStore.setState({ token: urlToken })
|
||||
window.history.replaceState({}, '', '/')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="activities" element={<ActivitiesPage />} />
|
||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||
<Route path="health" element={<HealthPage />} />
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
})
|
||||
|
||||
const TILE_LAYERS = {
|
||||
dark: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
street: {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
},
|
||||
satellite: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
||||
const mapRef = useRef(null)
|
||||
const mapInstanceRef = useRef(null)
|
||||
const markerRef = useRef(null)
|
||||
const trackRef = useRef(null)
|
||||
const tileLayerRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return
|
||||
mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
|
||||
const tile = TILE_LAYERS['dark']
|
||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
||||
.addTo(mapInstanceRef.current)
|
||||
return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
|
||||
}, [])
|
||||
|
||||
// Switch tile layer when mapType changes
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
|
||||
if (tileLayerRef.current) {
|
||||
tileLayerRef.current.remove()
|
||||
}
|
||||
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
|
||||
.addTo(mapInstanceRef.current)
|
||||
}, [mapType])
|
||||
|
||||
// Draw route
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !polyline) return
|
||||
if (trackRef.current) trackRef.current.remove()
|
||||
const coords = decodePolyline(polyline)
|
||||
if (!coords.length) return
|
||||
trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
|
||||
.addTo(mapInstanceRef.current)
|
||||
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
||||
if (coords.length > 0) {
|
||||
const dot = (color) => L.divIcon({
|
||||
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
||||
})
|
||||
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
|
||||
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
|
||||
}
|
||||
}, [polyline, sportType])
|
||||
|
||||
// Position marker on timeline hover
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
|
||||
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
||||
if (!point?.latitude || !point?.longitude) return
|
||||
if (markerRef.current) {
|
||||
markerRef.current.setLatLng([point.latitude, point.longitude])
|
||||
} else {
|
||||
const icon = L.divIcon({
|
||||
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||
})
|
||||
markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
|
||||
}
|
||||
}, [hoveredDistance, dataPoints])
|
||||
|
||||
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const ZONE_CONFIG = [
|
||||
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
|
||||
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
|
||||
{ key: 'z3', label: 'Z3 Tempo', color: '#fbbf24' },
|
||||
{ key: 'z4', label: 'Z4 Threshold', color: '#f97316' },
|
||||
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
|
||||
]
|
||||
|
||||
export default function HRZoneBar({ zones }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stacked bar */}
|
||||
<div className="flex h-4 rounded-full overflow-hidden gap-0.5">
|
||||
{ZONE_CONFIG.map(({ key, color }) => {
|
||||
const pct = zones[key] || 0
|
||||
if (pct < 0.5) return null
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||
className="h-full"
|
||||
title={`${key.toUpperCase()}: ${pct}%`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{ZONE_CONFIG.map(({ key, label, color }) => {
|
||||
const pct = zones[key] || 0
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
<span className="text-xs font-medium text-white">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
|
||||
|
||||
export default function LapTable({ laps, sportType }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 border-b border-gray-800">
|
||||
<th className="text-left pb-2 font-medium">Lap</th>
|
||||
<th className="text-right pb-2 font-medium">Distance</th>
|
||||
<th className="text-right pb-2 font-medium">Time</th>
|
||||
<th className="text-right pb-2 font-medium">Pace</th>
|
||||
<th className="text-right pb-2 font-medium">Avg HR</th>
|
||||
<th className="text-right pb-2 font-medium">Cadence</th>
|
||||
<th className="text-right pb-2 font-medium">Power</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{laps.map((lap) => (
|
||||
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
|
||||
<td className="py-2 text-gray-400">{lap.lap_number}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
|
||||
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-400">
|
||||
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { formatPace, formatCadence } from '../../utils/format'
|
||||
|
||||
function downsample(points, maxPoints = 500) {
|
||||
if (points.length <= maxPoints) return points
|
||||
const step = Math.ceil(points.length / maxPoints)
|
||||
return points.filter((_, i) => i % step === 0)
|
||||
}
|
||||
|
||||
function buildChartData(dataPoints, activeMetrics) {
|
||||
return dataPoints
|
||||
.filter(p => p.timestamp)
|
||||
.map(p => {
|
||||
const row = { distance_m: p.distance_m ?? 0 }
|
||||
for (const key of activeMetrics) {
|
||||
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
|
||||
if (!active || !payload?.length) return null
|
||||
if (onHover) onHover(label)
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
|
||||
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
|
||||
{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)
|
||||
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`
|
||||
return (
|
||||
<div key={entry.dataKey} className="flex items-center gap-2">
|
||||
<span style={{ color: entry.color }}>●</span>
|
||||
<span className="text-gray-300">{metric.label}:</span>
|
||||
<span className="text-white font-medium">{display}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
|
||||
const chartData = useMemo(() =>
|
||||
downsample(buildChartData(dataPoints, activeMetrics)),
|
||||
[dataPoints, activeMetrics]
|
||||
)
|
||||
|
||||
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
|
||||
|
||||
const domains = useMemo(() => {
|
||||
const result = {}
|
||||
for (const m of activeMetricConfigs) {
|
||||
const vals = chartData.map(p => p[m.key]).filter(v => v != null)
|
||||
if (!vals.length) continue
|
||||
const min = Math.min(...vals)
|
||||
const max = Math.max(...vals)
|
||||
const pad = (max - min) * 0.1 || 1
|
||||
// For elevation, don't start from 0 - show actual range
|
||||
result[m.key] = [min - pad, max + pad]
|
||||
}
|
||||
return result
|
||||
}, [chartData, activeMetricConfigs])
|
||||
|
||||
if (!chartData.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
|
||||
No timeline data available
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{activeMetricConfigs.map((metric, idx) => {
|
||||
const domain = domains[metric.key] || ['auto', 'auto']
|
||||
const hasData = chartData.some(p => p[metric.key] != null)
|
||||
if (!hasData) return null
|
||||
|
||||
return (
|
||||
<div key={metric.key}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
|
||||
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="distance_m"
|
||||
type="number"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
hide={idx < activeMetricConfigs.length - 1}
|
||||
/>
|
||||
<YAxis
|
||||
domain={domain}
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
tickFormatter={v => {
|
||||
if (metric.key === 'speed_ms') {
|
||||
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
||||
const spm = 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))
|
||||
return Math.round(v)
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={metric.key}
|
||||
stroke={metric.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../hooks/useAuth'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true },
|
||||
{ to: '/activities', label: 'Activities', icon: '🏃' },
|
||||
{ to: '/health', label: 'Health', icon: '❤️' },
|
||||
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
||||
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-gray-950">
|
||||
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
<div className="px-4 py-5 border-b border-gray-800">
|
||||
<h1 className="text-lg font-bold text-white tracking-tight">
|
||||
<span className="text-blue-400">Mile</span>Vault
|
||||
</h1>
|
||||
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
{nav.map(({ to, label, icon, exact }) => (
|
||||
<NavLink key={to} to={to} end={exact}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
|
||||
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
|
||||
}`
|
||||
}>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="px-4 py-4 border-t border-gray-800">
|
||||
<button onClick={handleLogout}
|
||||
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const accentColors = {
|
||||
default: 'text-white',
|
||||
red: 'text-red-400',
|
||||
blue: 'text-blue-400',
|
||||
green: 'text-green-400',
|
||||
orange: 'text-orange-400',
|
||||
purple: 'text-purple-400',
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, accent = 'default', sub }) {
|
||||
return (
|
||||
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50">
|
||||
<p className="text-xs text-gray-500 mb-1">{label}</p>
|
||||
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
|
||||
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand'
|
||||
import api from '../utils/api'
|
||||
|
||||
export const useAuthStore = create((set) => ({
|
||||
token: localStorage.getItem('token'),
|
||||
user: null,
|
||||
isLoading: false,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('username', username)
|
||||
params.append('password', password)
|
||||
const { data } = await api.post('/auth/token', params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
localStorage.setItem('token', data.access_token)
|
||||
set({ token: data.access_token, user: data, isLoading: false })
|
||||
return true
|
||||
} catch (e) {
|
||||
set({ isLoading: false })
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('token')
|
||||
set({ token: null, user: null })
|
||||
},
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/auth/me')
|
||||
set({ user: data })
|
||||
} catch {
|
||||
set({ token: null, user: null })
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-950 text-gray-100 antialiased;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet dark mode fixes */
|
||||
.leaflet-container {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
|
||||
|
||||
/* HR zone colours */
|
||||
.zone-1 { color: #60a5fa; }
|
||||
.zone-2 { color: #34d399; }
|
||||
.zone-3 { color: #fbbf24; }
|
||||
.zone-4 { color: #f97316; }
|
||||
.zone-5 { color: #f43f5e; }
|
||||
|
||||
.zone-bg-1 { background-color: #1e3a5f; }
|
||||
.zone-bg-2 { background-color: #065f46; }
|
||||
.zone-bg-3 { background-color: #78350f; }
|
||||
.zone-bg-4 { background-color: #7c2d12; }
|
||||
.zone-bg-5 { background-color: #881337; }
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 60_000, retry: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||
formatDate, sportIcon, sportColor,
|
||||
} from '../utils/format'
|
||||
|
||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||
|
||||
export default function ActivitiesPage() {
|
||||
const [sport, setSport] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data: activities, isLoading } = useQuery({
|
||||
queryKey: ['activities', sport, page],
|
||||
queryFn: () =>
|
||||
api.get('/activities/', {
|
||||
params: {
|
||||
sport_type: sport === 'all' ? undefined : sport,
|
||||
page,
|
||||
per_page: 20,
|
||||
},
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||
<Link
|
||||
to="/upload"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
+ Import
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Sport filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{SPORTS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setSport(s); setPage(1) }}
|
||||
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors ${
|
||||
sport === s
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? 'All' : `${sportIcon(s)} ${s}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activity list */}
|
||||
{isLoading ? (
|
||||
<div className="text-gray-500 text-sm">Loading…</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activities?.map(activity => (
|
||||
<Link
|
||||
key={activity.id}
|
||||
to={`/activities/${activity.id}`}
|
||||
className="flex items-center gap-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl p-4 transition-all group"
|
||||
>
|
||||
{/* Sport indicator */}
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg"
|
||||
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
|
||||
>
|
||||
{sportIcon(activity.sport_type)}
|
||||
</div>
|
||||
|
||||
{/* Name + date */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
|
||||
{activity.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm">
|
||||
<div className="text-right">
|
||||
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m)}</p>
|
||||
<p className="text-xs text-gray-600">distance</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-200 font-medium">{formatDuration(activity.duration_s)}</p>
|
||||
<p className="text-xs text-gray-600">time</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type)}</p>
|
||||
<p className="text-xs text-gray-600">pace</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-red-400 font-medium">{formatHeartRate(activity.avg_heart_rate)}</p>
|
||||
<p className="text-xs text-gray-600">avg HR</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-200 font-medium">
|
||||
{activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">elev</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-gray-700 group-hover:text-gray-400 transition-colors ml-2">›</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{activities?.length === 0 && (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-4xl mb-3">🏃</p>
|
||||
<p className="text-lg">No activities yet</p>
|
||||
<p className="text-sm mt-1">
|
||||
<Link to="/upload" className="text-blue-400 hover:underline">Import your Garmin or Strava data</Link> to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{activities?.length === 20 && (
|
||||
<div className="flex justify-center gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg disabled:opacity-30 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<span className="px-4 py-2 text-sm text-gray-500">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState, useMemo } from 'react'
|
||||
import api from '../utils/api'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import MetricTimeline from '../components/activity/MetricTimeline'
|
||||
import HRZoneBar from '../components/activity/HRZoneBar'
|
||||
import LapTable from '../components/activity/LapTable'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatElevation,
|
||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||
} from '../utils/format'
|
||||
|
||||
const METRICS = [
|
||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
||||
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
||||
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
|
||||
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
|
||||
]
|
||||
|
||||
export default function ActivityDetailPage() {
|
||||
const { id } = useParams()
|
||||
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
|
||||
const [hoveredDistance, setHoveredDistance] = useState(null)
|
||||
const [mapHeight, setMapHeight] = useState(420)
|
||||
const [mapType, setMapType] = useState('dark')
|
||||
|
||||
const { data: activity, isLoading } = useQuery({
|
||||
queryKey: ['activity', id],
|
||||
queryFn: () => api.get(`/activities/${id}`).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: dataPoints } = useQuery({
|
||||
queryKey: ['activity-points', id],
|
||||
queryFn: () => api.get(`/activities/${id}/data-points?downsample=3`).then(r => r.data),
|
||||
enabled: !!activity,
|
||||
})
|
||||
|
||||
const { data: laps } = useQuery({
|
||||
queryKey: ['activity-laps', id],
|
||||
queryFn: () => api.get(`/activities/${id}/laps`).then(r => r.data),
|
||||
enabled: !!activity,
|
||||
})
|
||||
|
||||
const toggleMetric = (key) => {
|
||||
setActiveMetrics(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
)
|
||||
}
|
||||
|
||||
// Check which metrics have actual data
|
||||
const availableMetrics = useMemo(() => {
|
||||
if (!dataPoints?.length) return new Set()
|
||||
return new Set(
|
||||
METRICS
|
||||
.filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
|
||||
.map(m => m.key)
|
||||
)
|
||||
}, [dataPoints])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity…</div></div>
|
||||
}
|
||||
if (!activity) return null
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
|
||||
<h1 className="text-2xl font-bold text-white">{activity.name}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{formatDateTime(activity.start_time)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary stats */}
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
|
||||
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
|
||||
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
|
||||
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
|
||||
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
|
||||
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
|
||||
</div>
|
||||
|
||||
{/* Secondary stats */}
|
||||
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
|
||||
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
|
||||
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
|
||||
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
|
||||
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
|
||||
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
|
||||
</div>
|
||||
|
||||
{/* Map with controls */}
|
||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
||||
{/* Map toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Map style:</span>
|
||||
{['dark', 'street', 'satellite'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setMapType(t)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
|
||||
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Height:</span>
|
||||
{[280, 420, 560].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setMapHeight(h)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
||||
mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ height: mapHeight }}>
|
||||
<ActivityMap
|
||||
polyline={activity.polyline}
|
||||
dataPoints={dataPoints}
|
||||
hoveredDistance={hoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
mapType={mapType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HR Zones */}
|
||||
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
||||
<HRZoneBar zones={activity.hr_zones} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metric timeline */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggleMetric(key)}
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
activeMetrics.includes(key)
|
||||
? 'border-transparent text-white'
|
||||
: 'border-gray-700 text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
style={activeMetrics.includes(key) ? { backgroundColor: color + '33', borderColor: color, color } : {}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{dataPoints && dataPoints.length > 0 ? (
|
||||
<MetricTimeline
|
||||
dataPoints={dataPoints}
|
||||
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
|
||||
metrics={METRICS}
|
||||
onHoverDistance={setHoveredDistance}
|
||||
sportType={activity.sport_type}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Laps */}
|
||||
{laps && laps.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
|
||||
<LapTable laps={laps} sportType={activity.sport_type} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||
formatDate, sportIcon, formatSleep,
|
||||
} from '../utils/format'
|
||||
|
||||
function WeeklyChart({ activities }) {
|
||||
if (!activities?.length) return (
|
||||
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
||||
)
|
||||
|
||||
// Build last 8 weeks in chronological order
|
||||
const now = new Date()
|
||||
const weeks = eachWeekOfInterval({
|
||||
start: subWeeks(startOfWeek(now), 7),
|
||||
end: startOfWeek(now),
|
||||
})
|
||||
|
||||
const data = weeks.map(weekStart => {
|
||||
const weekKey = format(weekStart, 'MMM d')
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||
const km = activities
|
||||
.filter(a => {
|
||||
const t = new Date(a.start_time)
|
||||
return t >= weekStart && t < weekEnd
|
||||
})
|
||||
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
||||
return { week: weekKey, km: parseFloat(km.toFixed(2)) }
|
||||
})
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={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)}`} />
|
||||
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
|
||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: recentActivities } = useQuery({
|
||||
queryKey: ['activities-recent'],
|
||||
queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: allActivities } = useQuery({
|
||||
queryKey: ['activities-all-chart'],
|
||||
queryFn: () =>
|
||||
api.get('/activities/', {
|
||||
params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() },
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: healthSummary } = useQuery({
|
||||
queryKey: ['health-summary'],
|
||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: records } = useQuery({
|
||||
queryKey: ['records-running'],
|
||||
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
||||
})
|
||||
|
||||
const latest = healthSummary?.latest
|
||||
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
|
||||
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
|
||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<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="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>
|
||||
|
||||
{/* Recent activities */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Recent activities</h3>
|
||||
<Link to="/activities" className="text-xs text-blue-400 hover:underline">View all →</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{recentActivities?.slice(0, 5).map(activity => (
|
||||
<Link key={activity.id} to={`/activities/${activity.id}`}
|
||||
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm text-right">
|
||||
<div><p className="text-gray-200">{formatDistance(activity.distance_m)}</p><p className="text-xs text-gray-600">dist</p></div>
|
||||
<div><p className="text-gray-200">{formatDuration(activity.duration_s)}</p><p className="text-xs text-gray-600">time</p></div>
|
||||
<div><p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p><p className="text-xs text-gray-600">HR</p></div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!recentActivities?.length && (
|
||||
<p className="text-gray-600 text-sm text-center py-8">
|
||||
No activities yet — <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{records?.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Running PRs</h3>
|
||||
<Link to="/records" className="text-xs text-blue-400 hover:underline">View all →</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{records.slice(0, 5).map(rec => (
|
||||
<div key={rec.id} className="bg-gray-800/60 rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">{rec.distance_label}</p>
|
||||
<p className="font-mono font-semibold text-yellow-400">{formatDuration(rec.duration_s)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
LineChart, Line, AreaChart, Area, BarChart, Bar,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { format, subDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '1W', days: 7 },
|
||||
{ label: '2W', days: 14 },
|
||||
{ label: '1M', days: 30 },
|
||||
{ label: '3M', days: 90 },
|
||||
{ label: '6M', days: 180 },
|
||||
{ label: '1Y', days: 365 },
|
||||
]
|
||||
|
||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
|
||||
const vals = data.filter(d => d[dataKey] != null)
|
||||
if (!vals.length) return (
|
||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||
)
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`grad-${dataKey}`} 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>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||
tickFormatter={formatter} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function SleepChart({ data }) {
|
||||
const chartData = data.map(d => ({
|
||||
date: d.date,
|
||||
deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null,
|
||||
rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null,
|
||||
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
|
||||
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
|
||||
}))
|
||||
const hasData = chartData.some(d => d.deep || d.rem || d.light)
|
||||
if (!hasData) return <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
|
||||
tickFormatter={v => `${v}h`} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
||||
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
|
||||
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
|
||||
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HealthPage() {
|
||||
const [rangeDays, setRangeDays] = useState(7) // default 1 week
|
||||
|
||||
const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['health-summary'],
|
||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: metrics, isLoading } = useQuery({
|
||||
queryKey: ['health-metrics', rangeDays],
|
||||
queryFn: () =>
|
||||
api.get('/health-metrics/', {
|
||||
params: { from_date: fromDate, limit: rangeDays + 1 },
|
||||
}).then(r => r.data.slice().reverse()), // oldest first for charts
|
||||
keepPreviousData: true,
|
||||
})
|
||||
|
||||
const latest = summary?.latest
|
||||
const avg30 = summary?.avg_30d
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Health</h1>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
|
||||
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
|
||||
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
|
||||
sub={latest?.hrv_status || undefined} />
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
|
||||
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
|
||||
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
|
||||
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
|
||||
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
|
||||
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
|
||||
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
|
||||
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
|
||||
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
|
||||
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
|
||||
</div>
|
||||
|
||||
{/* Range selector */}
|
||||
<div className="flex gap-2">
|
||||
{RANGES.map(({ label, days }) => (
|
||||
<button key={label} onClick={() => setRangeDays(days)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-gray-500 text-sm">Loading…</div>
|
||||
) : metrics && metrics.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
|
||||
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
|
||||
formatter={v => `${Math.round(v)} bpm`} />
|
||||
</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">HRV (nightly avg)</h3>
|
||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||
formatter={v => `${Math.round(v)} ms`} />
|
||||
</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">Sleep Stages</h3>
|
||||
<SleepChart data={metrics} />
|
||||
<div className="flex gap-4 mt-2">
|
||||
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
|
||||
<div key={l} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||
<span className="text-xs text-gray-400">{l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
|
||||
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
|
||||
formatter={v => `${v.toFixed(1)} kg`} />
|
||||
</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">VO2 Max</h3>
|
||||
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
|
||||
</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">Daily Steps</h3>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={metrics} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
||||
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||
formatter={v => `${Math.round(v)} bpm`} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
||||
formatter={v => Math.round(v)} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-4xl mb-3">📊</p>
|
||||
<p className="text-lg">No health data for this period</p>
|
||||
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../hooks/useAuth'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const { login, isLoading } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: pocketidData } = useQuery({
|
||||
queryKey: ['pocketid-available'],
|
||||
queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
|
||||
})
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Login failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePocketID = async () => {
|
||||
const { data } = await api.get('/auth/pocketid/login-url')
|
||||
window.location.href = data.url
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
<span className="text-blue-400">Mile</span>Vault
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2 text-sm">Your personal fitness dashboard</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-2xl p-6 border border-gray-800">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{pocketidData?.available && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 h-px bg-gray-800" />
|
||||
<span className="text-xs text-gray-600">or</span>
|
||||
<div className="flex-1 h-px bg-gray-800" />
|
||||
</div>
|
||||
<button
|
||||
onClick={handlePocketID}
|
||||
className="w-full bg-gray-800 hover:bg-gray-700 text-gray-300 font-medium py-2.5 rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
🔑 Sign in with passkey
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from '../hooks/useAuth'
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
|
||||
return (
|
||||
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
)
|
||||
}
|
||||
|
||||
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={onClick} disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
{loading ? 'Saving…' : label}
|
||||
</button>
|
||||
{saved && <span className="text-green-400 text-sm">✓ Saved</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const qc = useQueryClient()
|
||||
const { user } = useAuthStore()
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => api.get('/profile/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: pocketidConfig } = useQuery({
|
||||
queryKey: ['pocketid-config'],
|
||||
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
|
||||
enabled: !!user?.is_admin,
|
||||
})
|
||||
|
||||
// HR / measurements form
|
||||
const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
|
||||
const [hrSaved, setHrSaved] = useState(false)
|
||||
useEffect(() => {
|
||||
if (profile) setHrForm({
|
||||
max_heart_rate: profile.max_heart_rate || '',
|
||||
resting_heart_rate: profile.resting_heart_rate || '',
|
||||
birth_year: profile.birth_year || '',
|
||||
height_cm: profile.height_cm || '',
|
||||
})
|
||||
}, [profile])
|
||||
|
||||
const updateProfile = useMutation({
|
||||
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
|
||||
})
|
||||
|
||||
// Weight log
|
||||
const { data: weightLog } = useQuery({
|
||||
queryKey: ['weight-log'],
|
||||
queryFn: () => api.get('/profile/weight').then(r => r.data),
|
||||
})
|
||||
const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) })
|
||||
const [weightSaved, setWeightSaved] = useState(false)
|
||||
const addWeight = useMutation({
|
||||
mutationFn: data => api.post('/profile/weight', data).then(r => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) },
|
||||
})
|
||||
const deleteWeight = useMutation({
|
||||
mutationFn: id => api.delete(`/profile/weight/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }),
|
||||
})
|
||||
|
||||
// Password change
|
||||
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
|
||||
const [pwError, setPwError] = useState('')
|
||||
const [pwSaved, setPwSaved] = useState(false)
|
||||
const changePassword = useMutation({
|
||||
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
|
||||
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
|
||||
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
||||
})
|
||||
|
||||
// PocketID config
|
||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
||||
const [pidSaved, setPidSaved] = useState(false)
|
||||
useEffect(() => {
|
||||
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
|
||||
}, [pocketidConfig])
|
||||
const savePocketID = useMutation({
|
||||
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
|
||||
})
|
||||
|
||||
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
||||
|
||||
{/* HR & Measurements */}
|
||||
<Section title="Heart Rate & Measurements">
|
||||
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
|
||||
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
|
||||
{effectiveMaxHr && (
|
||||
<div className="mt-2 text-white">
|
||||
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
|
||||
{!profile?.max_heart_rate && ' (estimated from age)'}
|
||||
{' · '}Zones: Z1 <{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}–{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}–{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}–{Math.round(effectiveMaxHr * 0.9)}, Z5 >{Math.round(effectiveMaxHr * 0.9)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
||||
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
|
||||
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
|
||||
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
||||
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
||||
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Height (cm)">
|
||||
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
||||
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<SaveButton
|
||||
onClick={() => updateProfile.mutate(Object.fromEntries(
|
||||
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
|
||||
))}
|
||||
loading={updateProfile.isPending}
|
||||
saved={hrSaved}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Weight log */}
|
||||
<Section title="Weight Log">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="Weight (kg)">
|
||||
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
|
||||
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Body fat % (optional)">
|
||||
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
|
||||
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<Input type="datetime-local" value={weightForm.date}
|
||||
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => addWeight.mutate({
|
||||
weight_kg: parseFloat(weightForm.weight_kg),
|
||||
body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null,
|
||||
date: new Date(weightForm.date).toISOString(),
|
||||
})}
|
||||
loading={addWeight.isPending}
|
||||
saved={weightSaved}
|
||||
label="Log weight"
|
||||
/>
|
||||
|
||||
{weightLog && weightLog.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{weightLog.slice(0, 20).map(entry => (
|
||||
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
|
||||
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
|
||||
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
|
||||
<button onClick={() => deleteWeight.mutate(entry.id)}
|
||||
className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Password change */}
|
||||
<Section title="Change Password">
|
||||
<div className="space-y-3">
|
||||
<Field label="Current password">
|
||||
<Input type="password" value={pwForm.current_password}
|
||||
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
|
||||
</Field>
|
||||
<Field label="New password (min 8 characters)">
|
||||
<Input type="password" value={pwForm.new_password}
|
||||
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Confirm new password">
|
||||
<Input type="password" value={pwForm.confirm}
|
||||
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
|
||||
</Field>
|
||||
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
|
||||
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
|
||||
}}
|
||||
loading={changePassword.isPending}
|
||||
saved={pwSaved}
|
||||
label="Change password"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* PocketID — admin only */}
|
||||
{user?.is_admin && (
|
||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||
<p className="text-xs text-gray-500">
|
||||
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
|
||||
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
|
||||
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Client ID">
|
||||
<Input value={pidForm.client_id} placeholder="milevault"
|
||||
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Client secret" hint="Leave blank to keep existing secret">
|
||||
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||
</Field>
|
||||
{pocketidConfig?.enabled && (
|
||||
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||
)}
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => savePocketID.mutate(pidForm)}
|
||||
loading={savePocketID.isPending}
|
||||
saved={pidSaved}
|
||||
label="Save PocketID config"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } 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 } from '../utils/format'
|
||||
|
||||
const SPORTS = ['running', 'cycling', 'swimming']
|
||||
|
||||
const DISTANCE_ORDER = [
|
||||
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
|
||||
'Half marathon', 'Marathon', '50k', '100k',
|
||||
]
|
||||
|
||||
export default function RecordsPage() {
|
||||
const [sport, setSport] = useState('running')
|
||||
const [selectedDistance, setSelectedDistance] = useState(null)
|
||||
|
||||
const { data: records } = useQuery({
|
||||
queryKey: ['records', sport],
|
||||
queryFn: () => api.get('/records/', { params: { sport_type: sport } }).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ['record-history', selectedDistance, sport],
|
||||
queryFn: () =>
|
||||
api.get(`/records/history/${encodeURIComponent(selectedDistance)}`, {
|
||||
params: { sport_type: sport },
|
||||
}).then(r => r.data),
|
||||
enabled: !!selectedDistance,
|
||||
})
|
||||
|
||||
// Sort by standard distance order
|
||||
const sortedRecords = records?.slice().sort((a, b) => {
|
||||
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
|
||||
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
|
||||
|
||||
{/* Sport selector */}
|
||||
<div className="flex gap-2">
|
||||
{SPORTS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setSport(s); setSelectedDistance(null) }}
|
||||
className={`capitalize text-sm px-4 py-1.5 rounded-full border transition-colors ${
|
||||
sport === s
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sortedRecords?.length === 0 && (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-4xl mb-3">🏆</p>
|
||||
<p>No records yet — import activities to track your best times</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Records table */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||
<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">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">Date</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRecords?.map(rec => (
|
||||
<tr
|
||||
key={rec.id}
|
||||
onClick={() => setSelectedDistance(rec.distance_label)}
|
||||
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
|
||||
selectedDistance === rec.distance_label
|
||||
? 'bg-blue-900/20'
|
||||
: 'hover:bg-gray-800/40'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
|
||||
<td className="px-4 py-3 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">
|
||||
{formatDate(rec.achieved_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link
|
||||
to={`/activities/${rec.activity_id}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-xs text-blue-400 hover:underline"
|
||||
>
|
||||
View →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Progress chart */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
{selectedDistance && history ? (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-1">
|
||||
{selectedDistance} progression
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
|
||||
{history.length > 1 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart
|
||||
data={history.map(h => ({
|
||||
date: h.achieved_at,
|
||||
time: h.duration_s,
|
||||
}))}
|
||||
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={d => format(new Date(d), 'MMM yy')}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: '#6b7280' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
tickFormatter={formatDuration}
|
||||
reversed
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||
formatter={v => [formatDuration(v), 'Time']}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="time"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#fbbf24', r: 4 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
|
||||
Only one record — complete more activities to see progression
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
|
||||
Select a distance to see your progression
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' })
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: routes } = useQuery({
|
||||
queryKey: ['routes'],
|
||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: routeActivities } = useQuery({
|
||||
queryKey: ['route-activities', selected?.id],
|
||||
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||
enabled: !!selected,
|
||||
})
|
||||
|
||||
const { data: recentActivities } = useQuery({
|
||||
queryKey: ['recent-activities-for-route'],
|
||||
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
|
||||
enabled: showCreate,
|
||||
})
|
||||
|
||||
const createRoute = useMutation({
|
||||
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
|
||||
onSuccess: (route) => {
|
||||
qc.invalidateQueries({ queryKey: ['routes'] })
|
||||
setShowCreate(false)
|
||||
setNewRoute({ name: '', activity_id: '' })
|
||||
setSelected(route)
|
||||
},
|
||||
})
|
||||
|
||||
const fastest = routeActivities?.[0]
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Routes are auto-detected when you run the same path twice. You can also create them manually.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
+ New route
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create route */}
|
||||
{showCreate && (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white">Create named route</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
|
||||
<input value={newRoute.name}
|
||||
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Morning park loop" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
|
||||
{recentActivities?.length === 0 ? (
|
||||
<p className="text-xs text-gray-600 py-2">No recent activities found.</p>
|
||||
) : (
|
||||
<select
|
||||
value={newRoute.activity_id}
|
||||
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select an activity…</option>
|
||||
{recentActivities?.map(a => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{sportIcon(a.sport_type)} {a.name} — {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
|
||||
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
Create
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)}
|
||||
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Route list */}
|
||||
<div className="space-y-2">
|
||||
{routes?.length === 0 && !showCreate && (
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-3xl mb-2">🗺️</p>
|
||||
<p className="text-sm">No named routes yet</p>
|
||||
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||
</div>
|
||||
)}
|
||||
{routes?.map(route => (
|
||||
<button key={route.id} onClick={() => setSelected(route)}
|
||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
||||
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="font-medium text-white">{route.name}</p>
|
||||
{route.auto_detected && (
|
||||
<span className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full ml-2">auto</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||
<span>{formatDistance(route.distance_m)}</span>
|
||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
||||
<span>{formatDate(route.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Route detail */}
|
||||
{selected && (
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||||
{selected.auto_detected && (
|
||||
<span className="text-xs bg-blue-900/40 text-blue-400 border border-blue-700/40 px-2 py-0.5 rounded-full">
|
||||
Auto-detected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fastest && (
|
||||
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-yellow-600 mb-1">Course record 🏆</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xl font-bold text-yellow-400">{formatDuration(fastest.duration_s)}</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||
All runs ({routeActivities?.length ?? 0})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{routeActivities?.map((act, i) => (
|
||||
<div key={act.id} className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm">
|
||||
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
|
||||
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
|
||||
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
|
||||
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
|
||||
{act.avg_heart_rate && (
|
||||
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
|
||||
)}
|
||||
{i === 0 && (
|
||||
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">CR</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
|
||||
function UploadZone({ title, description, accept, endpoint, icon }) {
|
||||
const [tasks, setTasks] = useState([])
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const { data } = await api.post(endpoint, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return { file: file.name, ...data }
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setTasks(t => [...t, { ...data, status: 'queued' }])
|
||||
},
|
||||
})
|
||||
|
||||
const onDrop = useCallback((accepted) => {
|
||||
accepted.forEach(file => upload.mutate(file))
|
||||
}, [upload])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-2xl">{icon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{title}</h3>
|
||||
<p className="text-xs text-gray-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-blue-500 bg-blue-950/30'
|
||||
: 'border-gray-700 hover:border-gray-500 hover:bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<p className="text-blue-400 text-sm">Drop files here…</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">Drag & drop files here, or click to browse</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
{Object.values(accept).flat().join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{upload.isPending && (
|
||||
<p className="text-xs text-blue-400 mt-2 animate-pulse">Uploading…</p>
|
||||
)}
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{tasks.map((task, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-300 truncate flex-1">{task.file}</span>
|
||||
{task.activity_tasks !== undefined && (
|
||||
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
|
||||
)}
|
||||
<span className="ml-2 text-green-400">✓ Queued</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Import Data</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Import activities from Garmin or Strava. Large exports are processed in the background.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* How to export guides */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-blue-950/30 border border-blue-900/50 rounded-xl p-4 text-sm">
|
||||
<h3 className="font-semibold text-blue-300 mb-2">📥 How to export from Garmin Connect</h3>
|
||||
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
||||
<li>Go to Garmin Connect → Profile → Account</li>
|
||||
<li>Scroll to Data Management → Export Your Data</li>
|
||||
<li>Request export and wait for the email</li>
|
||||
<li>Download and upload the ZIP file below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-orange-950/20 border border-orange-900/40 rounded-xl p-4 text-sm">
|
||||
<h3 className="font-semibold text-orange-300 mb-2">📥 How to export from Strava</h3>
|
||||
<ol className="text-gray-400 space-y-1 list-decimal list-inside text-xs">
|
||||
<li>Go to strava.com → Settings → My Account</li>
|
||||
<li>Scroll to Download or Delete Your Account</li>
|
||||
<li>Click "Request Your Archive"</li>
|
||||
<li>Download and upload the ZIP file below</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Single FIT/GPX */}
|
||||
<UploadZone
|
||||
title="Single activity"
|
||||
description="Upload a .fit or .gpx file"
|
||||
icon="🏃"
|
||||
endpoint="/upload/activity"
|
||||
accept={{
|
||||
'application/octet-stream': ['.fit'],
|
||||
'application/gpx+xml': ['.gpx'],
|
||||
'text/xml': ['.gpx'],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Garmin full export */}
|
||||
<UploadZone
|
||||
title="Garmin Connect export"
|
||||
description="Upload your full Garmin data export ZIP"
|
||||
icon="⌚"
|
||||
endpoint="/upload/garmin-export"
|
||||
accept={{ 'application/zip': ['.zip'] }}
|
||||
/>
|
||||
|
||||
{/* Strava export */}
|
||||
<UploadZone
|
||||
title="Strava bulk export"
|
||||
description="Upload your Strava archive ZIP"
|
||||
icon="🚴"
|
||||
endpoint="/upload/strava-export"
|
||||
accept={{ 'application/zip': ['.zip'] }}
|
||||
/>
|
||||
|
||||
{/* Ongoing FIT files */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-2xl">🔄</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Ongoing sync</h3>
|
||||
<p className="text-xs text-gray-500">Automatically import new Garmin watch files</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 text-xs text-gray-500">
|
||||
<p>After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:</p>
|
||||
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono">
|
||||
GARMIN/Activity/*.fit
|
||||
</code>
|
||||
<p>Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:</p>
|
||||
<code className="block bg-gray-800 rounded px-3 py-2 text-green-400 font-mono whitespace-pre">
|
||||
{`# Example: auto-upload new FIT files
|
||||
inotifywait -m ~/Garmin/Activity/ -e create \\
|
||||
--format '%f' | while read file; do
|
||||
curl -X POST /api/upload/activity \\
|
||||
-H "Authorization: Bearer TOKEN" \\
|
||||
-F "file=@$file"
|
||||
done`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,94 @@
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds) return '--'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatPace(speedMs, sportType = 'running') {
|
||||
if (!speedMs || speedMs <= 0) return '--'
|
||||
if (sportType === 'cycling') {
|
||||
return `${(speedMs * 3.6).toFixed(1)} km/h`
|
||||
}
|
||||
const secsPerKm = 1000 / speedMs
|
||||
const mins = Math.floor(secsPerKm / 60)
|
||||
const secs = Math.floor(secsPerKm % 60)
|
||||
return `${mins}:${String(secs).padStart(2, '0')} /km`
|
||||
}
|
||||
|
||||
export function formatDistance(metres) {
|
||||
if (!metres) return '--'
|
||||
if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km`
|
||||
return `${Math.round(metres)} m`
|
||||
}
|
||||
|
||||
export function formatElevation(metres) {
|
||||
if (metres == null) return '--'
|
||||
return `${Math.round(metres)} m`
|
||||
}
|
||||
|
||||
export function formatHeartRate(bpm) {
|
||||
if (!bpm) return '--'
|
||||
return `${Math.round(bpm)} bpm`
|
||||
}
|
||||
|
||||
export function formatSleep(seconds) {
|
||||
if (!seconds) return '--'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.round((seconds % 3600) / 60)
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
|
||||
export function formatWeight(kg) {
|
||||
if (!kg) return '--'
|
||||
return `${kg.toFixed(1)} kg`
|
||||
}
|
||||
|
||||
export function formatDate(dateStr) {
|
||||
if (!dateStr) return '--'
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '--'
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatCadence(value, sportType) {
|
||||
if (!value) return '--'
|
||||
// Garmin stores running cadence as steps per minute / 2 (one foot)
|
||||
// We need to double it to get total steps per minute (both feet)
|
||||
if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') {
|
||||
return `${Math.round(value * 2)} spm`
|
||||
}
|
||||
// Cycling is already in rpm
|
||||
return `${Math.round(value)} rpm`
|
||||
}
|
||||
|
||||
export function hrZoneColor(zone) {
|
||||
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
|
||||
return colors[zone] || '#9ca3af'
|
||||
}
|
||||
|
||||
export function sportIcon(sportType) {
|
||||
const icons = {
|
||||
running: '🏃', cycling: '🚴', hiking: '🥾',
|
||||
walking: '🚶', other: '⚡',
|
||||
}
|
||||
return icons[sportType?.toLowerCase()] || '⚡'
|
||||
}
|
||||
|
||||
export function sportColor(sportType) {
|
||||
const colors = {
|
||||
running: '#3b82f6', cycling: '#f97316',
|
||||
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
||||
}
|
||||
return colors[sportType?.toLowerCase()] || '#6b7280'
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user