Dashboard
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 & Form (CTL / ATL / TSB)
|
||||
</p>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user