diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index d2c0ac1..66f6f08 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -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]) async def list_activities( page: int = Query(1, ge=1), @@ -111,18 +215,17 @@ async def list_activities( sport_type: Optional[str] = None, from_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), current_user: User = Depends(get_current_user), ): q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id) - - if sport_type: - q = q.where(Activity.sport_type == sport_type) - if from_date: - q = q.where(Activity.start_time >= from_date) - if to_date: - q = q.where(Activity.start_time <= to_date) - + q = _apply_activity_filters( + q, sport_type=sport_type, from_date=from_date, to_date=to_date, year=year, + min_distance_km=min_distance_km, max_distance_km=max_distance_km, + ) q = q.order_by(desc(Activity.start_time)) q = q.offset((page - 1) * per_page).limit(per_page) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a740644..0b58b06 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import LoginPage from './pages/LoginPage' import DashboardPage from './pages/DashboardPage' import ActivitiesPage from './pages/ActivitiesPage' import ActivityDetailPage from './pages/ActivityDetailPage' +import SummaryPage from './pages/SummaryPage' import HealthPage from './pages/HealthPage' import RoutesPage from './pages/RoutesPage' import RecordsPage from './pages/RecordsPage' @@ -33,6 +34,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx index ec5842f..6f516e5 100644 --- a/frontend/src/components/ui/Layout.jsx +++ b/frontend/src/components/ui/Layout.jsx @@ -7,6 +7,7 @@ import UnitToggle from './UnitToggle' const nav = [ { to: '/', label: 'Dashboard', icon: '📊', exact: true, mobilePrimary: true }, { to: '/activities', label: 'Activities', icon: '🏃', mobilePrimary: true }, + { to: '/summary', label: 'Summary', icon: '📈' }, { to: '/health', label: 'Health', icon: '❤️', mobilePrimary: true }, { to: '/routes', label: 'Routes', icon: '🗺️', mobilePrimary: true }, { to: '/records', label: 'Records', icon: '🏆' }, diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx index cc27c57..210fd26 100644 --- a/frontend/src/pages/ActivitiesPage.jsx +++ b/frontend/src/pages/ActivitiesPage.jsx @@ -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() {

Activities

- - + Import - +
+ + 📈 Summary + + + + Import + +
- {/* YTD stats */} - {ytdStats && ( -
- {ytdStats.running_km > 0 && ( - - - {convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year - - )} - {ytdStats.cycling_km > 0 && ( - - - {convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year - - )} -
- )} - - {/* Date filter chip + week totals */} - {fromParam && ( -
- - Week of {format(new Date(fromParam), 'MMM d, yyyy')} - - - {activities?.length > 0 && ( - - {formatDistance(totalDistanceM, unit)} ·{' '} - {formatDuration(totalDurationS)} ·{' '} - {activities.length} {activities.length === 1 ? 'activity' : 'activities'} - - )} -
- )} - - {/* Sport filter */} -
- {SPORTS.map(s => ( + {/* Sport filter chips */} +
+ {['all', ...sportTypes].map(s => ( ))}
+ {/* Year / distance / date filters */} +
+ + + + + + {anyFilter && ( + + )} +
+ + {/* Filtered totals (only when the result set is a single page) */} + {anyFilter && activities?.length > 0 && singlePage && ( +
+ {formatDistance(totalDistanceM, unit)} ·{' '} + {formatDuration(totalDurationS)} ·{' '} + {activities.length} {activities.length === 1 ? 'activity' : 'activities'} +
+ )} + {/* Activity list */} {isLoading ? (
Loading…
diff --git a/frontend/src/pages/SummaryPage.jsx b/frontend/src/pages/SummaryPage.jsx new file mode 100644 index 0000000..d0ac8e5 --- /dev/null +++ b/frontend/src/pages/SummaryPage.jsx @@ -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 ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ) +} + +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 ( +
+
+

Summary

+ + ← Activities + +
+ + {isLoading ? ( +
Loading…
+ ) : !allTime || allTime.count === 0 ? ( +
+

No activities yet

+

+ Import your data to see your totals +

+
+ ) : ( + <> + {/* All-time totals */} +
+

All time

+
+ + + + +
+
+ + {/* Distance per year */} + {chartData.length > 1 && ( +
+

Distance per year ({distLabel})

+
+ + + + + + [`${v.toLocaleString()} ${distLabel}`, 'Distance']} + /> + + + +
+
+ )} + + {/* Per-year breakdown */} +
+ {byYear.map(y => ( +
+
+

{y.year}

+
+ {y.count} activities + {dist(y.distance_km)} + {formatDuration(y.duration_s)} + {formatElevation(y.elevation_m, unit)} +
+
+
+ {y.by_sport.map(s => ( +
+ + + {(s.sport_type || 'other').replace(/_/g, ' ')} + + {s.count} act. + {dist(s.distance_km)} + {formatDuration(s.duration_s)} +
+ ))} +
+
+ ))} +
+ + )} +
+ ) +}