Last workout
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user