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:
@@ -0,0 +1,124 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid,
|
||||
} from 'recharts'
|
||||
import api from '../utils/api'
|
||||
import { formatDuration, sportColor, convertKm, distanceUnitLabel, formatElevation } from '../utils/format'
|
||||
import { useUnit } from '../hooks/useUnits'
|
||||
import SportIcon from '../components/ui/SportIcon'
|
||||
|
||||
function StatTile({ label, value, sub }) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SummaryPage() {
|
||||
const unit = useUnit()
|
||||
const distLabel = distanceUnitLabel(unit)
|
||||
const dist = km => `${convertKm(km, unit).toLocaleString(undefined, { maximumFractionDigits: 0 })} ${distLabel}`
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['stats-summary'],
|
||||
queryFn: () => api.get('/activities/stats/summary').then(r => r.data),
|
||||
})
|
||||
|
||||
const byYear = data?.by_year || []
|
||||
const allTime = data?.all_time
|
||||
const chartData = [...byYear].reverse().map(y => ({
|
||||
year: String(y.year),
|
||||
distance: Math.round(convertKm(y.distance_km, unit)),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Summary</h1>
|
||||
<Link to="/activities" className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm px-4 py-2 rounded-lg transition-colors">
|
||||
← Activities
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-gray-500 text-sm">Loading…</div>
|
||||
) : !allTime || allTime.count === 0 ? (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<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 data</Link> to see your totals
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* All-time totals */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-2">All time</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatTile label="Activities" value={allTime.count.toLocaleString()} />
|
||||
<StatTile label="Distance" value={dist(allTime.distance_km)} />
|
||||
<StatTile label="Moving time" value={formatDuration(allTime.duration_s)} />
|
||||
<StatTile label="Elevation gain" value={formatElevation(allTime.elevation_m, unit)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distance per year */}
|
||||
{chartData.length > 1 && (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<h2 className="text-sm font-semibold text-gray-400 mb-3">Distance per year ({distLabel})</h2>
|
||||
<div style={{ width: '100%', height: 220 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||
<XAxis dataKey="year" tick={{ fontSize: 11, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: '#6b7280' }} axisLine={false} tickLine={false} width={44} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||
labelStyle={{ color: '#e5e7eb' }}
|
||||
formatter={v => [`${v.toLocaleString()} ${distLabel}`, 'Distance']}
|
||||
/>
|
||||
<Bar dataKey="distance" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-year breakdown */}
|
||||
<div className="space-y-4">
|
||||
{byYear.map(y => (
|
||||
<div key={y.year} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div className="flex items-baseline justify-between mb-3 flex-wrap gap-x-4 gap-y-1">
|
||||
<h3 className="text-lg font-bold text-white">{y.year}</h3>
|
||||
<div className="text-sm text-gray-400 flex flex-wrap gap-x-4">
|
||||
<span><span className="text-gray-200 font-medium">{y.count}</span> activities</span>
|
||||
<span><span className="text-gray-200 font-medium">{dist(y.distance_km)}</span></span>
|
||||
<span><span className="text-gray-200 font-medium">{formatDuration(y.duration_s)}</span></span>
|
||||
<span>↑ <span className="text-gray-200 font-medium">{formatElevation(y.elevation_m, unit)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{y.by_sport.map(s => (
|
||||
<div key={s.sport_type} className="flex items-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 w-32 capitalize" style={{ color: sportColor(s.sport_type) }}>
|
||||
<SportIcon sport={s.sport_type} size={15} color="currentColor" />
|
||||
{(s.sport_type || 'other').replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="text-gray-500 w-20">{s.count} act.</span>
|
||||
<span className="text-gray-300 w-24">{dist(s.distance_km)}</span>
|
||||
<span className="text-gray-500">{formatDuration(s.duration_s)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user