GoalMetric

This commit is contained in:
2026-05-17 15:17:52 +02:00
parent 3fe7db0a35
commit 4453ed2b07
2 changed files with 135 additions and 92 deletions

View File

@@ -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">
<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,8 +311,11 @@ export function ActivityOverviewCard({
)}
</div>
{/* Body – full-width map + metrics below slider */}
<div className="aspect-square">
{/* Body – mobile: map then metrics stacked; lg: map left, metrics right */}
<div className="lg:flex">
{/* 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!}
@@ -282,42 +324,38 @@ export function ActivityOverviewCard({
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">
<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 – 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">
{/* 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">
<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">
<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 */}
<div className="grid grid-cols-2 sm:grid-cols-4 border-t border-zinc-100">
{/* 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` : '—'
}] : []),
...(isBike(activity.activityType) ? [{label: 'Avg Power', value: power != null ? `${power} W` : '—'}] : []),
{label: 'kCal', value: kcal != null ? kcal.toLocaleString() : '—'},
].map((metric, i) => (
<div
@@ -325,7 +363,7 @@ export function ActivityOverviewCard({
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' : '',
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>
@@ -334,6 +372,7 @@ export function ActivityOverviewCard({
))}
</div>
</div>
</div>
{/* Footer – always full-width */}
<div className="px-5 pb-5 pt-4">

View File

@@ -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>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<GoalPeriodColumn
title="Weekly"