Last workout

This commit is contained in:
2026-04-18 22:37:17 +02:00
parent 5e0758d97a
commit 8a57cc4446

View File

@@ -43,7 +43,7 @@ function ActivityRouteMap({points}: {points: ActivityMapStreamPointDo[]}) {
})
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)
const polyline = L.polyline(latLngs, {color: '#f97316', weight: 5, opacity: 1}).addTo(map)
map.fitBounds(polyline.getBounds(), {padding: [24, 24]})
mapRef.current = map
return () => {
@@ -55,6 +55,21 @@ function ActivityRouteMap({points}: {points: ActivityMapStreamPointDo[]}) {
return <div ref={containerRef} className="w-full h-full"/>
}
type MetricItem = {
label: string
value: string
colorClass?: string
}
function MetricCell({label, value, colorClass = 'text-zinc-800'}: MetricItem) {
return (
<div>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase">{label}</p>
<p className={`text-sm font-semibold tabular-nums ${colorClass}`}>{value}</p>
</div>
)
}
export function LastWorkoutCard({
activity,
mapStream,
@@ -78,10 +93,18 @@ export function LastWorkoutCard({
const sportBadgeLabel = isBike(activity.type) ? 'Ride' : isRun(activity.type) ? 'Run' : null
const metrics: MetricItem[] = [
{label: speedPaceLabel, value: speedPaceValue},
{label: 'Avg HR', value: heartrate != null ? `${heartrate} bpm` : '—'},
{label: 'TSS', value: String(tssValue ?? '—'), colorClass: tssValue != null ? tssColor(tssValue) : 'text-zinc-800'},
...(isBike(activity.type) ? [{label: 'Avg Power', value: power != null ? `${power} W` : '—'}] : []),
{label: 'kCal', value: kcal != null ? kcal.toLocaleString() : '—'},
]
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="border border-zinc-200 rounded-xl bg-white shadow-md hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 overflow-hidden">
{/* Header – always full-width */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100">
<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">
@@ -90,99 +113,54 @@ export function LastWorkoutCard({
</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">
<span className="shrink-0 ml-3 px-3 py-1 text-xs font-bold text-white rounded-full bg-gradient-to-r from-red-500 to-orange-500 shadow-sm">
{sportBadgeLabel}
</span>
)}
</div>
{/* Map / Indoor placeholder */}
<div className="relative mx-4 mb-4 rounded-xl overflow-hidden shadow-md h-[360px]">
{hasMap ? (
<>
{/* Body – stacked on mobile, two-column on lg+ */}
<div className="grid grid-cols-1 lg:grid-cols-[65%_35%]">
{/* Left: map or indoor placeholder */}
<div className="h-[280px] lg:h-[420px]">
{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 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.type)}</span>
<p className="text-sm font-semibold text-zinc-400 tracking-wide">Indoor Trainer Ride</p>
</div>
)}
</div>
{/* Right: metrics panel */}
<div className="flex flex-col justify-between p-5 border-t border-zinc-100 lg:border-t-0 lg:border-l lg:border-zinc-100">
{/* Hero row: Moving Time + Distance */}
<div>
<div className="mb-1">
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase mb-1">Moving Time · Distance</p>
<div className="flex items-baseline gap-1.5">
<p className="text-2xl font-bold text-zinc-900 tabular-nums leading-none">{formatDuration(activity.movingTimeS)}</p>
<span className="text-zinc-300 text-lg font-light">•</span>
<p className="text-2xl font-bold text-zinc-900 tabular-nums leading-none">{formatDistance(activity.distanceM)}</p>
</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 className="border-t border-zinc-100 my-4"/>
{/* 2-column metrics grid – easy to extend by adding to the metrics array */}
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
{metrics.map((metric) => (
<MetricCell key={metric.label} {...metric}/>
))}
</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">
{/* Footer – always full-width */}
<div className="px-5 pb-5 pt-4">
<button className="w-full py-3 rounded-lg text-sm font-bold text-white bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 transition-all shadow-sm hover:shadow-md">
View Details →
</button>
</div>