Files
runners-calendar/frontend/src/pages/DashboardPage.tsx
Anton 0da7454033
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
feat(frontend): redesign race dashboard
2026-04-22 11:47:37 +03:00

362 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components";
import {
formatDistance,
formatRaceDate,
getRaceCountdownLabel,
getPaceLabel,
isCloseDistance,
parseFinishTimeToSeconds,
parseRaceDate,
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 "Не удалось загрузить данные обзора.";
}
export function DashboardPage(): JSX.Element {
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadDashboardData(): Promise<void> {
try {
const items = await getRaces(undefined, { signal: ac.signal });
if (!isMounted || ac.signal.aborted) {
return;
}
setRaces(items);
setErrorMessage(null);
} catch (error) {
if (ac.signal.aborted || !isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
}
void loadDashboardData();
return () => {
isMounted = false;
ac.abort();
};
}, []);
const dashboardMetrics = useMemo(() => {
const { upcoming, past } = splitRacesByDate(races);
const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null;
const lastPersonalRecord =
past.find(
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
) ?? null;
const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
const seasonCompleted = seasonRaces.filter((race) => race.status === "completed");
return {
nextRace,
lastResult,
lastPersonalRecord,
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: parseRaceDate(race.date).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]);
const seasonProgress =
dashboardMetrics.seasonTotal > 0
? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100)
: 0;
if (isLoading) {
return (
<section className="page page--dashboard" aria-busy="true">
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Загружаем ваши старты...</p>
</section>
);
}
if (errorMessage) {
return (
<section className="page page--dashboard" role="alert">
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
</section>
);
}
return (
<section className="page page--dashboard">
<section className="dashboard-hero" aria-label="Обзор сезона">
<div className="dashboard-hero__content">
<p className="dashboard-hero__eyebrow">Календарь сезона</p>
<h1 className="dashboard-hero__title">Беговой штаб</h1>
<p className="dashboard-hero__text">
Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям.
</p>
<div className="dashboard-hero__actions">
<Link className="btn btn--primary" to="/races">
Смотреть старты
</Link>
<Link className="btn btn--secondary dashboard-hero__secondary" to="/races/new">
Добавить старт
</Link>
</div>
</div>
<div className="dashboard-hero__panel">
<p className="dashboard-hero__panel-label">Ближайший старт</p>
{dashboardMetrics.nextRace ? (
<Link className="dashboard-hero__race-link" to={`/races/${dashboardMetrics.nextRace.id}`}>
<span className="dashboard-hero__race-title">{dashboardMetrics.nextRace.title}</span>
<span className="dashboard-hero__race-meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
</span>
<span className="dashboard-hero__race-countdown">
{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}
</span>
</Link>
) : (
<p className="dashboard-hero__empty">Запланируйте первый старт сезона.</p>
)}
</div>
</section>
<div className="dashboard-grid" aria-label="Ключевые метрики">
<article
className={`dashboard-card dashboard-card--accent-blue${dashboardMetrics.nextRace ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.nextRace ? (
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.nextRace.id}`}
aria-label={`Ближайший старт: ${dashboardMetrics.nextRace.title}`}
>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
<p className="dashboard-card__meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} ·{" "}
{formatDistance(dashboardMetrics.nextRace.distanceKm)}
</p>
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
</Link>
) : (
<>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
</>
)}
</article>
<article
className={`dashboard-card dashboard-card--accent-coral${dashboardMetrics.lastResult ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastResult ? (
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastResult.id}`}
aria-label={`Последний результат: ${dashboardMetrics.lastResult.title}`}
>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
</Link>
) : (
<>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
</>
)}
</article>
<article
className={`dashboard-card dashboard-card--accent-lime${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastPersonalRecord ? (
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
aria-label={`Последний личный рекорд: ${dashboardMetrics.lastPersonalRecord.title}`}
>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__value">
{dashboardMetrics.lastPersonalRecord.finishTime ?? "время не указано"}
</p>
<p className="dashboard-card__meta">
{dashboardMetrics.lastPersonalRecord.title} ·{" "}
{formatDistance(dashboardMetrics.lastPersonalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
) : (
<>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
</>
)}
</article>
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
<h2 className="dashboard-card__title">Сезон</h2>
<p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p>
<p className="dashboard-card__meta">стартов в этом году</p>
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
<div className="dashboard-card__progress" aria-label={`Сезон завершен на ${seasonProgress}%`}>
<span style={{ width: `${seasonProgress}%` }} />
</div>
</article>
</div>
<section className="dashboard-section" aria-label="Динамика времени на дистанции">
<h2 className="dashboard-section__title">Прогресс по времени</h2>
<p className="dashboard-section__intro">
Линия по завершённым стартам выбранной дистанции: выше лучше время (короче гонка).
</p>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция для графика</span>
<select
className="races-filter__select"
value={String(chartDistanceKm)}
onChange={(event) => {
setChartDistanceKm(Number(event.target.value));
}}
>
{PR_DISTANCES.map((d) => (
<option key={d} value={d}>
{formatDistance(d)}
</option>
))}
</select>
</label>
<PaceTrendChart races={races} distanceKm={chartDistanceKm} />
</section>
<section className="dashboard-section" aria-label="Личные рекорды по дистанциям">
<h2 className="dashboard-section__title">Рекорды по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card">
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
{item.bestRace ? (
<>
<p className="dashboard-card__value">{item.bestRace.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">{item.bestRace.title}</p>
<p className="dashboard-card__hint">{formatRaceDate(item.bestRace.date)}</p>
</>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
)}
</article>
))}
</div>
</section>
<section className="dashboard-section" aria-label="Сравнение завершённых стартов">
<h2 className="dashboard-section__title">Сравнение стартов</h2>
{comparisonRows.length > 0 ? (
<div className="comparison-table-wrapper">
<table className="comparison-table">
<thead>
<tr>
<th>Год</th>
<th>Старт</th>
<th>Дистанция</th>
<th>Время</th>
<th>Темп</th>
<th>Место</th>
</tr>
</thead>
<tbody>
{comparisonRows.map((row) => (
<tr key={row.id}>
<td>{row.year}</td>
<td>{row.title}</td>
<td>{row.distance}</td>
<td>{row.finishTime}</td>
<td>{row.pace}</td>
<td>{row.place}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для сравнения.</p>
)}
</section>
</section>
);
}