diff --git a/peakup-frontend/src/components/dashboard/ActivityOverviewCard.tsx b/peakup-frontend/src/components/dashboard/ActivityOverviewCard.tsx index 286d0f9..104c19e 100644 --- a/peakup-frontend/src/components/dashboard/ActivityOverviewCard.tsx +++ b/peakup-frontend/src/components/dashboard/ActivityOverviewCard.tsx @@ -1,6 +1,7 @@ import 'leaflet/dist/leaflet.css' import L from 'leaflet' import {format} from 'date-fns' +import {Pause, Play} from 'lucide-react' import {useEffect, useRef, useState} from 'react' import {Area, AreaChart, ReferenceLine, ResponsiveContainer, XAxis, YAxis} from 'recharts' import type {ActivityMapStreamPointDo} from '@/types/ActivityMapStreamPointDo' @@ -51,8 +52,10 @@ function ActivityRouteMap({ const markerRef = useRef(null) const [sliderIdx, setSliderIdx] = useState(0) + const [playing, setPlaying] = useState(false) const sliderMax = Math.max(points.length - 1, 0) const boundedIdx = Math.min(sliderIdx, sliderMax) + const step = Math.max(1, Math.ceil(sliderMax / 500)) const totalDistanceM = timeSeries.length > 0 ? (timeSeries[timeSeries.length - 1]?.distanceM ?? 0) : 0 useEffect(() => { @@ -96,7 +99,32 @@ function ActivityRouteMap({ if (!markerRef.current || boundedIdx >= points.length) return const pt = points[boundedIdx] markerRef.current.setLatLng([pt.lat, pt.lng]) - }, [boundedIdx, points]) + if (playing && mapRef.current) { + mapRef.current.panTo([pt.lat, pt.lng], {animate: false}) + } + }, [boundedIdx, points, playing]) + + useEffect(() => { + if (!playing || !mapRef.current || boundedIdx >= points.length) return + const pt = points[boundedIdx] + mapRef.current.setView([pt.lat, pt.lng], 15, {animate: true}) + // only fires when playing flips to true + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playing]) + + useEffect(() => { + if (!playing) return + const id = setInterval(() => { + setSliderIdx((prev) => { + if (prev + step >= sliderMax) { + setPlaying(false) + return sliderMax + } + return prev + step + }) + }, 200) + return () => clearInterval(id) + }, [playing, sliderMax, step]) const tsPoint = timeSeries.length > 0 ? timeSeries[Math.min(boundedIdx, timeSeries.length - 1)] @@ -116,13 +144,24 @@ function ActivityRouteMap({ return (
-
+
{pointInfo && ( -
+
-

At Point

+
+

At Point

+ +
@@ -144,7 +183,7 @@ function ActivityRouteMap({
{altitudeData.some((p) => p.alt !== null) && (
{ const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect() const ratio = (e.clientX - rect.left) / rect.width @@ -272,66 +311,66 @@ export function ActivityOverviewCard({ )}
- {/* Body – full-width map + metrics below slider */} -
- {hasMap ? ( - - ) : ( -
- {sportIcon(activity.activityType)} -

Indoor Trainer Ride

-
- )} -
+ {/* Body – mobile: map then metrics stacked; lg: map left, metrics right */} +
- {/* Metrics – always below the map/slider */} -
- {/* Hero row: Time · Distance · TSS */} -
-
-

Time

-

{formatDuration(activity.movingTimeS)}

-
-
-

Distance

-

{formatDistance(activity.distanceM)}

-
-
-

TSS

-

- {tssValue ?? '—'} -

-
-
- {/* Secondary metrics */} -
- {[ - {label: speedPaceLabel, value: speedPaceValue}, - {label: 'Avg HR', value: heartrate != null ? `${heartrate} bpm` : '—'}, - ...(isBike(activity.activityType) ? [{ - label: 'Avg Power', - value: power != null ? `${power} W` : '—' - }] : []), - {label: 'kCal', value: kcal != null ? kcal.toLocaleString() : '—'}, - ].map((metric, i) => ( -
= 2 ? 'border-t border-zinc-100 sm:border-t-0' : '', - ].join(' ')} - > -

{metric.label}

-

{metric.value}

+ {/* Map — always square; fixed width on lg so it doesn't dominate */} +
+ {hasMap ? ( + + ) : ( +
+ {sportIcon(activity.activityType)} +

Indoor Trainer Ride

- ))} + )} +
+ + {/* Metrics – below map on mobile, right panel on lg (2-col grid) */} +
+ {/* Hero: Time · Distance on row 1, TSS spanning row 2 */} +
+
+

Time

+

{formatDuration(activity.movingTimeS)}

+
+
+

Distance

+

{formatDistance(activity.distanceM)}

+
+
+

TSS

+

+ {tssValue ?? '—'} +

+
+
+ {/* Secondary metrics — 2-col always */} +
+ {[ + {label: speedPaceLabel, value: speedPaceValue}, + {label: 'Avg HR', value: heartrate != null ? `${heartrate} bpm` : '—'}, + ...(isBike(activity.activityType) ? [{label: 'Avg Power', value: power != null ? `${power} W` : '—'}] : []), + {label: 'kCal', value: kcal != null ? kcal.toLocaleString() : '—'}, + ].map((metric, i) => ( +
= 2 ? 'border-t border-zinc-100' : '', + ].join(' ')} + > +

{metric.label}

+

{metric.value}

+
+ ))} +
diff --git a/peakup-frontend/src/pages/Dashboard.tsx b/peakup-frontend/src/pages/Dashboard.tsx index c9c8602..f14caa3 100644 --- a/peakup-frontend/src/pages/Dashboard.tsx +++ b/peakup-frontend/src/pages/Dashboard.tsx @@ -514,6 +514,38 @@ export function Dashboard({athleteId}: { athleteId: number }) {
+ {/* ── Week header ───────────────────────────────────────────────────── */} +
+
+

CW {week} · {year}

+

+ {format(wStart, 'EEE, d MMM')} – {format(wEnd, 'EEE, d MMM yyyy')} +

+
+
+ + + +
+
+ {/* ── 1. Activity Overview ─────────────────────────────────────────── */} {selectedActivity && (
@@ -555,35 +587,7 @@ export function Dashboard({athleteId}: { athleteId: number }) { {/* ── 3. Training Goals ─────────────────────────────────────────────── */}
-
-

Training Goals

-
- - - CW {week} · {year} - - - -
-
+

Training Goals