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 */}
+
+
+ {/* 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)}
+
+ ))}
+
+
+ ))}
+
+ >
+ )}
+
+ )
+}