Files
MileVault/frontend/src/pages/ActivitiesPage.jsx
T
owain e2870e86a8
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
feat: Activities filters (type/year/date-range/distance) + Summary page with all-time & per-year/per-sport totals and distance-per-year chart
2026-06-21 16:29:33 +01:00

254 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}