Layout
This commit is contained in:
@@ -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 & Form (CTL / ATL / TSB)
|
||||
</p>
|
||||
<p className="text-[10px] font-bold 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'}}
|
||||
>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user