import { useEffect, useMemo, useState } from "react"; import type { Race } from "../api"; import { ApiError, getRaces } from "../api"; import { PaceTrendChart } from "../components"; import { formatDistance, formatRaceDate, getRaceCountdownLabel, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, splitRacesByDate, } from "../lib"; const PR_DISTANCES = [5, 10, 21.1, 42.2] as const; function getErrorMessage(error: unknown): string { if (error instanceof ApiError) { return error.message; } return "Не удалось загрузить данные dashboard."; } export function DashboardPage(): JSX.Element { const [races, setRaces] = useState([]); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); const [chartDistanceKm, setChartDistanceKm] = useState(10); useEffect(() => { let isMounted = true; async function loadDashboardData(): Promise { try { const items = await getRaces(); if (!isMounted) { return; } setRaces(items); setErrorMessage(null); } catch (error) { if (!isMounted) { return; } setErrorMessage(getErrorMessage(error)); } finally { if (isMounted) { setIsLoading(false); } } } void loadDashboardData(); return () => { isMounted = false; }; }, []); const dashboardMetrics = useMemo(() => { const { upcoming, past } = splitRacesByDate(races); const completed = races.filter((race) => race.status === "completed"); const nextRace = upcoming[0] ?? null; const lastResult = past.find((race) => race.status === "completed") ?? null; let personalRecord: Race | null = null; let personalRecordSeconds = Number.POSITIVE_INFINITY; for (const race of completed) { const finishSeconds = parseFinishTimeToSeconds(race.finishTime); if (!finishSeconds) { continue; } const candidate = finishSeconds / race.distanceKm; if (candidate < personalRecordSeconds) { personalRecordSeconds = candidate; personalRecord = race; } } const currentYear = new Date().getFullYear(); const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear); const seasonCompleted = seasonRaces.filter((race) => race.status === "completed"); return { nextRace, lastResult, personalRecord, seasonTotal: seasonRaces.length, seasonCompletedCount: seasonCompleted.length, }; }, [races]); const personalRecordsByDistance = useMemo(() => { return PR_DISTANCES.map((distanceKm) => { const candidates = races.filter((race) => { return ( race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && Boolean(parseFinishTimeToSeconds(race.finishTime)) ); }); let bestRace: Race | null = null; let bestPace = Number.POSITIVE_INFINITY; for (const race of candidates) { const totalSeconds = parseFinishTimeToSeconds(race.finishTime); if (!totalSeconds) { continue; } const paceSeconds = totalSeconds / race.distanceKm; if (paceSeconds < bestPace) { bestPace = paceSeconds; bestRace = race; } } return { distanceKm, bestRace, }; }); }, [races]); const comparisonRows = useMemo(() => { return races .filter((race) => race.status === "completed") .map((race) => ({ id: race.id, year: new Date(`${race.date}T00:00:00`).getFullYear(), title: race.title, distance: formatDistance(race.distanceKm), finishTime: race.finishTime ?? "время не указано", pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить", place: race.finishPlace?.trim() ? race.finishPlace : "нет данных", })) .sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU")); }, [races]); if (isLoading) { return (

Dashboard

Загружаем ваши старты...

); } if (errorMessage) { return (

Dashboard

{errorMessage}

); } return (

Dashboard

Ключевые метрики по вашему календарю стартов.

Ближайший старт

{dashboardMetrics.nextRace ? ( <>

{dashboardMetrics.nextRace.title}

{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}

{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}

) : (

Нет запланированных стартов.

)}

Последний результат

{dashboardMetrics.lastResult ? ( <>

{dashboardMetrics.lastResult.finishTime ?? "время не указано"}

{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}

{formatRaceDate(dashboardMetrics.lastResult.date)}

) : (

Пока нет завершённых стартов.

)}

Личный рекорд

{dashboardMetrics.personalRecord ? ( <>

{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}

{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}

Лучший темп среди завершённых стартов.

) : (

Недостаточно данных для PR.

)}

Сезон

{dashboardMetrics.seasonTotal}

стартов в этом году

Завершено: {dashboardMetrics.seasonCompletedCount}

Прогресс по времени

Линия по завершённым стартам выбранной дистанции: выше — лучше время (короче гонка).

PR по дистанциям

{personalRecordsByDistance.map((item) => (

{formatDistance(item.distanceKm)}

{item.bestRace ? ( <>

{item.bestRace.finishTime ?? "время не указано"}

{item.bestRace.title}

{formatRaceDate(item.bestRace.date)}

) : (

Нет завершённых стартов для этой дистанции.

)}
))}

Сравнение стартов

{comparisonRows.length > 0 ? (
{comparisonRows.map((row) => ( ))}
Год Старт Дистанция Время Темп Место
{row.year} {row.title} {row.distance} {row.finishTime} {row.pace} {row.place}
) : (

Нет completed-стартов для сравнения.

)}
); }