Dashboard

This commit is contained in:
2026-04-20 07:30:48 +02:00
parent 7198b3737c
commit 63f8ca1104
3 changed files with 113 additions and 55 deletions

View File

@@ -49,6 +49,7 @@ function ActivityRouteMap({
const sliderMax = Math.max(points.length - 1, 0)
const boundedIdx = Math.min(sliderIdx, sliderMax)
const totalDistanceM = timeSeries.length > 0 ? (timeSeries[timeSeries.length - 1]?.distanceM ?? 0) : 0
useEffect(() => {
if (!containerRef.current || points.length === 0) return
@@ -111,11 +112,12 @@ function ActivityRouteMap({
<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 className="bg-white/95 backdrop-blur-sm rounded-xl shadow-lg w-[152px] border-t border-r border-b border-zinc-200 border-l-[3px] border-l-orange-400">
<div className="px-4 pt-3 pb-2">
<p className="text-[9px] font-bold tracking-widest text-orange-500 uppercase">At Point</p>
<div className="mt-2 h-px bg-zinc-100"/>
</div>
<div className="px-3 py-2 space-y-2.5">
<div className="px-4 pb-3 space-y-3">
<HoverRow
label={isRun ? 'Pace' : 'Speed'}
value={isRun ? formatPace(pointInfo.speed) : formatSpeed(pointInfo.speed)}
@@ -136,14 +138,39 @@ function ActivityRouteMap({
)}
</div>
{sliderMax > 0 && (
<div className="px-4 py-2 bg-white border-t border-zinc-100 shrink-0">
<div className="px-5 pt-3 pb-4 bg-white border-t border-zinc-100 shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-semibold tabular-nums text-zinc-700">
{formatDistance(tsPoint?.distanceM ?? 0)}
</span>
<span className="text-[10px] tabular-nums text-zinc-400">
{formatDistance(totalDistanceM)}
</span>
</div>
<input
type="range"
min={0}
max={sliderMax}
value={boundedIdx}
onChange={(e) => setSliderIdx(Number(e.target.value))}
className="w-full accent-orange-500 cursor-pointer"
className="w-full cursor-pointer accent-orange-500
[&::-webkit-slider-runnable-track]:h-2
[&::-webkit-slider-runnable-track]:rounded-full
[&::-webkit-slider-runnable-track]:bg-zinc-200
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:h-5
[&::-webkit-slider-thumb]:w-5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-orange-500
[&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:-mt-1.5
[&::-moz-range-track]:h-2
[&::-moz-range-track]:rounded-full
[&::-moz-range-thumb]:h-5
[&::-moz-range-thumb]:w-5
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:border-0
[&::-moz-range-thumb]:bg-orange-500"
/>
</div>
)}
@@ -246,11 +273,11 @@ export function LastWorkoutCard({
</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">
<div className="flex flex-col justify-between p-6 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>
<p className="text-[10px] font-semibold tracking-widest text-zinc-400 uppercase mb-1.5">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>
@@ -258,12 +285,14 @@ export function LastWorkoutCard({
</div>
</div>
<div className="border-t border-zinc-100 my-4"/>
<div className="border-t border-zinc-100 my-5"/>
{/* 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}/>
{/* 2-column metrics grid */}
<div className="grid grid-cols-2 gap-x-5 gap-y-5">
{metrics.map((metric, i) => (
<div key={metric.label} className={i >= 2 ? 'pt-4 border-t border-zinc-100' : ''}>
<MetricCell {...metric}/>
</div>
))}
</div>
</div>

View File

@@ -33,27 +33,42 @@ function weekStart(year: number, week: number): Date {
// ── MetricCard ────────────────────────────────────────────────────────────────
function TrendArrow({delta}: { delta: number }) {
if (delta === 0) return null
const up = delta > 0
return (
<span className={['text-xs font-semibold ml-2 align-middle', up ? 'text-emerald-500' : 'text-red-400'].join(' ')}>
{up ? '▲' : '▼'} {Math.abs(delta)}
</span>
)
}
function MetricCard({
label,
value,
unit,
sub,
valueColor,
className,
trend,
}: {
label: string
value: string | number
unit?: string
sub?: string
valueColor?: string
className?: string
trend?: number | null
}) {
return (
<div className="border border-zinc-200 rounded-sm p-5 bg-white">
<div className={['border rounded-sm p-5', className ?? 'border-zinc-200 bg-white'].join(' ')}>
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-3">
{label}
</p>
<p className="text-3xl font-bold leading-none" style={{color: valueColor ?? '#18181b'}}>
{value}
{unit && <span className="text-sm font-normal text-zinc-400 ml-1">{unit}</span>}
{trend != null && <TrendArrow delta={trend}/>}
</p>
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
</div>
@@ -164,7 +179,7 @@ function GoalPeriodColumn({
actuals: Actuals
}) {
return (
<div className="border border-zinc-200 rounded-sm bg-white p-5 flex flex-col gap-4">
<div className="border border-zinc-200 rounded-sm bg-white p-5 flex flex-col gap-4 shadow-sm min-h-[140px]">
<div>
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">{title}</p>
<p className="text-xs text-zinc-400 mt-0.5">{subtitle}</p>
@@ -253,14 +268,14 @@ function DayCard({
className={[
'border rounded-sm p-3 min-h-[90px]',
today
? 'bg-zinc-50 border-l-2 border-l-orange-500 border-t-zinc-200 border-r-zinc-200 border-b-zinc-200'
: 'bg-white border-zinc-200',
? 'bg-orange-50 shadow-md border-t-zinc-200 border-r-zinc-200 border-b-zinc-200 border-l-4 border-l-orange-400'
: 'bg-white border-zinc-200 shadow-sm',
].join(' ')}
>
<p
className={[
'text-xs font-semibold tracking-widest uppercase mb-2',
today ? 'gradient-text' : 'text-zinc-400',
'text-xs tracking-widest uppercase mb-2',
today ? 'font-bold gradient-text' : 'font-semibold text-zinc-400',
].join(' ')}
>
{format(day, 'EEE d')}
@@ -430,13 +445,24 @@ export function Dashboard({athleteId}: { athleteId: number }) {
const ctlLoadData = loadData.slice(loadData.findIndex(d => d.ctl !== null))
const nonNullCtlData = loadData.filter((d) => d.ctl !== null)
const prevCtlEntry = nonNullCtlData.length >= 2 ? nonNullCtlData[nonNullCtlData.length - 2] : null
const currentCtlEntry = nonNullCtlData.length >= 1 ? nonNullCtlData[nonNullCtlData.length - 1] : null
const ctlTrend = currentCtlEntry?.ctl != null && prevCtlEntry?.ctl != null
? currentCtlEntry.ctl - prevCtlEntry.ctl
: null
const atlTrend = currentCtlEntry?.atl != null && prevCtlEntry?.atl != null
? currentCtlEntry.atl - prevCtlEntry.atl
: null
const tsbTrend = ctlTrend != null && atlTrend != null ? ctlTrend - atlTrend : null
const ctlValues = ctlLoadData.map(d => d.ctl).filter((v): v is number => v !== null)
const ctlDomain: [number, number] | ['auto', 'auto'] = ctlValues.length
? [Math.floor(Math.min(...ctlValues) * 0.9), Math.ceil(Math.max(...ctlValues) * 1.1)]
: ['auto', 'auto']
return (
<div className="flex-1 overflow-y-auto bg-zinc-50 p-8">
<div className="flex-1 overflow-y-auto bg-zinc-50 p-4 md:p-8">
{/* Page header */}
<div className="mb-8">
<h1 className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-1">Overview</h1>
@@ -444,7 +470,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
</div>
{/* ── Section 1: Training Goals ───────────────────────────────────────── */}
<section className="mb-10">
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
Training Goals
@@ -475,7 +501,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<GoalPeriodColumn
title="Weekly"
subtitle={`CW ${week} · ${year}`}
@@ -499,7 +525,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
{/* ── Section 2: Last Workout ────────────────────────────────────────── */}
{selectedActivity && (
<section className="mb-10">
<section className="mb-12">
<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
@@ -520,45 +546,47 @@ export function Dashboard({athleteId}: { athleteId: number }) {
)}
{/* ── Section 3: This Week's Training ────────────────────────────────── */}
<section className="mb-8">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-4">
This Week's Training
</p>
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-4">
<section className="mb-12">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-5">
Daily Breakdown
</p>
<div className="grid grid-cols-7 gap-2 mb-8">
{weekDays.map((day) => (
<DayCard
key={day.toISOString()}
day={day}
activities={selectedWeekActivities}
selectedActivityId={selectedActivityId}
onActivityClick={(activity) => setSelectedActivityId(activity.id ?? null)}
/>
))}
<div className="overflow-x-auto -mx-4 px-4 md:mx-0 md:px-0 mb-10">
<div className="grid grid-cols-7 gap-3 min-w-[560px]">
{weekDays.map((day) => (
<DayCard
key={day.toISOString()}
day={day}
activities={selectedWeekActivities}
selectedActivityId={selectedActivityId}
onActivityClick={(activity) => setSelectedActivityId(activity.id ?? null)}
/>
))}
</div>
</div>
{/* Fitness State */}
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-4">
Fitness State
</p>
<div className="grid grid-cols-3 gap-3 mb-8">
<MetricCard label="Fitness (CTL)" value={ctl} unit="TSS/day" sub="Chronic Training Load"
valueColor="#ef4444"/>
<MetricCard label="Fatigue (ATL)" value={atl} unit="TSS/day" sub="Acute Training Load"
valueColor="#fdba74"/>
<MetricCard
label="Form (TSB)"
value={tsb}
sub={Number(tsb) > 0 ? 'Fresh — ready to race' : 'Fatigued — building fitness'}
valueColor="#52525b"
/>
<div className="border border-zinc-200 rounded-sm bg-white shadow-sm p-5 mb-8">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-4">
Fitness State
</p>
<div className="grid grid-cols-3 gap-4">
<MetricCard label="Fitness (CTL)" value={ctl} unit="TSS/day" sub="Chronic Training Load"
valueColor="#ef4444" className="border-zinc-100 bg-zinc-50/60" trend={ctlTrend}/>
<MetricCard label="Fatigue (ATL)" value={atl} unit="TSS/day" sub="Acute Training Load"
valueColor="#fdba74" className="border-zinc-100 bg-zinc-50/60" trend={atlTrend}/>
<MetricCard
label="Form (TSB)"
value={tsb}
sub={Number(tsb) > 0 ? 'Fresh — ready to race' : 'Fatigued — building fitness'}
valueColor="#52525b"
className="border-zinc-100 bg-zinc-50/60"
trend={tsbTrend}
/>
</div>
</div>
{/* Weekly TSS chart */}
<div className="border border-zinc-200 rounded-sm bg-white p-5">
<div className="border border-zinc-200 rounded-sm bg-white p-5 mb-4 shadow-sm">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-5">
Weekly Training Load (TSS)
</p>
@@ -592,7 +620,7 @@ export function Dashboard({athleteId}: { athleteId: number }) {
</div>
{/* CTL / ATL / TSB chart */}
<div className="border border-zinc-200 rounded-sm bg-white p-5">
<div className="border border-zinc-200 rounded-sm bg-white p-5 shadow-sm">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-5">
Fitness, Fatigue &amp; Form (CTL / ATL / TSB)
</p>

View File

@@ -57,6 +57,7 @@ import java.util.stream.Stream;
* <li>{@code hooks.ts} — TanStack Query hooks</li>
* </ul>
*/
@SuppressWarnings("unused")
@Mojo(
name = "generate-typescript",
defaultPhase = LifecyclePhase.PROCESS_CLASSES,