Files
runners-calendar/frontend/src/pages/DashboardPage.tsx
Vaka.pro fdb0ba3d2d
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
fix: use next race image as dashboard hero background
2026-04-27 23:30:36 +03:00

380 lines
15 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 type { CSSProperties } from "react";
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,
getRaceVisual,
getPaceLabel,
isCloseDistance,
parseFinishTimeToSeconds,
parseRaceDate,
splitRacesByDate,
} from "../lib";
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
type DashboardHeroStyle = CSSProperties & {
"--dashboard-hero-image"?: string;
};
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить данные обзора.";
}
function toCssUrl(value: string): string {
return `url(${JSON.stringify(value)})`;
}
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;
const dashboardHeroVisual = dashboardMetrics.nextRace ? getRaceVisual(dashboardMetrics.nextRace) : null;
const dashboardHeroStyle: DashboardHeroStyle | undefined = dashboardHeroVisual
? { "--dashboard-hero-image": toCssUrl(dashboardHeroVisual.imageSrc) }
: undefined;
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${dashboardHeroVisual ? " dashboard-hero--with-image" : ""}`}
style={dashboardHeroStyle}
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>
);
}