Files
MileVault/frontend/src/pages/ActivitiesPage.jsx
T
owain e7123ee5db
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
Mobile-responsive UI: bottom tab nav, stacked dashboard, page fixes for phones
- Layout: sidebar hidden below md; mobile top header + bottom tab bar
  (Dashboard/Activities/Health/Routes) with a More slide-up sheet holding
  Records/Import/Profile/Users, Garmin sync progress and sign-out
- Dashboard: single-column widget stack on phones ordered from the saved
  desktop layout (stat cards pair into 2-col grid); drag-edit stays
  desktop-only; grid conditionally mounted so WidthProvider never
  measures a hidden container
- New useMediaQuery/useIsMobile hook (matchMedia, md breakpoint)
- ActivityDetail: 2-col stats on phones, wrapping map toolbar, Height
  control hidden on small screens
- Activities: compact distance/time/pace line on mobile rows
- Records/Users: horizontally scrollable tables; route records hide
  Pace/Date columns below sm
- Profile forms single-column on phones; Routes expanded card stacks;
  Health flex-wrap fixes; p-4 md:p-6 page padding; h-dvh for mobile
  browser chrome
- vite.config: VITE_PROXY_TARGET override for host-side dev

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:52:10 +01:00

189 lines
7.5 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Link, useSearchParams, useNavigate } 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,
formatDate, sportIcon, sportColor,
} from '../utils/format'
const SPORTS = ['all', '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 fromParam = searchParams.get('from')
const toParam = searchParams.get('to')
const { data: activities, isLoading } = useQuery({
queryKey: ['activities', sport, page, fromParam, toParam],
queryFn: () =>
api.get('/activities/', {
params: {
sport_type: sport === 'all' ? undefined : sport,
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 clearDateFilter = () => navigate('/activities')
return (
<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>
{/* YTD stats */}
{ytdStats && (
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
{ytdStats.running_km > 0 && (
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
)}
{ytdStats.cycling_km > 0 && (
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
)}
</div>
)}
{/* Date filter chip */}
{fromParam && (
<div className="flex 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>
</div>
)}
{/* Sport filter */}
<div className="flex gap-2 mb-6 flex-wrap">
{SPORTS.map(s => (
<button
key={s}
onClick={() => { setSport(s); setPage(1) }}
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors ${
sport === s
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
}`}
>
{s === 'all' ? 'All' : `${sportIcon(s)} ${s}`}
</button>
))}
</div>
{/* Activity list */}
{isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : (
<div className="space-y-2">
{activities?.map(activity => (
<Link
key={activity.id}
to={`/activities/${activity.id}`}
className="flex items-center gap-3 p-3 sm:gap-4 sm:p-4 bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-xl transition-all group"
>
{/* Sport indicator */}
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg"
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
>
{sportIcon(activity.sport_type)}
</div>
{/* Name + date */}
<div className="flex-1 min-w-0">
<p className="font-medium text-white group-hover:text-blue-400 transition-colors truncate">
{activity.name}
</p>
<p className="text-xs text-gray-500 mt-0.5">{formatDate(activity.start_time)}</p>
{/* Compact metrics line — the full metrics column is hidden below sm */}
<p className="sm:hidden text-xs text-gray-400 mt-0.5 truncate">
{formatDistance(activity.distance_m)} · {formatDuration(activity.duration_s)} · {formatPace(activity.avg_speed_ms, activity.sport_type)}
</p>
</div>
{/* Metrics */}
<div className="hidden sm:flex items-center gap-6 text-sm">
<div className="text-right">
<p className="text-gray-200 font-medium">{formatDistance(activity.distance_m)}</p>
<p className="text-xs text-gray-600">distance</p>
</div>
<div className="text-right">
<p className="text-gray-200 font-medium">{formatDuration(activity.duration_s)}</p>
<p className="text-xs text-gray-600">time</p>
</div>
<div className="text-right">
<p className="text-gray-200 font-medium">{formatPace(activity.avg_speed_ms, activity.sport_type)}</p>
<p className="text-xs text-gray-600">pace</p>
</div>
<div className="text-right">
<p className="text-red-400 font-medium">{formatHeartRate(activity.avg_heart_rate)}</p>
<p className="text-xs text-gray-600">avg HR</p>
</div>
<div className="text-right">
<p className="text-gray-200 font-medium">
{activity.elevation_gain_m ? `${Math.round(activity.elevation_gain_m)}m` : '--'}
</p>
<p className="text-xs text-gray-600">elev</p>
</div>
</div>
<span className="text-gray-700 group-hover:text-gray-400 transition-colors ml-2"></span>
</Link>
))}
{activities?.length === 0 && (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">🏃</p>
<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 Garmin or Strava data</Link> to get started
</p>
</div>
)}
</div>
)}
{/* Pagination */}
{activities?.length === 20 && (
<div className="flex justify-center gap-3 mt-6">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg disabled:opacity-30 hover:bg-gray-700 transition-colors"
>
Previous
</button>
<span className="px-4 py-2 text-sm text-gray-500">Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 transition-colors"
>
Next
</button>
</div>
)}
</div>
)
}