Last workout

This commit is contained in:
2026-04-18 22:24:29 +02:00
parent 5ce2c0814d
commit 5e0758d97a
2 changed files with 193 additions and 122 deletions

View File

@@ -0,0 +1,191 @@
import 'leaflet/dist/leaflet.css'
import L from 'leaflet'
import {format} from 'date-fns'
import {useEffect, useRef} from 'react'
import type {ActivityMapStreamPointDo} from '@/types/ActivityMapStreamPointDo'
import type {IntervalsActivityDo} from '@/types/IntervalsActivityDo'
import {formatDistance, formatDuration, formatPace, formatSpeed, sportIcon} from '@/utils/activity'
function isBike(type: string): boolean {
const lowered = type.toLowerCase()
return lowered.includes('ride') || lowered.includes('cycling')
}
function isRun(type: string): boolean {
return type.toLowerCase().includes('run')
}
function tssColor(tss: number): string {
if (tss < 50) return 'text-green-600'
if (tss < 100) return 'text-amber-500'
if (tss < 150) return 'text-orange-500'
return 'text-red-600'
}
function ActivityRouteMap({points}: {points: ActivityMapStreamPointDo[]}) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
useEffect(() => {
if (!containerRef.current || points.length === 0) return
if (mapRef.current) {
mapRef.current.remove()
mapRef.current = null
}
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: false,
dragging: true,
scrollWheelZoom: true,
doubleClickZoom: true,
touchZoom: true,
keyboard: false,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map)
const latLngs = points.map((p) => L.latLng(p.lat, p.lng))
const polyline = L.polyline(latLngs, {color: '#ef4444', weight: 4, opacity: 0.9}).addTo(map)
map.fitBounds(polyline.getBounds(), {padding: [24, 24]})
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
}
}, [points])
return <div ref={containerRef} className="w-full h-full"/>
}
export function LastWorkoutCard({
activity,
mapStream,
}: {
activity: IntervalsActivityDo
mapStream: ActivityMapStreamPointDo[] | null
}) {
const startDate = new Date(activity.startTime)
const dateLabel = format(startDate, 'EEE d MMM yyyy · HH:mm')
const hasMap = (isBike(activity.type) || isRun(activity.type)) && mapStream != null && mapStream.length > 0
const tssValue = activity.trainingLoad > 0 ? Math.round(activity.trainingLoad) : null
const kcal = activity.calories > 0 ? Math.round(activity.calories) : null
const heartrate = activity.averageHeartrateBpm > 0 ? Math.round(activity.averageHeartrateBpm) : null
const power = activity.averagePowerWatt > 0 ? Math.round(activity.averagePowerWatt) : null
const speedPaceLabel = isRun(activity.type) ? 'Avg Pace' : 'Avg Speed'
const speedPaceValue = isRun(activity.type)
? (activity.paceMps != null && activity.paceMps > 0 ? formatPace(activity.paceMps) : formatPace(activity.averageSpeedMps))
: formatSpeed(activity.averageSpeedMps)
const sportBadgeLabel = isBike(activity.type) ? 'Ride' : isRun(activity.type) ? 'Run' : null
return (
<div className="border border-zinc-200 rounded-xl bg-white shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-3 min-w-0">
<span className="text-2xl leading-none shrink-0">{sportIcon(activity.type)}</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-zinc-900 leading-tight truncate">{activity.name}</p>
<p className="text-xs text-zinc-400 mt-0.5">{dateLabel}</p>
</div>
</div>
{sportBadgeLabel != null && (
<span className="shrink-0 ml-3 px-2.5 py-1 text-xs font-semibold text-white rounded-full bg-gradient-to-r from-red-500 to-orange-500">
{sportBadgeLabel}
</span>
)}
</div>
{/* Map / Indoor placeholder */}
<div className="relative mx-4 mb-4 rounded-xl overflow-hidden shadow-md h-[360px]">
{hasMap ? (
<>
<ActivityRouteMap points={mapStream!}/>
{/* Stats overlay */}
<div className="absolute bottom-4 right-4 z-[1000] bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 min-w-[170px]">
<div className="flex items-end gap-4 mb-3 pb-3 border-b border-zinc-100">
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">Time</p>
<p className="text-xl font-bold text-zinc-900 leading-tight tabular-nums">{formatDuration(activity.movingTimeS)}</p>
</div>
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">Distance</p>
<p className="text-xl font-bold text-zinc-900 leading-tight tabular-nums">{formatDistance(activity.distanceM)}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5">
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">{speedPaceLabel}</p>
<p className="text-sm font-semibold text-zinc-800 tabular-nums">{speedPaceValue}</p>
</div>
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">Avg HR</p>
<p className="text-sm font-semibold text-zinc-800 tabular-nums">{heartrate != null ? `${heartrate} bpm` : '—'}</p>
</div>
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">TSS</p>
<p className={`text-sm font-semibold tabular-nums ${tssValue != null ? tssColor(tssValue) : 'text-zinc-800'}`}>
{tssValue ?? '—'}
</p>
</div>
{isBike(activity.type) && (
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">Avg Power</p>
<p className="text-sm font-semibold text-zinc-800 tabular-nums">{power != null ? `${power} W` : '—'}</p>
</div>
)}
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">kCal</p>
<p className="text-sm font-semibold text-zinc-800 tabular-nums">{kcal != null ? kcal.toLocaleString() : '—'}</p>
</div>
</div>
</div>
</>
) : (
/* Indoor / no-GPS placeholder */
<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-5xl opacity-20">🏋️</span>
<p className="text-sm font-semibold text-zinc-500">Indoor Activity</p>
<div className="flex items-center gap-6 mt-5 pt-5 border-t border-zinc-200">
<div className="text-center">
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">Time</p>
<p className="text-xl font-bold text-zinc-700 tabular-nums">{formatDuration(activity.movingTimeS)}</p>
</div>
{tssValue != null && (
<div className="text-center">
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">TSS</p>
<p className={`text-xl font-bold tabular-nums ${tssColor(tssValue)}`}>{tssValue}</p>
</div>
)}
{power != null && (
<div className="text-center">
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">Avg Power</p>
<p className="text-xl font-bold text-zinc-700 tabular-nums">{power} W</p>
</div>
)}
{heartrate != null && (
<div className="text-center">
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">Avg HR</p>
<p className="text-xl font-bold text-zinc-700 tabular-nums">{heartrate} bpm</p>
</div>
)}
{kcal != null && (
<div className="text-center">
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">kCal</p>
<p className="text-xl font-bold text-zinc-700 tabular-nums">{kcal.toLocaleString()}</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-5 pb-5">
<button className="w-full py-2.5 rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-red-500 to-orange-500 hover:opacity-90 transition-opacity">
View Details →
</button>
</div>
</div>
)
}

View File

@@ -9,21 +9,18 @@ import {
isToday,
startOfISOWeek,
} from 'date-fns'
import 'leaflet/dist/leaflet.css'
import L from 'leaflet'
import {ChevronLeft, ChevronRight} from 'lucide-react'
import {useEffect, useRef} from 'react'
import {useSearchParams} from 'react-router-dom'
import {Area, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'
import type {MouseHandlerDataParam} from 'recharts'
import {useDashboardGetDashboard} from '@/api/hooks'
import type {ActivityMapStreamPointDo} from '@/types/ActivityMapStreamPointDo'
import type {IntervalsActivityDo} from '@/types/IntervalsActivityDo'
import type {GoalDo} from '@/types/GoalDo'
import type {GoalMetricEnum} from '@/types/GoalMetricEnum'
import type {GoalMetricPeriodTotalDo} from '@/types/GoalMetricPeriodTotalDo'
import type {GoalPeriodEnum} from '@/types/GoalPeriodEnum'
import {formatDistance, formatDuration, formatPace, formatSpeed, sportIcon} from '@/utils/activity'
import {formatDistance, formatDuration, sportIcon} from '@/utils/activity'
import {LastWorkoutCard} from '@/components/dashboard/LastWorkoutCard'
// ── Week helpers ──────────────────────────────────────────────────────────────
@@ -189,123 +186,6 @@ function GoalPeriodColumn({
)
}
// ── RouteMap ──────────────────────────────────────────────────────────────────
function RouteMap({points}: { points: ActivityMapStreamPointDo[] }) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
useEffect(() => {
if (!containerRef.current || points.length === 0) return
if (mapRef.current) {
mapRef.current.remove()
mapRef.current = null
}
const map = L.map(containerRef.current, {
zoomControl: true,
attributionControl: false,
dragging: true,
scrollWheelZoom: true,
doubleClickZoom: true,
touchZoom: true,
keyboard: true,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map)
const latLngs = points.map((p) => L.latLng(p.lat, p.lng))
const polyline = L.polyline(latLngs, {color: '#ef4444', weight: 2.5, opacity: 0.85}).addTo(map)
map.fitBounds(polyline.getBounds(), {padding: [10, 10]})
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
}
}, [points])
return <div ref={containerRef} className="w-full h-full rounded-sm"/>
}
// ── WorkoutMetric ─────────────────────────────────────────────────────────────
function WorkoutMetric({label, value}: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">{label}</p>
<p className="text-sm font-semibold text-zinc-800">{value}</p>
</div>
)
}
// ── LastWorkoutCard ───────────────────────────────────────────────────────────
function isBike(type: string): boolean {
const lowered = type.toLowerCase()
return lowered.includes('ride') || lowered.includes('cycling')
}
function isRun(type: string): boolean {
return type.toLowerCase().includes('run')
}
function LastWorkoutCard({
activity,
mapStream,
}: {
activity: IntervalsActivityDo
mapStream: ActivityMapStreamPointDo[] | null
}) {
const startDate = new Date(activity.startTime)
const dateLabel = format(startDate, 'EEE d MMM yyyy · HH:mm')
const showMap = (isBike(activity.type) || isRun(activity.type)) && mapStream && mapStream.length > 0
const tss = activity.trainingLoad != null ? Math.round(activity.trainingLoad).toString() : '—'
const kcal = activity.calories != null ? Math.round(activity.calories).toString() : '—'
const bikeOrRunMetrics = isBike(activity.type) ? (
<>
<WorkoutMetric label="Distance" value={formatDistance(activity.distanceM)}/>
<WorkoutMetric label="Avg Speed" value={formatSpeed(activity.averageSpeedMps)}/>
<WorkoutMetric label="Avg Watt" value={activity.averagePowerWatt != null ? `${Math.round(activity.averagePowerWatt)} W` : '—'}/>
<WorkoutMetric label="TSS" value={tss}/>
<WorkoutMetric label="kcal" value={kcal}/>
</>
) : isRun(activity.type) ? (
<>
<WorkoutMetric label="Distance" value={formatDistance(activity.distanceM)}/>
<WorkoutMetric label="Avg Pace" value={activity.paceMps != null ? formatPace(activity.paceMps) : formatPace(activity.averageSpeedMps)}/>
<WorkoutMetric label="Avg Watt" value={activity.averagePowerWatt != null ? `${Math.round(activity.averagePowerWatt)} W` : '—'}/>
<WorkoutMetric label="TSS" value={tss}/>
<WorkoutMetric label="kcal" value={kcal}/>
</>
) : null
return (
<div className="border border-zinc-200 rounded-sm bg-white p-5">
<div className="flex items-center gap-2 mb-4">
<span className="text-lg leading-none">{sportIcon(activity.type)}</span>
<div>
<p className="text-sm font-semibold text-zinc-800 leading-tight">{activity.name}</p>
<p className="text-xs text-zinc-400">{dateLabel}</p>
</div>
</div>
<div className={showMap ? 'flex gap-5' : undefined}>
<div className="flex flex-col gap-3 shrink-0">
<WorkoutMetric label="Duration" value={formatDuration(activity.movingTimeS)}/>
{bikeOrRunMetrics ?? (
<>
<WorkoutMetric label="TSS" value={tss}/>
<WorkoutMetric label="kcal" value={kcal}/>
</>
)}
</div>
{showMap && (
<div className="flex-1 min-h-[180px]">
<RouteMap points={mapStream}/>
</div>
)}
</div>
</div>
)
}
// ── TSS / load chart ──────────────────────────────────────────────────────────
type LoadPoint = {