Files
runners-calendar/frontend/src/pages/DashboardPage.tsx
Vaka.pro 42ee36d0a2
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
feat: русский UI, версии в футере, даты и устойчивость загрузки API
- API: дата старта всегда YYYY-MM-DD; фронт: parseRaceDate без двойного T00:00:00
- GET /health с version из package.json; Vite define __FRONTEND_VERSION__
- Футер с версиями клиента/сервера (BEM), сетка app-shell на три ряда
- AbortController для карточки старта; ретраи GET при 502–504 и понятные ошибки шлюза
- Русские подписи навигации/страниц, lang=ru, без английских фраз в интерфейсе
2026-04-08 00:40:03 +03:00

303 lines
11 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 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 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) => parseRaceDate(race.date).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: 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]);
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">
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<div className="dashboard-grid" aria-label="Ключевые метрики">
<article className="dashboard-card">
<h2 className="dashboard-card__title">Ближайший старт</h2>
{dashboardMetrics.nextRace ? (
<>
<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>
</>
) : (
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Последний результат</h2>
{dashboardMetrics.lastResult ? (
<>
<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>
</>
) : (
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Личный рекорд</h2>
{dashboardMetrics.personalRecord ? (
<>
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
</>
) : (
<p className="dashboard-card__empty">Недостаточно данных для личного рекорда.</p>
)}
</article>
<article className="dashboard-card">
<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>
</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>
);
}