254 lines
12 KiB
React
254 lines
12 KiB
React
import { useState, useEffect } from 'react'
|
||
import { Link, useSearchParams } from 'react-router-dom'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import api from '../utils/api'
|
||
import {
|
||
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
||
formatDate, sportColor, distanceUnitLabel,
|
||
} from '../utils/format'
|
||
import { useUnit } from '../hooks/useUnits'
|
||
import SportIcon from '../components/ui/SportIcon'
|
||
|
||
const KM_PER_MI = 1.609344
|
||
const FALLBACK_SPORTS = ['running', 'cycling', 'hiking', 'walking']
|
||
|
||
export default function ActivitiesPage() {
|
||
const [searchParams] = useSearchParams()
|
||
const unit = useUnit()
|
||
const distLabel = distanceUnitLabel(unit)
|
||
const [page, setPage] = useState(1)
|
||
|
||
// Filters
|
||
const [sport, setSport] = useState('all')
|
||
const [year, setYear] = useState('all')
|
||
const [minDist, setMinDist] = useState('')
|
||
const [maxDist, setMaxDist] = useState('')
|
||
const [fromDate, setFromDate] = useState(searchParams.get('from') || '')
|
||
const [toDate, setToDate] = useState(searchParams.get('to') || '')
|
||
|
||
// Deep link from the dashboard weekly chart arrives as ?from&to.
|
||
useEffect(() => {
|
||
const f = searchParams.get('from'); const t = searchParams.get('to')
|
||
if (f) setFromDate(f)
|
||
if (t) setToDate(t)
|
||
}, [searchParams])
|
||
|
||
// Reset to page 1 whenever a filter changes.
|
||
useEffect(() => { setPage(1) }, [sport, year, minDist, maxDist, fromDate, toDate])
|
||
|
||
const { data: filterOpts } = useQuery({
|
||
queryKey: ['activity-filters'],
|
||
queryFn: () => api.get('/activities/stats/filters').then(r => r.data),
|
||
})
|
||
const sportTypes = filterOpts?.sport_types?.length ? filterOpts.sport_types : FALLBACK_SPORTS
|
||
const years = filterOpts?.years || []
|
||
|
||
const toKm = v => {
|
||
const n = parseFloat(v)
|
||
if (isNaN(n)) return undefined
|
||
return unit === 'mi' ? n * KM_PER_MI : n
|
||
}
|
||
|
||
const { data: activities, isLoading } = useQuery({
|
||
queryKey: ['activities', sport, year, minDist, maxDist, fromDate, toDate, page, unit],
|
||
queryFn: () =>
|
||
api.get('/activities/', {
|
||
params: {
|
||
sport_type: sport === 'all' ? undefined : sport,
|
||
year: year === 'all' ? undefined : year,
|
||
min_distance_km: toKm(minDist),
|
||
max_distance_km: toKm(maxDist),
|
||
from_date: fromDate ? new Date(fromDate).toISOString() : undefined,
|
||
to_date: toDate ? new Date(toDate + 'T23:59:59').toISOString() : undefined,
|
||
page,
|
||
per_page: 20,
|
||
},
|
||
}).then(r => r.data),
|
||
})
|
||
|
||
const anyFilter = sport !== 'all' || year !== 'all' || minDist || maxDist || fromDate || toDate
|
||
const clearFilters = () => {
|
||
setSport('all'); setYear('all'); setMinDist(''); setMaxDist(''); setFromDate(''); setToDate('')
|
||
}
|
||
|
||
// Totals for the visible results — only meaningful (complete) when everything
|
||
// fits on one page; otherwise they'd be a misleading partial sum.
|
||
const singlePage = (activities?.length ?? 0) < 20
|
||
const totalDistanceM = activities?.reduce((sum, a) => sum + (a.distance_m || 0), 0) || 0
|
||
const totalDurationS = activities?.reduce((sum, a) => sum + (a.duration_s || 0), 0) || 0
|
||
|
||
return (
|
||
<div className="p-4 md:p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||
<div className="flex items-center gap-2">
|
||
<Link to="/summary" className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm px-4 py-2 rounded-lg transition-colors">
|
||
📈 Summary
|
||
</Link>
|
||
<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>
|
||
</div>
|
||
|
||
{/* Sport filter chips */}
|
||
<div className="flex gap-2 mb-3 flex-wrap">
|
||
{['all', ...sportTypes].map(s => (
|
||
<button
|
||
key={s}
|
||
onClick={() => setSport(s)}
|
||
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors inline-flex items-center gap-1.5 ${
|
||
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' && <SportIcon sport={s} size={15} color="currentColor" />}
|
||
{s === 'all' ? 'All' : s.replace(/_/g, ' ')}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Year / distance / date filters */}
|
||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-gray-500">Year</span>
|
||
<select value={year} onChange={e => setYear(e.target.value)}
|
||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||
<option value="all">All years</option>
|
||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-gray-500">From</span>
|
||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)}
|
||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-gray-500">To</span>
|
||
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)}
|
||
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-gray-500">Min {distLabel}</span>
|
||
<input type="number" min="0" step="0.1" value={minDist} onChange={e => setMinDist(e.target.value)}
|
||
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-xs text-gray-500">Max {distLabel}</span>
|
||
<input type="number" min="0" step="0.1" value={maxDist} onChange={e => setMaxDist(e.target.value)}
|
||
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||
</label>
|
||
{anyFilter && (
|
||
<button onClick={clearFilters} className="text-xs text-gray-500 hover:text-gray-300 transition-colors pb-2">✕ Clear filters</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Filtered totals (only when the result set is a single page) */}
|
||
{anyFilter && activities?.length > 0 && singlePage && (
|
||
<div className="mb-4 text-sm text-gray-400">
|
||
<span className="text-gray-200 font-medium">{formatDistance(totalDistanceM, unit)}</span> ·{' '}
|
||
<span className="text-gray-200 font-medium">{formatDuration(totalDurationS)}</span> ·{' '}
|
||
{activities.length} {activities.length === 1 ? 'activity' : 'activities'}
|
||
</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-3 p-3 sm:gap-4 sm:p-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl transition-all group"
|
||
>
|
||
{/* Sport indicator */}
|
||
<div
|
||
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
|
||
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
|
||
>
|
||
<SportIcon sport={activity.sport_type} size={20} color={sportColor(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>
|
||
{activity.original_name && (
|
||
<p className="text-xs text-gray-600 truncate">orig. {activity.original_name}</p>
|
||
)}
|
||
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
|
||
{/* Compact metrics line — the full metrics column is hidden below sm */}
|
||
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
|
||
{formatDistance(activity.distance_m, unit)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type, unit)}
|
||
</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, unit)}</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, unit)}</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 ? `↑ ${formatElevation(activity.elevation_gain_m, unit)}` : '--'}
|
||
</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">
|
||
<SportIcon sport="running" size={44} color="currentColor" className="mx-auto mb-3" />
|
||
<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>
|
||
)
|
||
}
|