Dashboard
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user