feat: Activities filters (type/year/date-range/distance) + Summary page with all-time & per-year/per-sport totals and distance-per-year chart
This commit is contained in:
@@ -1,50 +1,79 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import {
|
||||
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
||||
formatDate, sportColor, convertKm, distanceUnitLabel,
|
||||
formatDate, sportColor, distanceUnitLabel,
|
||||
} from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import SportIcon from '../components/ui/SportIcon'
|
||||
|
||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||
const KM_PER_MI = 1.609344
|
||||
const FALLBACK_SPORTS = ['running', 'cycling', 'hiking', 'walking']
|
||||
|
||||
export default function ActivitiesPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [sport, setSport] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const unit = useUnit()
|
||||
const distLabel = distanceUnitLabel(unit)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const fromParam = searchParams.get('from')
|
||||
const toParam = searchParams.get('to')
|
||||
// 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, page, fromParam, toParam],
|
||||
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,
|
||||
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
|
||||
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
|
||||
},
|
||||
}).then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: ytdStats } = useQuery({
|
||||
queryKey: ['ytd-stats'],
|
||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
||||
})
|
||||
const anyFilter = sport !== 'all' || year !== 'all' || minDist || maxDist || fromDate || toDate
|
||||
const clearFilters = () => {
|
||||
setSport('all'); setYear('all'); setMinDist(''); setMaxDist(''); setFromDate(''); setToDate('')
|
||||
}
|
||||
|
||||
const clearDateFilter = () => navigate('/activities')
|
||||
|
||||
// Totals for the currently shown (date-filtered) list
|
||||
// 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
|
||||
|
||||
@@ -52,55 +81,22 @@ export default function ActivitiesPage() {
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
{/* YTD stats */}
|
||||
{ytdStats && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
|
||||
{ytdStats.running_km > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5" style={{ color: sportColor('running') }}>
|
||||
<SportIcon sport="running" size={15} color="currentColor" />
|
||||
{convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year
|
||||
</span>
|
||||
)}
|
||||
{ytdStats.cycling_km > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5" style={{ color: sportColor('cycling') }}>
|
||||
<SportIcon sport="cycling" size={15} color="currentColor" />
|
||||
{convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date filter chip + week totals */}
|
||||
{fromParam && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
|
||||
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
|
||||
</span>
|
||||
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">✕ Clear</button>
|
||||
{activities?.length > 0 && (
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
<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'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sport filter */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{SPORTS.map(s => (
|
||||
{/* Sport filter chips */}
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{['all', ...sportTypes].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setSport(s); setPage(1) }}
|
||||
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'
|
||||
@@ -108,11 +104,55 @@ export default function ActivitiesPage() {
|
||||
}`}
|
||||
>
|
||||
{s !== 'all' && <SportIcon sport={s} size={15} color="currentColor" />}
|
||||
{s === 'all' ? 'All' : s}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user