GoalMetric
This commit is contained in:
@@ -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<L.CircleMarker | null>(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 (
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<div className="relative flex-1 min-h-0 px-5">
|
||||
<div ref={containerRef} className="w-full h-full"/>
|
||||
{pointInfo && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-[1000] pointer-events-none">
|
||||
<div className="absolute right-3 top-3 z-[1000] pointer-events-none">
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg w-[152px] border-t border-r border-b border-zinc-200 border-l-[3px] border-l-orange-400">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<p className="text-[9px] font-bold tracking-widest text-orange-500 uppercase">At Point</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[9px] font-bold tracking-widest text-orange-500 uppercase">At Point</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (boundedIdx >= sliderMax) setSliderIdx(0)
|
||||
setPlaying((p) => !p)
|
||||
}}
|
||||
className="pointer-events-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white hover:bg-orange-600 transition-colors shadow-sm"
|
||||
>
|
||||
{playing ? <Pause size={9}/> : <Play size={9}/>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 h-px bg-zinc-100"/>
|
||||
</div>
|
||||
<div className="px-4 pb-3 space-y-3">
|
||||
@@ -144,7 +183,7 @@ function ActivityRouteMap({
|
||||
</div>
|
||||
{altitudeData.some((p) => p.alt !== null) && (
|
||||
<div
|
||||
className="h-20 shrink-0 cursor-crosshair"
|
||||
className="h-20 shrink-0 cursor-crosshair px-5"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect()
|
||||
const ratio = (e.clientX - rect.left) / rect.width
|
||||
@@ -272,66 +311,66 @@ export function ActivityOverviewCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body – full-width map + metrics below slider */}
|
||||
<div className="aspect-square">
|
||||
{hasMap ? (
|
||||
<ActivityRouteMap
|
||||
points={mapStream!}
|
||||
timeSeries={timeSeries}
|
||||
isRunActivity={isRun(activity.activityType)}
|
||||
isBikeActivity={isBike(activity.activityType)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full bg-gradient-to-br from-zinc-50 to-zinc-100 flex flex-col items-center justify-center gap-2">
|
||||
<span className="text-6xl opacity-20">{sportIcon(activity.activityType)}</span>
|
||||
<p className="text-sm font-semibold text-zinc-400 tracking-wide">Indoor Trainer Ride</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Body – mobile: map then metrics stacked; lg: map left, metrics right */}
|
||||
<div className="lg:flex">
|
||||
|
||||
{/* Metrics – always below the map/slider */}
|
||||
<div className="border-t border-zinc-100">
|
||||
{/* Hero row: Time · Distance · TSS */}
|
||||
<div className="grid grid-cols-3 divide-x divide-zinc-100">
|
||||
<div className="px-4 py-3 text-center">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Time</p>
|
||||
<p className="text-base font-bold tabular-nums text-zinc-900 leading-none">{formatDuration(activity.movingTimeS)}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Distance</p>
|
||||
<p className="text-base font-bold tabular-nums text-zinc-900 leading-none">{formatDistance(activity.distanceM)}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">TSS</p>
|
||||
<p className={`text-base font-bold tabular-nums leading-none ${tssValue != null ? tssColor(tssValue) : 'text-zinc-900'}`}>
|
||||
{tssValue ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Secondary metrics */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 border-t border-zinc-100">
|
||||
{[
|
||||
{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) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className={[
|
||||
'px-4 py-2.5',
|
||||
i % 2 === 0 ? 'border-r border-zinc-100' : '',
|
||||
i >= 2 ? 'border-t border-zinc-100 sm:border-t-0' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-0.5">{metric.label}</p>
|
||||
<p className="text-sm font-semibold tabular-nums text-zinc-800">{metric.value}</p>
|
||||
{/* Map — always square; fixed width on lg so it doesn't dominate */}
|
||||
<div className="aspect-square lg:flex-none lg:w-[40rem] xl:w-[48rem]">
|
||||
{hasMap ? (
|
||||
<ActivityRouteMap
|
||||
points={mapStream!}
|
||||
timeSeries={timeSeries}
|
||||
isRunActivity={isRun(activity.activityType)}
|
||||
isBikeActivity={isBike(activity.activityType)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-50 to-zinc-100 flex flex-col items-center justify-center gap-2">
|
||||
<span className="text-6xl opacity-20">{sportIcon(activity.activityType)}</span>
|
||||
<p className="text-sm font-semibold text-zinc-400 tracking-wide">Indoor Trainer Ride</p>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics – below map on mobile, right panel on lg (2-col grid) */}
|
||||
<div className="border-t border-zinc-100 lg:border-t-0 lg:border-l lg:flex-1 lg:flex lg:flex-col lg:overflow-y-auto">
|
||||
{/* Hero: Time · Distance on row 1, TSS spanning row 2 */}
|
||||
<div className="grid grid-cols-3 divide-x divide-zinc-100 lg:grid-cols-2 lg:divide-x-0">
|
||||
<div className="px-4 py-3 text-center lg:border-b lg:border-r lg:border-zinc-100">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Time</p>
|
||||
<p className="text-base font-bold tabular-nums text-zinc-900 leading-none">{formatDuration(activity.movingTimeS)}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center border-r border-zinc-100 lg:border-r-0 lg:border-b lg:border-zinc-100">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">Distance</p>
|
||||
<p className="text-base font-bold tabular-nums text-zinc-900 leading-none">{formatDistance(activity.distanceM)}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center lg:col-span-2 lg:border-b lg:border-zinc-100">
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-1">TSS</p>
|
||||
<p className={`text-base font-bold tabular-nums leading-none ${tssValue != null ? tssColor(tssValue) : 'text-zinc-900'}`}>
|
||||
{tssValue ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Secondary metrics — 2-col always */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-2 border-t border-zinc-100">
|
||||
{[
|
||||
{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) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className={[
|
||||
'px-4 py-2.5',
|
||||
i % 2 === 0 ? 'border-r border-zinc-100' : '',
|
||||
i >= 2 ? 'border-t border-zinc-100' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className="text-[9px] font-bold tracking-widest text-zinc-400 uppercase mb-0.5">{metric.label}</p>
|
||||
<p className="text-sm font-semibold tabular-nums text-zinc-800">{metric.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -514,6 +514,38 @@ export function Dashboard({athleteId}: { athleteId: number }) {
|
||||
<div className="flex-1 overflow-y-auto bg-zinc-50">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-8 flex flex-col gap-10">
|
||||
|
||||
{/* ── Week header ───────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xl font-bold text-zinc-900">CW {week} · {year}</p>
|
||||
<p className="text-sm text-zinc-400 mt-0.5">
|
||||
{format(wStart, 'EEE, d MMM')} – {format(wEnd, 'EEE, d MMM yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => goWeek(-1)}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16}/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goWeek(1)}
|
||||
disabled={isCurrentWeek}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={16}/>
|
||||
</button>
|
||||
<button
|
||||
onClick={goToday}
|
||||
disabled={isCurrentWeek}
|
||||
className="ml-1 px-2 py-1 text-xs font-semibold tracking-widest border border-zinc-200 rounded-lg transition-colors text-zinc-400 hover:enabled:text-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
TODAY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 1. Activity Overview ─────────────────────────────────────────── */}
|
||||
{selectedActivity && (
|
||||
<section className="flex flex-col gap-4">
|
||||
@@ -555,35 +587,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
|
||||
|
||||
{/* ── 3. Training Goals ─────────────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-bold tracking-widest text-zinc-400 uppercase">Training Goals</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => goWeek(-1)}
|
||||
className="p-1 rounded-lg text-zinc-400 hover:text-zinc-700 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14}/>
|
||||
</button>
|
||||
<span
|
||||
className="text-xs font-semibold text-zinc-700 px-2 min-w-27.5 text-center tabular-nums">
|
||||
CW {week} · {year}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goWeek(1)}
|
||||
disabled={isCurrentWeek}
|
||||
className="p-1 rounded-lg text-zinc-400 hover:text-zinc-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={14}/>
|
||||
</button>
|
||||
<button
|
||||
onClick={goToday}
|
||||
disabled={isCurrentWeek}
|
||||
className="ml-2 px-2 py-1 text-xs font-semibold tracking-widest border border-zinc-200 rounded-lg transition-colors text-zinc-400 hover:enabled:text-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
TODAY
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] font-bold tracking-widest text-zinc-400 uppercase">Training Goals</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<GoalPeriodColumn
|
||||
title="Weekly"
|
||||
|
||||
Reference in New Issue
Block a user