Last workout
This commit is contained in:
191
peakup-frontend/src/components/dashboard/LastWorkoutCard.tsx
Normal file
191
peakup-frontend/src/components/dashboard/LastWorkoutCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user