Minor refactoring
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -4,4 +4,5 @@ public enum IntensityMetric {
|
||||
POWER,
|
||||
HEART_RATE,
|
||||
PACE
|
||||
|
||||
}
|
||||
|
||||
@@ -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!} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
264
peakup-frontend/src/pages/Settings.tsx
Normal file
264
peakup-frontend/src/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user