diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 7c9e56c..066d99f 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -5,10 +5,13 @@ import { formatDistance, formatRaceDate, getRaceCountdownLabel, + getPaceLabel, 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; @@ -16,6 +19,10 @@ function getErrorMessage(error: unknown): string { return "Не удалось загрузить данные dashboard."; } +function isSameDistance(left: number, right: number): boolean { + return Math.abs(left - right) < 0.05; +} + export function DashboardPage(): JSX.Element { const [races, setRaces] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -86,6 +93,54 @@ export function DashboardPage(): JSX.Element { }; }, [races]); + const personalRecordsByDistance = useMemo(() => { + return PR_DISTANCES.map((distanceKm) => { + const candidates = races.filter((race) => { + return ( + race.status === "completed" && + isSameDistance(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: "нет данных", + })) + .sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU")); + }, [races]); + if (isLoading) { return (
@@ -162,6 +217,60 @@ export function DashboardPage(): JSX.Element {

Завершено: {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-стартов для сравнения.

+ )} +
); } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 766e92b..724112f 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -135,6 +135,49 @@ a { font-size: var(--font-size-caption); } +.dashboard-section { + margin-top: var(--space-6); +} + +.dashboard-section__title { + margin: 0 0 var(--space-4); + font-size: var(--font-size-h2); +} + +.dashboard-grid--pr { + margin-top: 0; +} + +.comparison-table-wrapper { + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: #fcfdff; +} + +.comparison-table { + width: 100%; + border-collapse: collapse; + min-width: 720px; +} + +.comparison-table th, +.comparison-table td { + padding: var(--space-3); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.comparison-table th { + color: var(--color-text-muted); + font-size: var(--font-size-caption); + font-weight: 600; +} + +.comparison-table tbody tr:last-child td { + border-bottom: none; +} + .race-lists { margin-top: var(--space-6); display: grid;