This commit is contained in:
2026-05-15 18:27:08 +02:00
parent 57809f728a
commit 335f8069a0

View File

@@ -98,13 +98,7 @@ function ProgressBar({value, max}: { value: number; max: number }) {
// ── GoalRow ───────────────────────────────────────────────────────────────────
function GoalRow({
label,
currentDisplay,
targetDisplay,
currentRaw,
targetRaw,
}: {
function GoalRow({label, currentDisplay, targetDisplay, currentRaw, targetRaw}: {
label: string
currentDisplay: string
targetDisplay: string
@@ -128,12 +122,7 @@ function GoalRow({
// ── GoalPeriodColumn ──────────────────────────────────────────────────────────
function GoalPeriodColumn({
title,
subtitle,
goals,
actuals,
}: {
function GoalPeriodColumn({title, subtitle, goals, actuals}: {
title: string
subtitle: string
goals: GoalDo[]
@@ -165,12 +154,7 @@ function GoalPeriodColumn({
// ── DayCard ───────────────────────────────────────────────────────────────────
function DayCard({
day,
activities,
selectedActivityId,
onActivityClick,
}: {
function DayCard({day, activities, selectedActivityId, onActivityClick}: {
day: Date
activities: ActivitySummaryDo[]
selectedActivityId: number | null
@@ -221,28 +205,91 @@ function DayCard({
)
}
// ── DailyBreakdown ────────────────────────────────────────────────────────────
function DailyBreakdown({weekDays, activities, selectedActivityId, onActivityClick, initialDayIndex}: {
weekDays: Date[]
activities: ActivitySummaryDo[]
selectedActivityId: number | null
onActivityClick: (activity: ActivitySummaryDo) => void
initialDayIndex: number
}) {
const [mobileDayIndex, setMobileDayIndex] = useState(initialDayIndex)
const currentDay = weekDays[mobileDayIndex]
return (
<>
{/* Desktop: all 7 days */}
<div className="hidden lg:grid grid-cols-7 gap-2">
{weekDays.map((day) => (
<DayCard
key={day.toISOString()}
day={day}
activities={activities}
selectedActivityId={selectedActivityId}
onActivityClick={onActivityClick}
/>
))}
</div>
{/* Mobile: single card with prev/next navigation */}
<div className="lg:hidden flex flex-col items-center gap-3">
<div className="flex items-center gap-3 w-full">
<button
onClick={() => setMobileDayIndex((i) => Math.max(0, i - 1))}
disabled={mobileDayIndex === 0}
className="p-2 rounded-xl bg-white border border-zinc-100 shadow-sm text-zinc-400 hover:text-zinc-700 disabled:opacity-25 disabled:cursor-not-allowed transition-colors shrink-0"
>
<ChevronLeft size={18}/>
</button>
<div className="flex-1">
<DayCard
day={currentDay}
activities={activities}
selectedActivityId={selectedActivityId}
onActivityClick={onActivityClick}
/>
</div>
<button
onClick={() => setMobileDayIndex((i) => Math.min(weekDays.length - 1, i + 1))}
disabled={mobileDayIndex === weekDays.length - 1}
className="p-2 rounded-xl bg-white border border-zinc-100 shadow-sm text-zinc-400 hover:text-zinc-700 disabled:opacity-25 disabled:cursor-not-allowed transition-colors shrink-0"
>
<ChevronRight size={18}/>
</button>
</div>
<div className="flex items-center gap-1.5">
{weekDays.map((_, i) => (
<button
key={i}
onClick={() => setMobileDayIndex(i)}
className={[
'h-1.5 rounded-full transition-all',
i === mobileDayIndex ? 'gradient-bg w-4' : 'w-1.5 bg-zinc-200',
].join(' ')}
/>
))}
</div>
</div>
</>
)
}
// ── TrendArrow ────────────────────────────────────────────────────────────────
function TrendArrow({delta}: { delta: number }) {
function TrendArrow({delta}: {delta: number}) {
if (delta === 0) return null
const up = delta > 0
return (
<span className={['text-xs font-semibold ml-2', up ? 'text-emerald-500' : 'text-red-400'].join(' ')}>
{up ? '▲' : '▼'} {Math.abs(delta)}
</span>
{up ? '▲' : '▼'} {Math.abs(delta)}
</span>
)
}
// ── MetricCard ────────────────────────────────────────────────────────────────
function MetricCard({
label,
value,
unit,
sub,
valueColor,
trend,
}: {
function MetricCard({label, value, unit, sub, valueColor, trend}: {
label: string
value: string | number
unit?: string
@@ -310,17 +357,11 @@ function buildLoadData(activities: ActivitySummaryDo[], anchor: Date): LoadPoint
})
}
// ── LoadTooltip ───────────────────────────────────────────────────────────────
const UNIT: Record<string, string> = {TSS: 'TSS', CTL: 'TSS/day', ATL: 'TSS/day', TSB: 'pts'}
function LoadTooltip({
active,
payload,
label,
}: {
function LoadTooltip({active, payload, label}: {
active?: boolean
payload?: Array<{ name: string; value: number; color: string }>
payload?: Array<{name: string; value: number; color: string}>
label?: string
}) {
if (!active || !payload?.length) return null
@@ -359,16 +400,6 @@ export function Dashboard({athleteId}: { athleteId: number }) {
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 ?? []
@@ -445,6 +476,19 @@ export function Dashboard({athleteId}: { athleteId: number }) {
? [Math.floor(Math.min(...ctlValues) * 0.9), Math.ceil(Math.max(...ctlValues) * 1.1)]
: ['auto', 'auto']
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 todayIndex = weekDays.findIndex((d) => isToday(d))
const initialMobileDayIndex = todayIndex >= 0 ? todayIndex : 0
return (
<div className="flex-1 overflow-y-auto bg-zinc-50">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-8 flex flex-col gap-10">
@@ -478,19 +522,14 @@ export function Dashboard({athleteId}: { athleteId: number }) {
{/* ── 2. Daily Breakdown ────────────────────────────────────────────── */}
<section className="flex flex-col gap-4">
<p className="text-[11px] font-bold tracking-widest text-zinc-400 uppercase">Daily Breakdown</p>
<div className="overflow-x-auto -mx-4 px-4 md:mx-0 md:px-0">
<div className="grid grid-cols-7 gap-2 min-w-140">
{weekDays.map((day) => (
<DayCard
key={day.toISOString()}
day={day}
activities={selectedWeekActivities}
selectedActivityId={selectedActivityId}
onActivityClick={(a) => setSelectedActivityId(a.id)}
/>
))}
</div>
</div>
<DailyBreakdown
key={`${year}-${week}`}
weekDays={weekDays}
activities={selectedWeekActivities}
selectedActivityId={selectedActivityId}
onActivityClick={(a) => setSelectedActivityId(a.id)}
initialDayIndex={initialMobileDayIndex}
/>
</section>
{/* ── 3. Training Goals ─────────────────────────────────────────────── */}
@@ -506,8 +545,8 @@ export function Dashboard({athleteId}: { athleteId: number }) {
</button>
<span
className="text-xs font-semibold text-zinc-700 px-2 min-w-27.5 text-center tabular-nums">
CW {week} · {year}
</span>
CW {week} · {year}
</span>
<button
onClick={() => goWeek(1)}
disabled={isCurrentWeek}
@@ -551,22 +590,8 @@ export function Dashboard({athleteId}: { athleteId: number }) {
<p className="text-[11px] font-bold tracking-widest text-zinc-400 uppercase">Training Analytics</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<MetricCard
label="Fitness (CTL)"
value={ctl}
unit="TSS/day"
sub="Chronic Training Load"
valueColor="#ef4444"
trend={ctlTrend}
/>
<MetricCard
label="Fatigue (ATL)"
value={atl}
unit="TSS/day"
sub="Acute Training Load"
valueColor="#fdba74"
trend={atlTrend}
/>
<MetricCard label="Fitness (CTL)" value={ctl} unit="TSS/day" sub="Chronic Training Load" valueColor="#ef4444" trend={ctlTrend}/>
<MetricCard label="Fatigue (ATL)" value={atl} unit="TSS/day" sub="Acute Training Load" valueColor="#fdba74" trend={atlTrend}/>
<MetricCard
label="Form (TSB)"
value={tsb}
@@ -577,17 +602,10 @@ export function Dashboard({athleteId}: { athleteId: number }) {
</div>
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-5">
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-5">
Weekly Training Load (TSS)
</p>
<p className="text-[10px] font-bold 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'}}
>
<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"/>
@@ -598,76 +616,41 @@ export function Dashboard({athleteId}: { athleteId: number }) {
<stop offset="100%" stopColor="#f97316" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis dataKey="week" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false}
tickLine={false}/>
<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"/>
<Line type="monotone" dataKey="tss" stroke="url(#tssGrad)" strokeWidth={2} dot={false} name="TSS"/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="h-50 flex items-center justify-center text-zinc-400 text-sm">
No training data yet
</div>
<div className="h-50 flex items-center justify-center text-zinc-400 text-sm">No training data yet</div>
)}
</div>
<div className="bg-white rounded-2xl shadow-sm border border-zinc-100 p-5">
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-5">
Fitness, Fatigue &amp; Form (CTL / ATL / TSB)
</p>
<p className="text-[10px] font-bold tracking-widest text-zinc-400 uppercase mb-5">Fitness, Fatigue &amp; 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'}}
>
<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}
/>
<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/>
<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-50 flex items-center justify-center text-zinc-400 text-sm">
No fitness data yet
</div>
<div className="h-50 flex items-center justify-center text-zinc-400 text-sm">No fitness data yet</div>
)}
</div>
</section>