Minor refactoring

This commit is contained in:
2026-03-27 19:12:14 +01:00
parent 87e9b601bb
commit 355140ede6
6 changed files with 741 additions and 453 deletions

View File

@@ -4,15 +4,17 @@ import com.peakup.common.dao.AbstractEnumConverter;
import com.peakup.common.dao.PersistableEnum;
public enum GoalMetricEnum implements PersistableEnum<GoalMetricEnum> {
DISTANCE(1),
TSS(2),
ELEVATION(3),
;
DISTANCE(1, "km"),
TSS(2, ""),
ELEVATION(3, "m"),
TIME(4, "hh:mm");
private final int key;
private final String unit;
GoalMetricEnum(int key) {
GoalMetricEnum(int key, String unit) {
this.key = key;
this.unit = unit;
}
@Override
@@ -20,6 +22,10 @@ public enum GoalMetricEnum implements PersistableEnum<GoalMetricEnum> {
return key;
}
public String getUnit() {
return unit;
}
@jakarta.persistence.Converter(autoApply = true)
public static class Converter extends AbstractEnumConverter<GoalMetricEnum> {
@Override

View File

@@ -4,4 +4,5 @@ public enum IntensityMetric {
POWER,
HEART_RATE,
PACE
}

View File

@@ -6,6 +6,7 @@ import { Dashboard } from '@/pages/Dashboard'
import { Activities } from '@/pages/Activities'
import { WeekPlanPage } from '@/pages/WeekPlanPage'
import { Placeholder } from '@/pages/Placeholder'
import { Settings } from '@/pages/Settings'
import { api } from '@/api/client'
const queryClient = new QueryClient()
@@ -44,7 +45,7 @@ function AppContent() {
case 'calendar':
return <WeekPlanPage athleteId={athleteId!} />
case 'settings':
return <Placeholder name="Settings" />
return <Settings athleteId={athleteId!} />
default:
return <Dashboard athleteId={athleteId!} />
}

View File

@@ -1,504 +1,520 @@
import { useQuery } from '@tanstack/react-query'
import {useQuery} from '@tanstack/react-query'
import {
addWeeks,
eachDayOfInterval,
endOfISOWeek,
endOfMonth,
format,
getISOWeek,
getISOWeekYear,
isSameDay,
isToday,
startOfISOWeek,
eachDayOfInterval,
startOfMonth,
} from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useSearchParams } from 'react-router-dom'
import {
Area,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { api } from '@/api/client'
import { type Activity, formatDistance, formatDuration, sportIcon } from '@/types/api'
import {ChevronLeft, ChevronRight} from 'lucide-react'
import {useSearchParams} from 'react-router-dom'
import {Area, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis,} from 'recharts'
import {api} from '@/api/client'
import {type Activity, formatDistance, formatDuration, type Goal, sportIcon} from '@/types/api'
// ── Week helpers ──────────────────────────────────────────────────────────────
function weekStart(year: number, week: number): Date {
// Find the Monday of ISO week `week` in `year`
const jan4 = new Date(year, 0, 4) // Jan 4 is always in week 1
const startW1 = startOfISOWeek(jan4)
return addWeeks(startW1, week - 1)
const jan4 = new Date(year, 0, 4)
const startW1 = startOfISOWeek(jan4)
return addWeeks(startW1, week - 1)
}
// ── Sub-components ────────────────────────────────────────────────────────────
// ── MetricCard ────────────────────────────────────────────────────────────────
function MetricCard({
label,
value,
unit,
sub,
valueColor,
}: {
label: string
value: string | number
unit?: string
sub?: string
valueColor?: string
label,
value,
unit,
sub,
valueColor,
}: {
label: string
value: string | number
unit?: string
sub?: string
valueColor?: string
}) {
return (
<div className="border border-zinc-200 rounded-sm p-5 bg-white">
<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>
)}
</p>
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
</div>
)
return (
<div className="border border-zinc-200 rounded-sm p-5 bg-white">
<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>}
</p>
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
</div>
)
}
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>
)
// ── 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>
)
}
function WeeklyGoal({
label,
current,
target,
unit,
}: {
label: string
current: number
target: number
unit: string
// ── Goal helpers ──────────────────────────────────────────────────────────────
type Metric = Goal['metric']
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)
}
}
function computeActuals(acts: Activity[]): Actuals {
return {
DISTANCE: acts.reduce((s, a) => s + a.distanceM, 0) / 1000,
TSS: acts.reduce((s, a) => s + (a.trainingLoad ?? 0), 0),
ELEVATION: acts.reduce((s, a) => s + (a.totalElevationGainM ?? 0), 0),
TIME: acts.reduce((s, a) => s + a.movingTimeS, 0),
}
}
// ── GoalRow ───────────────────────────────────────────────────────────────────
function GoalRow({
label,
currentDisplay,
targetDisplay,
currentRaw,
targetRaw,
}: {
label: string
currentDisplay: string
targetDisplay: string
currentRaw: number
targetRaw: number
}) {
return (
<div className="border border-zinc-200 rounded-sm p-5 bg-white">
<div className="flex items-end justify-between mb-3">
<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">{current}</span> / {target}{' '}
{unit}
</p>
</div>
<ProgressBar value={current} max={target} />
</div>
)
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: Goal[]
actuals: Actuals
}) {
return (
<div className="border border-zinc-200 rounded-sm bg-white p-5 flex flex-col gap-4">
<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
tss: number
ctl: number | null
atl: number | null
tsb: number | null
week: string
tss: number
ctl: number | null
atl: number | null
tsb: number | null
}
function buildLoadData(activities: Activity[], anchor: Date): LoadPoint[] {
const anchorWeekStart = startOfISOWeek(anchor)
// Generate 12 ISO week Monday Date objects (oldest → newest)
const weekMondays: Date[] = Array.from({ length: 12 }, (_, i) =>
addWeeks(anchorWeekStart, i - 11),
)
// TSS: sum trainingLoad per week — keyed by epoch ms to avoid UTC-parse issues
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)
})
// CTL/ATL: for each week, use the last activity with startTime ≤ end of that week
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()
// Find last activity up to end of this week
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
return {
week: `CW ${getISOWeek(monday)}`,
tss: tssMap[key] ?? 0,
ctl: lastCtl,
atl: lastAtl,
tsb,
}
})
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
return {week: `CW ${getISOWeek(monday)}`, tss: tssMap[key] ?? 0, ctl: lastCtl, atl: lastAtl, tsb}
})
}
// ── Daily card ────────────────────────────────────────────────────────────────
// ── DayCard ───────────────────────────────────────────────────────────────────
function DayCard({ day, activities }: { day: Date; activities: Activity[] }) {
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-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',
].join(' ')}
>
<p
className={[
'text-xs font-semibold tracking-widest uppercase mb-2',
today ? 'gradient-text' : '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) => (
<li key={a.id} className="flex items-center gap-1.5">
<span className="text-sm leading-none">{sportIcon(a.type)}</span>
<div className="min-w-0">
<p className="text-xs font-medium text-zinc-700 truncate leading-none">
{a.name}
</p>
<p className="text-xs text-zinc-400">
{formatDistance(a.distanceM)} · {formatDuration(a.movingTimeS)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
)
function DayCard({day, activities}: { day: Date; activities: Activity[] }) {
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-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',
].join(' ')}
>
<p
className={[
'text-xs font-semibold tracking-widest uppercase mb-2',
today ? 'gradient-text' : '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) => (
<li key={a.id} className="flex items-center gap-1.5">
<span className="text-sm leading-none">{sportIcon(a.type)}</span>
<div className="min-w-0">
<p className="text-xs font-medium text-zinc-700 truncate leading-none">{a.name}</p>
<p className="text-xs text-zinc-400">
{formatDistance(a.distanceM)} · {formatDuration(a.movingTimeS)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
)
}
// ── Custom tooltip ────────────────────────────────────────────────────────────
// ── LoadTooltip ───────────────────────────────────────────────────────────────
const UNIT: Record<string, string> = {
TSS: 'TSS',
CTL: 'TSS/day',
ATL: 'TSS/day',
TSB: 'pts',
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
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>
)
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()
export function Dashboard({athleteId}: { athleteId: number }) {
const [searchParams, setSearchParams] = useSearchParams()
const now = new Date()
const currentISOWeek = getISOWeek(now)
const currentISOYear = getISOWeekYear(now)
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
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 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)})
}
const {data: activities = [], isLoading} = useQuery({
queryKey: ['activities', athleteId],
queryFn: () => api.activities(athleteId),
staleTime: 5 * 60 * 1000,
})
}
function goToday() {
setSearchParams({
year: String(currentISOYear),
week: String(currentISOWeek),
const {data: goals = []} = useQuery({
queryKey: ['goals', athleteId],
queryFn: () => api.goals(athleteId),
staleTime: 60_000,
})
}
const { data: activities = [], isLoading } = useQuery({
queryKey: ['activities', athleteId],
queryFn: () => api.activities(athleteId),
staleTime: 5 * 60 * 1000,
})
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 mStart = startOfMonth(wStart)
const mEnd = endOfMonth(wStart)
const yStart = new Date(wStart.getFullYear(), 0, 1)
const yEnd = new Date(wStart.getFullYear(), 11, 31, 23, 59, 59)
const weekActuals = computeActuals(activities.filter((a) => {
const t = new Date(a.startTime);
return t >= wStart && t <= wEnd
}))
const monthActuals = computeActuals(activities.filter((a) => {
const t = new Date(a.startTime);
return t >= mStart && t <= mEnd
}))
const yearActuals = computeActuals(activities.filter((a) => {
const t = new Date(a.startTime);
return t >= yStart && t <= yEnd
}))
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 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)
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>
<div className="flex-1 overflow-y-auto bg-zinc-50 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-10">
<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-3">
<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: 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">
Daily Breakdown
</p>
<div className="grid grid-cols-7 gap-2 mb-8">
{weekDays.map((day) => (
<DayCard key={day.toISOString()} day={day} activities={selectedWeekActivities}/>
))}
</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>
{/* Training Load chart */}
<div className="border border-zinc-200 rounded-sm bg-white p-5">
<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 || d.ctl !== null) ? (
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={loadData} margin={{left: -20, right: 30}}>
<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 yAxisId="tss" tick={{fontSize: 10, fill: '#a1a1aa'}} axisLine={false}
tickLine={false}/>
<YAxis yAxisId="fitness" orientation="right" tick={{fontSize: 10, fill: '#a1a1aa'}}
axisLine={false} tickLine={false}/>
<Tooltip content={<LoadTooltip/>}/>
<Area yAxisId="tss" type="monotone" dataKey="tss" fill="url(#tssAreaGrad)"
stroke="none"/>
<Line yAxisId="tss" type="monotone" dataKey="tss" stroke="url(#tssGrad)" strokeWidth={2}
dot={false} name="TSS"/>
<Line yAxisId="fitness" type="monotone" dataKey="ctl" stroke="#ef4444" strokeWidth={1.5}
strokeDasharray="5 3" dot={false} name="CTL" connectNulls/>
<Line yAxisId="fitness" type="monotone" dataKey="atl" stroke="#fdba74" strokeWidth={1.5}
strokeDasharray="5 3" dot={false} name="ATL" connectNulls/>
<Line yAxisId="fitness" 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 training data yet
</div>
)}
</div>
</section>
</div>
)
}
// Selected week bounds
const wStart = weekStart(year, week)
const wEnd = endOfISOWeek(wStart)
const weekDays = eachDayOfInterval({ start: wStart, end: wEnd })
const selectedWeekActivities = activities.filter((a) => {
const t = new Date(a.startTime)
return t >= wStart && t <= wEnd
})
const weekDistance = selectedWeekActivities.reduce((s, a) => s + a.distanceM, 0) / 1000
const weekTime = selectedWeekActivities.reduce((s, a) => s + a.movingTimeS, 0)
const weekTSS = selectedWeekActivities.reduce((s, a) => s + (a.trainingLoad || 0), 0)
const weekElevation = selectedWeekActivities.reduce((s, a) => s + (a.totalElevationGainM || 0), 0)
// CTL/ATL/TSB: snapshot at end of selected week
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)
return (
<div className="flex-1 overflow-y-auto bg-zinc-50 p-8">
{/* 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>
{/* Week navigation + Goals */}
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
This Week's Training
</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-2 lg:grid-cols-4 gap-3">
<WeeklyGoal
label="Distance"
current={Math.round(weekDistance)}
target={100}
unit="km"
/>
<WeeklyGoal
label="Time"
current={Math.round(weekTime / 3600)}
target={10}
unit="h"
/>
<WeeklyGoal
label="TSS"
current={Math.round(weekTSS)}
target={500}
unit="pts"
/>
<WeeklyGoal
label="Elevation"
current={Math.round(weekElevation)}
target={3000}
unit="m"
/>
</div>
</section>
{/* 7-day week grid */}
<section className="mb-8">
<p className="text-xs font-semibold tracking-widest text-zinc-400 uppercase mb-4">
Daily Breakdown
</p>
<div className="grid grid-cols-7 gap-2">
{weekDays.map((day) => (
<DayCard key={day.toISOString()} day={day} activities={selectedWeekActivities} />
))}
</div>
</section>
{/* Fitness Metrics */}
<section className="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-3">
<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>
</section>
{/* Load chart */}
<div className="border border-zinc-200 rounded-sm bg-white p-5">
<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 || d.ctl !== null) ? (
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={loadData} margin={{ left: -20, right: 30 }}>
<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}
/>
{/* Left axis: TSS */}
<YAxis
yAxisId="tss"
tick={{ fontSize: 10, fill: '#a1a1aa' }}
axisLine={false}
tickLine={false}
/>
{/* Right axis: CTL / ATL / TSB */}
<YAxis
yAxisId="fitness"
orientation="right"
tick={{ fontSize: 10, fill: '#a1a1aa' }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<LoadTooltip />} />
<Area
yAxisId="tss"
type="monotone"
dataKey="tss"
fill="url(#tssAreaGrad)"
stroke="none"
/>
<Line
yAxisId="tss"
type="monotone"
dataKey="tss"
stroke="url(#tssGrad)"
strokeWidth={2}
dot={false}
name="TSS"
/>
<Line
yAxisId="fitness"
type="monotone"
dataKey="ctl"
stroke="#ef4444"
strokeWidth={1.5}
strokeDasharray="5 3"
dot={false}
name="CTL"
connectNulls
/>
<Line
yAxisId="fitness"
type="monotone"
dataKey="atl"
stroke="#fdba74"
strokeWidth={1.5}
strokeDasharray="5 3"
dot={false}
name="ATL"
connectNulls
/>
<Line
yAxisId="fitness"
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 training data yet
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,264 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { api } from '@/api/client'
import type { Goal } from '@/types/api'
// ── Constants ─────────────────────────────────────────────────────────────────
type Period = 'WEEKLY' | 'MONTHLY' | 'YEARLY'
type Metric = 'DISTANCE' | 'TSS' | 'ELEVATION' | 'TIME'
const PERIODS: { key: Period; label: string; description: string }[] = [
{ key: 'WEEKLY', label: 'Weekly', description: 'Short-term consistency targets' },
{ key: 'MONTHLY', label: 'Monthly', description: 'Block-periodisation targets' },
{ key: 'YEARLY', label: 'Yearly', description: 'Season-long volume & peak performance' },
]
const METRICS: { key: Metric; label: string; unit: string; placeholder: string }[] = [
{ key: 'DISTANCE', label: 'Distance', unit: 'km', placeholder: '0' },
{ key: 'TSS', label: 'TSS', unit: '', placeholder: '0' },
{ key: 'ELEVATION', label: 'Elevation', unit: 'm', placeholder: '0' },
{ key: 'TIME', label: 'Time', unit: 'hh:mm', placeholder: '00:00' },
]
// ── Helpers ───────────────────────────────────────────────────────────────────
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')}`
}
function hhmmToSeconds(value: string): number | null {
const match = value.match(/^(\d{1,3}):([0-5]\d)$/)
if (!match) return null
return parseInt(match[1], 10) * 3600 + parseInt(match[2], 10) * 60
}
function formatDisplayValue(metric: Metric, targetValue: number): string {
if (metric === 'TIME') return secondsToHhmm(targetValue)
return String(targetValue)
}
type ParseResult = number | null | 'empty'
function parseInputValue(metric: Metric, raw: string): ParseResult {
const trimmed = raw.trim()
if (trimmed === '' || trimmed === '0' || trimmed === '00:00') return 'empty'
if (metric === 'TIME') {
const s = hhmmToSeconds(trimmed)
if (s === null) return null
return s === 0 ? 'empty' : s
}
const n = parseFloat(trimmed)
if (isNaN(n) || n < 0) return null
return n === 0 ? 'empty' : n
}
type AllValues = Record<Period, Record<Metric, string>>
type AllErrors = Record<Period, Record<Metric, string | null>>
function initAllValues(goals: Goal[]): AllValues {
const empty = (): Record<Metric, string> => ({ DISTANCE: '', TSS: '', ELEVATION: '', TIME: '' })
const map: AllValues = { WEEKLY: empty(), MONTHLY: empty(), YEARLY: empty() }
for (const g of goals) {
map[g.period][g.metric] = formatDisplayValue(g.metric, g.targetValue)
}
return map
}
function initAllErrors(): AllErrors {
const none = (): Record<Metric, string | null> => ({ DISTANCE: null, TSS: null, ELEVATION: null, TIME: null })
return { WEEKLY: none(), MONTHLY: none(), YEARLY: none() }
}
// ── PeriodCard (display only) ─────────────────────────────────────────────────
function PeriodCard({
period,
values,
errors,
disabled,
onChange,
}: {
period: (typeof PERIODS)[number]
values: Record<Metric, string>
errors: Record<Metric, string | null>
disabled: boolean
onChange: (metric: Metric, value: string) => void
}) {
return (
<div className="border border-zinc-200 rounded-sm bg-white p-6 flex flex-col gap-5">
<div>
<h3 className="text-sm font-semibold text-zinc-900 tracking-wide">{period.label}</h3>
<p className="text-xs text-zinc-400 mt-0.5">{period.description}</p>
</div>
<div className="flex flex-col gap-4">
{METRICS.map((metric) => (
<div key={metric.key} className="flex flex-col gap-1.5">
<label className="text-xs font-semibold tracking-widest text-zinc-400 uppercase">
{metric.label}
</label>
<div className="relative">
<input
type="text"
value={values[metric.key]}
onChange={(e) => onChange(metric.key, e.target.value)}
placeholder={metric.placeholder}
disabled={disabled}
className={[
'w-full rounded-sm border px-3 py-2 text-sm bg-white text-zinc-900',
'placeholder:text-zinc-400 focus:outline-none focus:ring-1 focus:ring-orange-400',
errors[metric.key] ? 'border-red-400' : 'border-slate-200',
disabled ? 'opacity-50 cursor-not-allowed' : '',
metric.unit ? 'pr-14' : '',
].join(' ')}
/>
{metric.unit && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-400 pointer-events-none">
{metric.unit}
</span>
)}
</div>
{errors[metric.key] && (
<p className="text-xs text-red-500">{errors[metric.key]}</p>
)}
</div>
))}
</div>
</div>
)
}
// ── Settings page ─────────────────────────────────────────────────────────────
export function Settings({ athleteId }: { athleteId: number }) {
const qc = useQueryClient()
const { data: goals = [], isLoading } = useQuery({
queryKey: ['goals', athleteId],
queryFn: () => api.goals(athleteId),
staleTime: 60_000,
})
const [values, setValues] = useState<AllValues>(() => initAllValues(goals))
const [errors, setErrors] = useState<AllErrors>(initAllErrors)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
setValues(initAllValues(goals))
setErrors(initAllErrors())
}, [goals])
function handleChange(period: Period, metric: Metric, raw: string) {
setValues((prev) => ({ ...prev, [period]: { ...prev[period], [metric]: raw } }))
setErrors((prev) => ({ ...prev, [period]: { ...prev[period], [metric]: null } }))
setSaved(false)
}
async function handleSave() {
// Validate everything first
const newErrors = initAllErrors()
let hasError = false
for (const p of PERIODS) {
for (const m of METRICS) {
if (parseInputValue(m.key, values[p.key][m.key]) === null) {
newErrors[p.key][m.key] = m.key === 'TIME' ? 'Use HH:mm (e.g. 08:30)' : 'Invalid number'
hasError = true
}
}
}
if (hasError) {
setErrors(newErrors)
return
}
setSaving(true)
setSaved(false)
try {
for (const p of PERIODS) {
for (const m of METRICS) {
const result = parseInputValue(m.key, values[p.key][m.key])
if (result === 'empty' || result === null) continue
const existing = goals.find((g) => g.period === p.key && g.metric === m.key)
if (existing) {
if (result !== existing.targetValue) await api.updateGoal(existing.id, result)
} else {
await api.createGoal(athleteId, { period: p.key, metric: m.key, targetValue: result })
}
}
}
await qc.invalidateQueries({ queryKey: ['goals', athleteId] })
setSaved(true)
} catch {
setErrors((prev) => ({
...prev,
WEEKLY: { ...prev.WEEKLY, DISTANCE: 'Save failed — please retry' },
}))
} finally {
setSaving(false)
}
}
return (
<div className="flex-1 overflow-y-auto p-8 bg-white">
<div className="max-w-5xl mx-auto flex flex-col gap-8">
<div>
<h1 className="text-2xl font-light text-zinc-900 tracking-tight">Settings</h1>
</div>
<section className="flex flex-col gap-4">
<div className="border-b border-zinc-100 pb-3">
<h2 className="text-base font-semibold text-zinc-900">Training Goals</h2>
<p className="text-sm text-zinc-400 mt-0.5">
Set your targets for volume and intensity.
</p>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PERIODS.map((p) => (
<div key={p.key} className="border border-zinc-200 rounded-sm bg-white p-6 h-72 animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{PERIODS.map((period) => (
<PeriodCard
key={period.key}
period={period}
values={values[period.key]}
errors={errors[period.key]}
disabled={saving}
onChange={(metric, raw) => handleChange(period.key, metric, raw)}
/>
))}
</div>
)}
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-zinc-400">
Fields left empty or set to 0 are ignored.
</p>
<button
onClick={handleSave}
disabled={saving || isLoading}
className={[
'rounded-sm px-6 py-2 text-sm font-semibold transition-colors',
'bg-gradient-to-r from-red-500 to-orange-500 text-white',
'hover:from-red-600 hover:to-orange-600',
saving || isLoading ? 'opacity-50 cursor-not-allowed' : '',
].join(' ')}
>
{saving ? 'Saving…' : saved ? 'Saved' : 'Save Goals'}
</button>
</div>
</section>
</div>
</div>
)
}

View File

@@ -88,7 +88,7 @@ export interface Goal {
id: number
athleteId: number
period: 'WEEKLY' | 'MONTHLY' | 'YEARLY'
metric: 'DISTANCE' | 'TSS' | 'ELEVATION'
metric: 'DISTANCE' | 'TSS' | 'ELEVATION' | 'TIME'
targetValue: number
}