663 lines
30 KiB
TypeScript
663 lines
30 KiB
TypeScript
import {
|
|
addWeeks,
|
|
eachDayOfInterval,
|
|
endOfISOWeek,
|
|
format,
|
|
getISOWeek,
|
|
getISOWeekYear,
|
|
isSameDay,
|
|
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'
|
|
import type {MouseHandlerDataParam} from 'recharts'
|
|
import {useDashboardGetDashboard} from '@/api/hooks'
|
|
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, sportIcon} from '@/utils/activity'
|
|
import {LastWorkoutCard} from '@/components/dashboard/LastWorkoutCard'
|
|
|
|
// ── Week helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function weekStart(year: number, week: number): Date {
|
|
const jan4 = new Date(year, 0, 4)
|
|
const startW1 = startOfISOWeek(jan4)
|
|
return addWeeks(startW1, week - 1)
|
|
}
|
|
|
|
// ── 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 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>
|
|
)
|
|
}
|
|
|
|
// ── ProgressBar ───────────────────────────────────────────────────────────────
|
|
|
|
function ProgressBar({value, max}: { value: number; max: number }) {
|
|
const pct = Math.min(100, (value / max) * 100)
|
|
return (
|
|
<div className="h-1.5 bg-zinc-100 rounded-sm overflow-hidden">
|
|
<div className="gradient-bar h-full transition-all" style={{width: `${pct}%`}}/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Goal helpers ──────────────────────────────────────────────────────────────
|
|
|
|
type Metric = GoalMetricEnum
|
|
type Actuals = Record<Metric, number>
|
|
|
|
function secondsToHhmm(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
}
|
|
|
|
const METRIC_LABEL: Record<Metric, string> = {
|
|
DISTANCE: 'Distance',
|
|
TSS: 'TSS',
|
|
ELEVATION: 'Elevation',
|
|
TIME: 'Time',
|
|
}
|
|
|
|
function formatGoalValue(metric: Metric, value: number): string {
|
|
switch (metric) {
|
|
case 'DISTANCE':
|
|
return `${Math.round(value)} km`
|
|
case 'TSS':
|
|
return String(Math.round(value))
|
|
case 'ELEVATION':
|
|
return `${Math.round(value)} m`
|
|
case 'TIME':
|
|
return secondsToHhmm(value)
|
|
default:
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
function buildActualsFromTotals(
|
|
totals: GoalMetricPeriodTotalDo[],
|
|
period: GoalPeriodEnum,
|
|
year: number,
|
|
calendarWeek: number,
|
|
): Actuals {
|
|
const relevant = totals.filter(
|
|
(t) => t.period === period && t.year === year && t.calendarWeek === calendarWeek,
|
|
)
|
|
const result: Actuals = {DISTANCE: 0, TSS: 0, ELEVATION: 0, TIME: 0}
|
|
for (const t of relevant) {
|
|
result[t.metric] = t.metric === 'DISTANCE' ? t.aggregatedValue / 1000 : t.aggregatedValue
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ── GoalRow ───────────────────────────────────────────────────────────────────
|
|
|
|
function GoalRow({
|
|
label,
|
|
currentDisplay,
|
|
targetDisplay,
|
|
currentRaw,
|
|
targetRaw,
|
|
}: {
|
|
label: string
|
|
currentDisplay: string
|
|
targetDisplay: string
|
|
currentRaw: number
|
|
targetRaw: number
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-end justify-between">
|
|
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">{label}</p>
|
|
<p className="text-xs text-zinc-400">
|
|
<span className="text-zinc-900 font-semibold">{currentDisplay}</span>
|
|
{' / '}
|
|
{targetDisplay}
|
|
</p>
|
|
</div>
|
|
<ProgressBar value={currentRaw} max={targetRaw}/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── GoalPeriodColumn ──────────────────────────────────────────────────────────
|
|
|
|
function GoalPeriodColumn({
|
|
title,
|
|
subtitle,
|
|
goals,
|
|
actuals,
|
|
}: {
|
|
title: string
|
|
subtitle: string
|
|
goals: GoalDo[]
|
|
actuals: Actuals
|
|
}) {
|
|
return (
|
|
<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>
|
|
</div>
|
|
{goals.length === 0 ? (
|
|
<p className="text-xs text-zinc-300">No goals set</p>
|
|
) : (
|
|
goals.map((goal) => (
|
|
<GoalRow
|
|
key={goal.metric}
|
|
label={METRIC_LABEL[goal.metric]}
|
|
currentDisplay={formatGoalValue(goal.metric, actuals[goal.metric])}
|
|
targetDisplay={formatGoalValue(goal.metric, goal.targetValue)}
|
|
currentRaw={actuals[goal.metric]}
|
|
targetRaw={goal.targetValue}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── TSS / load chart ──────────────────────────────────────────────────────────
|
|
|
|
type LoadPoint = {
|
|
week: string
|
|
weekNumber: number
|
|
weekYear: number
|
|
tss: number
|
|
ctl: number | null
|
|
atl: number | null
|
|
tsb: number | null
|
|
}
|
|
|
|
function buildLoadData(activities: IntervalsActivityDo[], anchor: Date): LoadPoint[] {
|
|
const anchorWeekStart = startOfISOWeek(anchor)
|
|
const weekMondays: Date[] = Array.from({length: 12}, (_, i) =>
|
|
addWeeks(anchorWeekStart, i - 11),
|
|
)
|
|
const tssMap: Record<number, number> = {}
|
|
activities.forEach((a) => {
|
|
if (a.trainingLoad <= 0) return
|
|
const monday = startOfISOWeek(new Date(a.startTime))
|
|
const key = monday.getTime()
|
|
tssMap[key] = (tssMap[key] ?? 0) + Math.round(a.trainingLoad)
|
|
})
|
|
const sorted = [...activities].sort(
|
|
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
|
)
|
|
let lastCtl: number | null = null
|
|
let lastAtl: number | null = null
|
|
return weekMondays.map((monday) => {
|
|
const weekEnd = endOfISOWeek(monday)
|
|
const key = monday.getTime()
|
|
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
if (new Date(sorted[i].startTime) <= weekEnd) {
|
|
lastCtl = Math.round(sorted[i].ctl)
|
|
lastAtl = Math.round(sorted[i].atl)
|
|
break
|
|
}
|
|
}
|
|
const tsb = lastCtl !== null && lastAtl !== null ? lastCtl - lastAtl : null
|
|
const weekNumber = getISOWeek(monday)
|
|
const weekYear = getISOWeekYear(monday)
|
|
return {week: `CW ${weekNumber}`, weekNumber, weekYear, tss: tssMap[key] ?? 0, ctl: lastCtl, atl: lastAtl, tsb}
|
|
})
|
|
}
|
|
|
|
// ── DayCard ───────────────────────────────────────────────────────────────────
|
|
|
|
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 (
|
|
<div
|
|
className={[
|
|
'border rounded-sm p-3 min-h-[90px]',
|
|
today
|
|
? '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 tracking-widest uppercase mb-2',
|
|
today ? 'font-bold gradient-text' : 'font-semibold text-zinc-400',
|
|
].join(' ')}
|
|
>
|
|
{format(day, 'EEE d')}
|
|
</p>
|
|
{dayActivities.length === 0 ? (
|
|
<p className="text-xs text-zinc-300">—</p>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{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>
|
|
)
|
|
}
|
|
|
|
// ── LoadTooltip ───────────────────────────────────────────────────────────────
|
|
|
|
const UNIT: Record<string, string> = {
|
|
TSS: 'TSS',
|
|
CTL: 'TSS/day',
|
|
ATL: 'TSS/day',
|
|
TSB: 'pts',
|
|
}
|
|
|
|
function LoadTooltip({
|
|
active,
|
|
payload,
|
|
label,
|
|
}: {
|
|
active?: boolean
|
|
payload?: Array<{ name: string; value: number; color: string }>
|
|
label?: string
|
|
}) {
|
|
if (!active || !payload?.length) return null
|
|
return (
|
|
<div className="bg-white border border-zinc-200 rounded-sm px-3 py-2 shadow-sm text-xs">
|
|
<p className="font-semibold text-zinc-500 mb-1">{label}</p>
|
|
{payload.map((p) => (
|
|
<p key={p.name} style={{color: p.color}}>
|
|
{p.name}: <span className="font-bold">{p.value}</span> {UNIT[p.name] ?? ''}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
|
|
export function Dashboard({athleteId}: { athleteId: number }) {
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
|
|
const now = new Date()
|
|
const currentISOWeek = getISOWeek(now)
|
|
const currentISOYear = getISOWeekYear(now)
|
|
|
|
const year = Number(searchParams.get('year')) || currentISOYear
|
|
const week = Number(searchParams.get('week')) || currentISOWeek
|
|
const isCurrentWeek = year === currentISOYear && week === currentISOWeek
|
|
|
|
function goWeek(delta: number) {
|
|
const base = weekStart(year, week)
|
|
const target = addWeeks(base, delta)
|
|
setSearchParams({year: String(getISOWeekYear(target)), week: String(getISOWeek(target))})
|
|
}
|
|
|
|
function goToday() {
|
|
setSearchParams({year: String(currentISOYear), week: String(currentISOWeek)})
|
|
}
|
|
|
|
function handleChartClick(data: LoadPoint[]) {
|
|
return (chartData: MouseHandlerDataParam) => {
|
|
const index = chartData.activeTooltipIndex
|
|
if (typeof index !== 'number') return
|
|
const point = data[index]
|
|
if (!point) return
|
|
setSearchParams({year: String(point.weekYear), week: String(point.weekNumber)})
|
|
}
|
|
}
|
|
|
|
const {data: dashboardData, isLoading} = useDashboardGetDashboard(athleteId, week, year)
|
|
const activities = dashboardData?.activities ?? []
|
|
const goals = dashboardData?.goals ?? []
|
|
const goalMetricPeriodTotals = dashboardData?.goalMetricPeriodTotals ?? []
|
|
const weekActivityMapStreams = dashboardData?.weekActivityMapStreams ?? {}
|
|
const weekActivityStreams = dashboardData?.weekActivityStreams ?? {}
|
|
|
|
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="w-6 h-6 rounded-full border-2 border-zinc-200 border-t-red-500 animate-spin"/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Week bounds (shared) ────────────────────────────────────────────────────
|
|
|
|
const wStart = weekStart(year, week)
|
|
const wEnd = endOfISOWeek(wStart)
|
|
const weekDays = eachDayOfInterval({start: wStart, end: wEnd})
|
|
|
|
// ── Actuals ─────────────────────────────────────────────────────────────────
|
|
|
|
const weekActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'WEEKLY', year, week)
|
|
const monthActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'MONTHLY', year, week)
|
|
const yearActuals = buildActualsFromTotals(goalMetricPeriodTotals, 'YEARLY', year, week)
|
|
|
|
const weeklyGoals = goals.filter((g) => g.period === 'WEEKLY')
|
|
const monthlyGoals = goals.filter((g) => g.period === 'MONTHLY')
|
|
const yearlyGoals = goals.filter((g) => g.period === 'YEARLY')
|
|
|
|
const selectedWeekActivities = activities.filter((a) => {
|
|
const t = new Date(a.startTime)
|
|
return t >= wStart && t <= wEnd
|
|
})
|
|
|
|
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]
|
|
|
|
const ctl = latest?.ctl?.toFixed(0) ?? '—'
|
|
const atl = latest?.atl?.toFixed(0) ?? '—'
|
|
const tsb = latest ? ((latest.ctl ?? 0) - (latest.atl ?? 0)).toFixed(0) : '—'
|
|
|
|
const loadData = buildLoadData(activities, wStart)
|
|
|
|
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-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>
|
|
<h2 className="text-2xl font-light text-zinc-900 tracking-tight">Dashboard</h2>
|
|
</div>
|
|
|
|
{/* ── Section 1: Training Goals ───────────────────────────────────────── */}
|
|
<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
|
|
</p>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => goWeek(-1)}
|
|
className="p-1 rounded-sm text-zinc-400 hover:text-zinc-700 transition-colors"
|
|
>
|
|
<ChevronLeft size={14}/>
|
|
</button>
|
|
<span className="text-xs font-semibold text-zinc-700 px-2 min-w-[110px] text-center">
|
|
CW {week} · {year}
|
|
</span>
|
|
<button
|
|
onClick={() => goWeek(1)}
|
|
disabled={isCurrentWeek}
|
|
className="p-1 rounded-sm text-zinc-400 hover:text-zinc-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronRight size={14}/>
|
|
</button>
|
|
<button
|
|
onClick={goToday}
|
|
disabled={isCurrentWeek}
|
|
className="ml-2 px-2 py-1 text-xs font-semibold tracking-widest border border-zinc-200 rounded-sm transition-colors text-zinc-400 hover:enabled:text-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
TODAY
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<GoalPeriodColumn
|
|
title="Weekly"
|
|
subtitle={`CW ${week} · ${year}`}
|
|
goals={weeklyGoals}
|
|
actuals={weekActuals}
|
|
/>
|
|
<GoalPeriodColumn
|
|
title="Monthly"
|
|
subtitle={format(wStart, 'MMMM yyyy')}
|
|
goals={monthlyGoals}
|
|
actuals={monthActuals}
|
|
/>
|
|
<GoalPeriodColumn
|
|
title="Yearly"
|
|
subtitle={String(wStart.getFullYear())}
|
|
goals={yearlyGoals}
|
|
actuals={yearActuals}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Section 2: Last Workout ────────────────────────────────────────── */}
|
|
{selectedActivity && (
|
|
<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
|
|
? '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>
|
|
)}
|
|
|
|
{/* ── Section 3: This Week's Training ────────────────────────────────── */}
|
|
<section className="mb-12">
|
|
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-5">
|
|
Daily Breakdown
|
|
</p>
|
|
<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 */}
|
|
<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 mb-4 shadow-sm">
|
|
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-5">
|
|
Weekly Training Load (TSS)
|
|
</p>
|
|
{loadData.some((d) => d.tss > 0) ? (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<ComposedChart data={loadData} margin={{left: -20, right: 10}} onClick={handleChartClick(loadData)} style={{cursor: 'pointer'}}>
|
|
<defs>
|
|
<linearGradient id="tssGrad" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stopColor="#ef4444"/>
|
|
<stop offset="100%" stopColor="#f97316"/>
|
|
</linearGradient>
|
|
<linearGradient id="tssAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.12}/>
|
|
<stop offset="100%" stopColor="#f97316" stopOpacity={0}/>
|
|
</linearGradient>
|
|
</defs>
|
|
<XAxis dataKey="week" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false}
|
|
tickLine={false}/>
|
|
<YAxis tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false} tickLine={false}/>
|
|
<Tooltip content={<LoadTooltip/>}/>
|
|
<Area type="monotone" dataKey="tss" fill="url(#tssAreaGrad)" stroke="none"/>
|
|
<Line type="monotone" dataKey="tss" stroke="url(#tssGrad)" strokeWidth={2}
|
|
dot={false} name="TSS"/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-[200px] flex items-center justify-center text-zinc-400 text-sm">
|
|
No training data yet
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* CTL / ATL / TSB chart */}
|
|
<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>
|
|
{ctlLoadData.some((d) => d.ctl !== null) ? (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<ComposedChart data={ctlLoadData} margin={{left: -20, right: 0}} onClick={handleChartClick(ctlLoadData)} style={{cursor: 'pointer'}}>
|
|
<defs>
|
|
<linearGradient id="tssAreaGrad2" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.12}/>
|
|
<stop offset="100%" stopColor="#f97316" stopOpacity={0}/>
|
|
</linearGradient>
|
|
</defs>
|
|
<XAxis dataKey="week" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false}
|
|
tickLine={false} padding={{left: 20, right: 20}}/>
|
|
<YAxis yAxisId="left" domain={ctlDomain} tick={{fontSize: 10, fill: '#a1a1aa'}}
|
|
axisLine={false} tickLine={false}/>
|
|
<YAxis yAxisId="right" orientation="right" tick={{fontSize: 10, fill: '#a1a1aa'}}
|
|
axisLine={false} tickLine={false}/>
|
|
<Tooltip content={<LoadTooltip/>}/>
|
|
<Area yAxisId="left" type="monotone" dataKey="ctl" fill="url(#tssAreaGrad2)"
|
|
stroke="none" connectNulls/>
|
|
<Line yAxisId="left" type="monotone" dataKey="ctl" stroke="#ef4444"
|
|
strokeWidth={2} dot={false} name="CTL" connectNulls/>
|
|
<Line yAxisId="left" type="monotone" dataKey="atl" stroke="#fdba74" strokeWidth={1.5}
|
|
strokeDasharray="5 3" dot={false} name="ATL" connectNulls/>
|
|
<Line yAxisId="right" type="monotone" dataKey="tsb" stroke="#52525b" strokeWidth={1.5}
|
|
strokeDasharray="3 3" dot={false} name="TSB" connectNulls/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<div className="h-[200px] flex items-center justify-center text-zinc-400 text-sm">
|
|
No fitness data yet
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|