Dashboard

This commit is contained in:
2026-04-20 07:06:56 +02:00
parent 32da8f32e7
commit 7198b3737c
4 changed files with 200 additions and 25 deletions

View File

@@ -1,11 +1,20 @@
import 'leaflet/dist/leaflet.css'
import L from 'leaflet'
import {format} from 'date-fns'
import {useEffect, useRef} from 'react'
import {useEffect, useRef, useState} from 'react'
import type {ActivityMapStreamPointDo} from '@/types/ActivityMapStreamPointDo'
import type {ActivityStreamPointDo} from '@/types/ActivityStreamPointDo'
import type {IntervalsActivityDo} from '@/types/IntervalsActivityDo'
import {formatDistance, formatDuration, formatPace, formatSpeed, sportIcon} from '@/utils/activity'
type PointInfo = {
speed: number
power: number
hr: number
grade: number
distance: number
} | null
function isBike(type: string): boolean {
const lowered = type.toLowerCase()
return lowered.includes('ride') || lowered.includes('cycling')
@@ -22,9 +31,24 @@ function tssColor(tss: number): string {
return 'text-red-600'
}
function ActivityRouteMap({points}: {points: ActivityMapStreamPointDo[]}) {
function ActivityRouteMap({
points,
timeSeries,
isRun,
isBike,
}: {
points: ActivityMapStreamPointDo[]
timeSeries: ActivityStreamPointDo[]
isRun: boolean
isBike: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markerRef = useRef<L.CircleMarker | null>(null)
const [sliderIdx, setSliderIdx] = useState(0)
const sliderMax = Math.max(points.length - 1, 0)
const boundedIdx = Math.min(sliderIdx, sliderMax)
useEffect(() => {
if (!containerRef.current || points.length === 0) return
@@ -45,14 +69,95 @@ function ActivityRouteMap({points}: {points: ActivityMapStreamPointDo[]}) {
const latLngs = points.map((p) => L.latLng(p.lat, p.lng))
const polyline = L.polyline(latLngs, {color: '#f97316', weight: 5, opacity: 1}).addTo(map)
map.fitBounds(polyline.getBounds(), {padding: [24, 24]})
markerRef.current = L.circleMarker([points[0].lat, points[0].lng], {
radius: 7,
color: '#f97316',
weight: 3,
fillColor: '#ffffff',
fillOpacity: 1,
opacity: 1,
}).addTo(map)
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
markerRef.current = null
}
}, [points])
return <div ref={containerRef} className="w-full h-full"/>
useEffect(() => {
if (!markerRef.current || boundedIdx >= points.length) return
const pt = points[boundedIdx]
markerRef.current.setLatLng([pt.lat, pt.lng])
}, [boundedIdx, points])
const tsPoint = timeSeries.length > 0
? timeSeries[Math.min(boundedIdx, timeSeries.length - 1)]
: null
const pointInfo: PointInfo = tsPoint ? {
speed: tsPoint.velocityMps ?? 0,
power: tsPoint.watts ?? 0,
hr: tsPoint.heartrateBpm ?? 0,
grade: tsPoint.gradeSmooth ?? 0,
distance: tsPoint.distanceM ?? 0,
} : null
return (
<div className="flex flex-col w-full h-full">
<div className="relative flex-1 min-h-0">
<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="bg-white/95 backdrop-blur-sm border border-zinc-200 rounded-xl shadow-lg w-[130px] overflow-hidden">
<div className="px-3 py-1.5 bg-gradient-to-r from-red-500 to-orange-500">
<p className="text-[9px] font-bold tracking-widest text-white/80 uppercase">At Point</p>
</div>
<div className="px-3 py-2 space-y-2.5">
<HoverRow
label={isRun ? 'Pace' : 'Speed'}
value={isRun ? formatPace(pointInfo.speed) : formatSpeed(pointInfo.speed)}
/>
<HoverRow
label="Distance"
value={formatDistance(pointInfo.distance)}
/>
{isBike && pointInfo.power > 0 && (
<HoverRow label="Power" value={`${Math.round(pointInfo.power)} W`}/>
)}
{pointInfo.hr > 0 && (
<HoverRow label="HR" value={`${Math.round(pointInfo.hr)} bpm`}/>
)}
</div>
</div>
</div>
)}
</div>
{sliderMax > 0 && (
<div className="px-4 py-2 bg-white border-t border-zinc-100 shrink-0">
<input
type="range"
min={0}
max={sliderMax}
value={boundedIdx}
onChange={(e) => setSliderIdx(Number(e.target.value))}
className="w-full accent-orange-500 cursor-pointer"
/>
</div>
)}
</div>
)
}
function HoverRow({label, value}: {label: string; value: string}) {
return (
<div>
<p className="text-[9px] font-semibold tracking-widest text-zinc-400 uppercase">{label}</p>
<p className="text-sm font-bold text-zinc-800 tabular-nums leading-tight">{value}</p>
</div>
)
}
type MetricItem = {
@@ -73,9 +178,11 @@ function MetricCell({label, value, colorClass = 'text-zinc-800'}: MetricItem) {
export function LastWorkoutCard({
activity,
mapStream,
timeSeries = [],
}: {
activity: IntervalsActivityDo
mapStream: ActivityMapStreamPointDo[] | null
timeSeries?: ActivityStreamPointDo[]
}) {
const startDate = new Date(activity.startTime)
const dateLabel = format(startDate, 'EEE d MMM yyyy · HH:mm')
@@ -124,7 +231,12 @@ export function LastWorkoutCard({
{/* Left: map or indoor placeholder */}
<div className="h-[280px] lg:h-[420px]">
{hasMap ? (
<ActivityRouteMap points={mapStream!}/>
<ActivityRouteMap
points={mapStream!}
timeSeries={timeSeries}
isRun={isRun(activity.type)}
isBike={isBike(activity.type)}
/>
) : (
<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>

View File

@@ -9,6 +9,7 @@ import {
isToday,
startOfISOWeek,
} from 'date-fns'
import {useState} from 'react'
import {ChevronLeft, ChevronRight} from 'lucide-react'
import {useSearchParams} from 'react-router-dom'
import {Area, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts'
@@ -234,7 +235,17 @@ function buildLoadData(activities: IntervalsActivityDo[], anchor: Date): LoadPoi
// ── DayCard ───────────────────────────────────────────────────────────────────
function DayCard({day, activities}: { day: Date; activities: IntervalsActivityDo[] }) {
function DayCard({
day,
activities,
selectedActivityId,
onActivityClick,
}: {
day: Date
activities: IntervalsActivityDo[]
selectedActivityId: number | null
onActivityClick: (activity: IntervalsActivityDo) => void
}) {
const today = isToday(day)
const dayActivities = activities.filter((a) => isSameDay(new Date(a.startTime), day))
return (
@@ -258,17 +269,32 @@ function DayCard({day, activities}: { day: Date; activities: IntervalsActivityDo
<p className="text-xs text-zinc-300">—</p>
) : (
<ul className="space-y-1">
{dayActivities.map((a) => (
<li key={a.originId} className="flex items-center gap-1.5">
<span className="text-sm leading-none">{sportIcon(a.type)}</span>
<div className="min-w-0">
<p className="text-xs font-medium text-zinc-700 truncate leading-none">{a.name}</p>
<p className="text-xs text-zinc-400">
{formatDistance(a.distanceM)} · {formatDuration(a.movingTimeS)}
</p>
</div>
</li>
))}
{dayActivities.map((a) => {
const isSelected = a.id === selectedActivityId
return (
<li
key={a.originId}
onClick={() => onActivityClick(a)}
className={[
'flex items-center gap-1.5 rounded px-1 py-0.5 cursor-pointer transition-colors',
isSelected
? 'bg-orange-50 ring-1 ring-orange-300'
: 'hover:bg-zinc-50',
].join(' ')}
>
<span className="text-sm leading-none">{sportIcon(a.type)}</span>
<div className="min-w-0">
<p className={[
'text-xs font-medium truncate leading-none',
isSelected ? 'text-orange-600' : 'text-zinc-700',
].join(' ')}>{a.name}</p>
<p className="text-xs text-zinc-400">
{formatDistance(a.distanceM)} · {formatDuration(a.movingTimeS)}
</p>
</div>
</li>
)
})}
</ul>
)}
</div>
@@ -343,7 +369,10 @@ export function Dashboard({athleteId}: { athleteId: number }) {
const activities = dashboardData?.activities ?? []
const goals = dashboardData?.goals ?? []
const goalMetricPeriodTotals = dashboardData?.goalMetricPeriodTotals ?? []
const lastActivityMapStream = dashboardData?.lastActivityMapStream ?? null
const weekActivityMapStreams = dashboardData?.weekActivityMapStreams ?? {}
const weekActivityStreams = dashboardData?.weekActivityStreams ?? {}
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null)
if (isLoading) {
return (
@@ -377,6 +406,18 @@ export function Dashboard({athleteId}: { athleteId: number }) {
const lastWorkout = [...activities]
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())[0]
const selectedActivity = (selectedActivityId !== null
? activities.find((a) => a.id === selectedActivityId)
: undefined) ?? lastWorkout
const selectedMapStream = selectedActivity?.id != null
? (weekActivityMapStreams[String(selectedActivity.id)] ?? null)
: null
const selectedTimeSeries = selectedActivity?.id != null
? (weekActivityStreams[String(selectedActivity.id)] ?? [])
: []
const latest = [...activities]
.filter((a) => new Date(a.startTime) <= wEnd)
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())[0]
@@ -457,12 +498,24 @@ export function Dashboard({athleteId}: { athleteId: number }) {
</section>
{/* ── Section 2: Last Workout ────────────────────────────────────────── */}
{lastWorkout && (
{selectedActivity && (
<section className="mb-10">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-4">
Last Workout
</p>
<LastWorkoutCard activity={lastWorkout} mapStream={lastActivityMapStream}/>
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
{selectedActivityId !== null && selectedActivity !== lastWorkout
? 'Selected Activity'
: 'Last Workout'}
</p>
{selectedActivityId !== null && selectedActivity !== lastWorkout && (
<button
onClick={() => setSelectedActivityId(null)}
className="text-xs text-zinc-400 hover:text-zinc-600 transition-colors"
>
← Back to last workout
</button>
)}
</div>
<LastWorkoutCard activity={selectedActivity} mapStream={selectedMapStream} timeSeries={selectedTimeSeries}/>
</section>
)}
@@ -477,7 +530,13 @@ export function Dashboard({athleteId}: { athleteId: number }) {
</p>
<div className="grid grid-cols-7 gap-2 mb-8">
{weekDays.map((day) => (
<DayCard key={day.toISOString()} day={day} activities={selectedWeekActivities}/>
<DayCard
key={day.toISOString()}
day={day}
activities={selectedWeekActivities}
selectedActivityId={selectedActivityId}
onActivityClick={(activity) => setSelectedActivityId(activity.id ?? null)}
/>
))}
</div>

View File

@@ -13,6 +13,6 @@ export interface DashboardDo extends AbstractDataObjectDo {
activities: IntervalsActivityDo[];
goals: GoalDo[];
goalMetricPeriodTotals: GoalMetricPeriodTotalDo[];
weekActivityMapStreams: unknown;
weekActivityStreams: unknown;
weekActivityMapStreams: Record<string, ActivityMapStreamPointDo[]>;
weekActivityStreams: Record<string, ActivityStreamPointDo[]>;
}

View File

@@ -245,6 +245,10 @@ public class TypeScriptClientGeneratorMojo extends AbstractMojo {
final Type[] args = parameterizedType.getActualTypeArguments();
if (args.length == 1) return toTsType(args[0]) + "[]";
}
if (rawName.equals("java.util.Map")) {
final Type[] args = parameterizedType.getActualTypeArguments();
if (args.length == 2) return "Record<string, " + toTsType(args[1]) + ">";
}
}
return "unknown";
}