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:
@@ -104,6 +104,110 @@ async def ytd_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_activity_filters(q, *, sport_type, from_date, to_date, year,
|
||||||
|
min_distance_km, max_distance_km):
|
||||||
|
"""Apply the shared Activities-list filters to a query selecting Activity."""
|
||||||
|
from datetime import timezone
|
||||||
|
if sport_type:
|
||||||
|
q = q.where(Activity.sport_type == sport_type)
|
||||||
|
if year:
|
||||||
|
ys = datetime(year, 1, 1, tzinfo=timezone.utc)
|
||||||
|
ye = datetime(year + 1, 1, 1, tzinfo=timezone.utc)
|
||||||
|
q = q.where(Activity.start_time >= ys, Activity.start_time < ye)
|
||||||
|
if from_date:
|
||||||
|
q = q.where(Activity.start_time >= from_date)
|
||||||
|
if to_date:
|
||||||
|
q = q.where(Activity.start_time <= to_date)
|
||||||
|
if min_distance_km is not None:
|
||||||
|
q = q.where(Activity.distance_m >= min_distance_km * 1000)
|
||||||
|
if max_distance_km is not None:
|
||||||
|
q = q.where(Activity.distance_m <= max_distance_km * 1000)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/filters")
|
||||||
|
async def filter_options(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Distinct sport types and activity years for this user — drives the
|
||||||
|
Activities page filter controls."""
|
||||||
|
from datetime import timezone
|
||||||
|
yr = func.extract("year", Activity.start_time)
|
||||||
|
sports = (await db.execute(
|
||||||
|
select(Activity.sport_type)
|
||||||
|
.where(Activity.user_id == current_user.id)
|
||||||
|
.distinct().order_by(Activity.sport_type)
|
||||||
|
)).scalars().all()
|
||||||
|
years = (await db.execute(
|
||||||
|
select(yr.label("yr"))
|
||||||
|
.where(Activity.user_id == current_user.id)
|
||||||
|
.distinct().order_by(yr.desc())
|
||||||
|
)).scalars().all()
|
||||||
|
return {
|
||||||
|
"sport_types": [s for s in sports if s],
|
||||||
|
"years": [int(y) for y in years if y is not None],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def stats_summary(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Per-year and per-sport totals (count, distance, time, elevation) plus an
|
||||||
|
all-time roll-up. Powers the Summary page."""
|
||||||
|
yr = func.extract("year", Activity.start_time)
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
yr.label("yr"),
|
||||||
|
Activity.sport_type,
|
||||||
|
func.count().label("cnt"),
|
||||||
|
func.coalesce(func.sum(Activity.distance_m), 0).label("dist"),
|
||||||
|
func.coalesce(func.sum(func.coalesce(Activity.moving_time_s, Activity.duration_s)), 0).label("dur"),
|
||||||
|
func.coalesce(func.sum(Activity.elevation_gain_m), 0).label("elev"),
|
||||||
|
)
|
||||||
|
.where(Activity.user_id == current_user.id)
|
||||||
|
.group_by(yr, Activity.sport_type)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
years: dict[int, dict] = {}
|
||||||
|
for r in rows:
|
||||||
|
if r.yr is None:
|
||||||
|
continue
|
||||||
|
yr = int(r.yr)
|
||||||
|
y = years.setdefault(yr, {"year": yr, "count": 0, "distance_km": 0.0,
|
||||||
|
"duration_s": 0.0, "elevation_m": 0.0, "by_sport": []})
|
||||||
|
sport = {
|
||||||
|
"sport_type": r.sport_type,
|
||||||
|
"count": int(r.cnt),
|
||||||
|
"distance_km": round((r.dist or 0) / 1000, 2),
|
||||||
|
"duration_s": float(r.dur or 0),
|
||||||
|
"elevation_m": round(r.elev or 0, 1),
|
||||||
|
}
|
||||||
|
y["by_sport"].append(sport)
|
||||||
|
y["count"] += sport["count"]
|
||||||
|
y["distance_km"] += sport["distance_km"]
|
||||||
|
y["duration_s"] += sport["duration_s"]
|
||||||
|
y["elevation_m"] += sport["elevation_m"]
|
||||||
|
|
||||||
|
by_year = []
|
||||||
|
for y in sorted(years.values(), key=lambda x: x["year"], reverse=True):
|
||||||
|
y["by_sport"].sort(key=lambda s: s["distance_km"], reverse=True)
|
||||||
|
y["distance_km"] = round(y["distance_km"], 2)
|
||||||
|
y["elevation_m"] = round(y["elevation_m"], 1)
|
||||||
|
by_year.append(y)
|
||||||
|
|
||||||
|
all_time = {
|
||||||
|
"count": sum(y["count"] for y in by_year),
|
||||||
|
"distance_km": round(sum(y["distance_km"] for y in by_year), 2),
|
||||||
|
"duration_s": sum(y["duration_s"] for y in by_year),
|
||||||
|
"elevation_m": round(sum(y["elevation_m"] for y in by_year), 1),
|
||||||
|
}
|
||||||
|
sport_types = sorted({s["sport_type"] for y in by_year for s in y["by_sport"] if s["sport_type"]})
|
||||||
|
return {"all_time": all_time, "by_year": by_year, "sport_types": sport_types}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[ActivitySummary])
|
@router.get("/", response_model=List[ActivitySummary])
|
||||||
async def list_activities(
|
async def list_activities(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -111,18 +215,17 @@ async def list_activities(
|
|||||||
sport_type: Optional[str] = None,
|
sport_type: Optional[str] = None,
|
||||||
from_date: Optional[datetime] = None,
|
from_date: Optional[datetime] = None,
|
||||||
to_date: Optional[datetime] = None,
|
to_date: Optional[datetime] = None,
|
||||||
|
year: Optional[int] = None,
|
||||||
|
min_distance_km: Optional[float] = None,
|
||||||
|
max_distance_km: Optional[float] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
|
q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
|
||||||
|
q = _apply_activity_filters(
|
||||||
if sport_type:
|
q, sport_type=sport_type, from_date=from_date, to_date=to_date, year=year,
|
||||||
q = q.where(Activity.sport_type == sport_type)
|
min_distance_km=min_distance_km, max_distance_km=max_distance_km,
|
||||||
if from_date:
|
)
|
||||||
q = q.where(Activity.start_time >= from_date)
|
|
||||||
if to_date:
|
|
||||||
q = q.where(Activity.start_time <= to_date)
|
|
||||||
|
|
||||||
q = q.order_by(desc(Activity.start_time))
|
q = q.order_by(desc(Activity.start_time))
|
||||||
q = q.offset((page - 1) * per_page).limit(per_page)
|
q = q.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import LoginPage from './pages/LoginPage'
|
|||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import ActivitiesPage from './pages/ActivitiesPage'
|
import ActivitiesPage from './pages/ActivitiesPage'
|
||||||
import ActivityDetailPage from './pages/ActivityDetailPage'
|
import ActivityDetailPage from './pages/ActivityDetailPage'
|
||||||
|
import SummaryPage from './pages/SummaryPage'
|
||||||
import HealthPage from './pages/HealthPage'
|
import HealthPage from './pages/HealthPage'
|
||||||
import RoutesPage from './pages/RoutesPage'
|
import RoutesPage from './pages/RoutesPage'
|
||||||
import RecordsPage from './pages/RecordsPage'
|
import RecordsPage from './pages/RecordsPage'
|
||||||
@@ -33,6 +34,7 @@ export default function App() {
|
|||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="activities" element={<ActivitiesPage />} />
|
<Route path="activities" element={<ActivitiesPage />} />
|
||||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||||
|
<Route path="summary" element={<SummaryPage />} />
|
||||||
<Route path="health" element={<HealthPage />} />
|
<Route path="health" element={<HealthPage />} />
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import UnitToggle from './UnitToggle'
|
|||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
{ to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true },
|
||||||
{ to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true },
|
{ to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true },
|
||||||
|
{ to: '/summary', label: 'Summary', icon: '📈' },
|
||||||
{ to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true },
|
{ to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true },
|
||||||
{ to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true },
|
{ to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true },
|
||||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||||
|
|||||||
@@ -1,50 +1,79 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
||||||
formatDate, sportColor, convertKm, distanceUnitLabel,
|
formatDate, sportColor, distanceUnitLabel,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
import { useUnit } from '../hooks/useUnits'
|
import { useUnit } from '../hooks/useUnits'
|
||||||
import SportIcon from '../components/ui/SportIcon'
|
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() {
|
export default function ActivitiesPage() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
|
||||||
const [sport, setSport] = useState('all')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const unit = useUnit()
|
const unit = useUnit()
|
||||||
const distLabel = distanceUnitLabel(unit)
|
const distLabel = distanceUnitLabel(unit)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const fromParam = searchParams.get('from')
|
// Filters
|
||||||
const toParam = searchParams.get('to')
|
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({
|
const { data: activities, isLoading } = useQuery({
|
||||||
queryKey: ['activities', sport, page, fromParam, toParam],
|
queryKey: ['activities', sport, year, minDist, maxDist, fromDate, toDate, page, unit],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/activities/', {
|
api.get('/activities/', {
|
||||||
params: {
|
params: {
|
||||||
sport_type: sport === 'all' ? undefined : sport,
|
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,
|
page,
|
||||||
per_page: 20,
|
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),
|
}).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: ytdStats } = useQuery({
|
const anyFilter = sport !== 'all' || year !== 'all' || minDist || maxDist || fromDate || toDate
|
||||||
queryKey: ['ytd-stats'],
|
const clearFilters = () => {
|
||||||
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
|
setSport('all'); setYear('all'); setMinDist(''); setMaxDist(''); setFromDate(''); setToDate('')
|
||||||
})
|
}
|
||||||
|
|
||||||
const clearDateFilter = () => navigate('/activities')
|
// Totals for the visible results — only meaningful (complete) when everything
|
||||||
|
// fits on one page; otherwise they'd be a misleading partial sum.
|
||||||
// Totals for the currently shown (date-filtered) list
|
const singlePage = (activities?.length ?? 0) < 20
|
||||||
const totalDistanceM = activities?.reduce((sum, a) => sum + (a.distance_m || 0), 0) || 0
|
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
|
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="p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
to="/upload"
|
<Link to="/summary" className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
📈 Summary
|
||||||
>
|
</Link>
|
||||||
+ Import
|
<Link to="/upload" className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
|
||||||
</Link>
|
+ Import
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* YTD stats */}
|
{/* Sport filter chips */}
|
||||||
{ytdStats && (
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
|
{['all', ...sportTypes].map(s => (
|
||||||
{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 => (
|
|
||||||
<button
|
<button
|
||||||
key={s}
|
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 ${
|
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors inline-flex items-center gap-1.5 ${
|
||||||
sport === s
|
sport === s
|
||||||
? 'bg-blue-600 border-blue-600 text-white'
|
? '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' && <SportIcon sport={s} size={15} color="currentColor" />}
|
||||||
{s === 'all' ? 'All' : s}
|
{s === 'all' ? 'All' : s.replace(/_/g, ' ')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Activity list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-gray-500 text-sm">Loading…</div>
|
<div className="text-gray-500 text-sm">Loading…</div>
|
||||||
|
|||||||
@@ -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