Initial Commit

This commit is contained in:
2026-06-06 13:23:33 +01:00
commit 1a0d45dd67
58 changed files with 5268 additions and 0 deletions
+147
View File
@@ -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', 'swimming', '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>
)
}
+158
View File
@@ -0,0 +1,158 @@
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, sportIcon,
} from '../utils/format'
const METRICS = [
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
{ key: 'cadence', label: 'Cadence', unit: 'rpm', color: '#f97316' },
{ key: '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 { 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]
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Loading activity</div>
</div>
)
}
if (!activity) return null
const speed = activity.avg_speed_ms
const pace = formatPace(speed, activity.sport_type)
return (
<div className="p-6 space-y-6">
{/* Header */}
<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>
{/* Summary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
<StatCard label="Pace" value={pace} />
<StatCard label="Elevation" value={`${formatElevation(activity.elevation_gain_m)}`} />
<StatCard label="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="Avg Cadence" value={activity.avg_cadence ? `${Math.round(activity.avg_cadence)} rpm` : '--'} />
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
<StatCard label="TSS" value={activity.training_stress_score ? Math.round(activity.training_stress_score) : '--'} />
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
</div>
{/* Map */}
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800" style={{ height: 420 }}>
<ActivityMap
polyline={activity.polyline}
dataPoints={dataPoints}
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
/>
</div>
{/* HR Zones */}
{activity.hr_zones && Object.keys(activity.hr_zones).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">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} />
</div>
)}
{/* Metric selector */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
<div className="flex flex-wrap gap-2">
{METRICS.map(({ key, label, color }) => (
<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 && (
<MetricTimeline
dataPoints={dataPoints}
activeMetrics={activeMetrics}
metrics={METRICS}
onHoverDistance={setHoveredDistance}
sportType={activity.sport_type}
/>
)}
</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>
)
}
+197
View File
@@ -0,0 +1,197 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { format, subDays, startOfWeek } from 'date-fns'
import 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 null
// Build last 8 weeks of distance data
const weeks = {}
activities.forEach(a => {
const week = format(startOfWeek(new Date(a.start_time)), 'MMM d')
if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 }
weeks[week].km += (a.distance_m || 0) / 1000
weeks[week].runs++
})
const data = Object.values(weeks).slice(-8)
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, name) => [`${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 totalActivities = recentActivities?.length ?? 0
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<Link
to="/upload"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
+ Import data
</Link>
</div>
{/* Top stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Activities (10)" value={totalActivities} />
<StatCard label="Distance (10)" value={formatDistance(totalDistance)} accent="blue" />
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Weekly distance chart */}
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
<WeeklyChart activities={allActivities} />
</div>
{/* Health snapshot */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-500">HRV</span>
<span className="text-white">{latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Sleep score</span>
<span className="text-white">{latest.sleep_score ? Math.round(latest.sleep_score) : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Steps</span>
<span className="text-white">{latest.steps?.toLocaleString() ?? '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">VO2 Max</span>
<span className="text-white">{latest.vo2max ? latest.vo2max.toFixed(1) : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Stress</span>
<span className="text-white">{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}</span>
</div>
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">
View full health dashboard
</Link>
</>
) : (
<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>
{/* PRs snapshot */}
{records?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<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>
)
}
+272
View File
@@ -0,0 +1,272 @@
import { useState } 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: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
{ label: '6M', days: 180 },
{ label: '1Y', days: 365 },
]
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
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={32}
tickFormatter={formatter}
/>
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
</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,
}))
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={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" radius={[0, 0, 0, 0]} />
<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(30)
const fromDate = subDays(new Date(), rangeDays).toISOString()
const { data: summary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const { data: metrics } = useQuery({
queryKey: ['health-metrics', rangeDays],
queryFn: () =>
api.get('/health-metrics/', {
params: { from_date: fromDate, limit: rangeDays },
}).then(r => r.data.reverse()),
})
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="Avg 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>
{metrics && metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Resting HR */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart
data={metrics}
dataKey="resting_hr"
color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`}
/>
</div>
{/* HRV */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart
data={metrics}
dataKey="hrv_nightly_avg"
color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
/>
</div>
{/* Sleep */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
<SleepChart data={metrics} />
<div className="flex gap-4 mt-2">
{[
{ label: 'Deep', color: '#6366f1' },
{ label: 'REM', color: '#8b5cf6' },
{ label: 'Light', color: '#a78bfa' },
{ label: 'Awake', color: '#374151' },
].map(({ label, color }) => (
<div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
</div>
))}
</div>
</div>
{/* Weight */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart
data={metrics}
dataKey="weight_kg"
color="#34d399"
formatter={v => `${v.toFixed(1)} kg`}
/>
</div>
{/* VO2 Max */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart
data={metrics}
dataKey="vo2max"
color="#3b82f6"
formatter={v => v.toFixed(1)}
/>
</div>
{/* Steps */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}>
<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={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
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>
) : (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">📊</p>
<p className="text-lg">No health data yet</p>
<p className="text-sm mt-1">Import a Garmin export to see your health trends</p>
</div>
)}
</div>
)
}
+102
View File
@@ -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">Fit</span>Tracker
</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>
)
}
+177
View File
@@ -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>
)
}
+205
View File
@@ -0,0 +1,205 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format'
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: segments } = useQuery({
queryKey: ['route-segments', selected?.id],
queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data),
enabled: !!selected,
})
const createRoute = useMutation({
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
},
})
const fastest = routeActivities?.[0]
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
<button
onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
+ New route
</button>
</div>
{/* Create route modal */}
{showCreate && (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Create named route</h3>
<p className="text-xs text-gray-500">
Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
</p>
<div className="grid grid-cols-2 gap-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 ID</label>
<input
type="number"
value={newRoute.activity_id}
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Activity ID"
/>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
disabled={!newRoute.name || !newRoute.activity_id}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
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 && (
<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>
</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'
}`}
>
<p className="font-medium text-white">{route.name}</p>
<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">
<h2 className="text-lg font-semibold text-white mb-1">{selected.name}</h2>
{selected.description && (
<p className="text-sm text-gray-400 mb-3">{selected.description}</p>
)}
{/* CR */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record</p>
<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>
)}
{/* All runs on route */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All runs ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-2">
{routeActivities?.map((act, i) => (
<div
key={act.id}
className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm"
>
<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>
{/* Segments */}
{segments && segments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<div className="space-y-2">
{segments.map(seg => (
<div key={seg.id} className="flex items-center justify-between py-2 border-b border-gray-800/50">
<div>
<p className="text-sm font-medium text-white">{seg.name}</p>
{seg.description && (
<p className="text-xs text-gray-500">{seg.description}</p>
)}
</div>
<div className="text-xs text-gray-400 text-right">
<p>{formatDistance(seg.start_distance_m)} {formatDistance(seg.end_distance_m)}</p>
<p>{formatDistance(seg.end_distance_m - seg.start_distance_m)}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
)
}
+178
View File
@@ -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>
)
}