Files
runners-calendar/frontend/src/pages/RaceDetailsPage.tsx
Anton 92cf94aa5b feat(frontend): add race details page with completed metrics
Implement race detail routing and UI with client-side pace calculation so completed race metrics are visible from the calendar flow.

Made-with: Cursor
2026-04-06 17:48:11 +03:00

160 lines
5.7 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, useParams } from "react-router-dom";
import { ApiError, getRaceById } from "../api";
import { formatDistance, formatRaceDate, getPaceLabel, getRaceStatusLabel } from "../lib";
import type { Race } from "../api";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить карточку старта.";
}
export function RaceDetailsPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>();
const [race, setRace] = useState<Race | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
async function loadRace(): Promise<void> {
if (!raceId) {
setErrorMessage("Не найден идентификатор старта.");
setIsLoading(false);
return;
}
try {
const item = await getRaceById(raceId);
if (!isMounted) {
return;
}
setRace(item);
setErrorMessage(null);
} catch (error) {
if (!isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
void loadRace();
return () => {
isMounted = false;
};
}, [raceId]);
const paceLabel = useMemo(() => {
if (!race || race.status !== "completed") {
return null;
}
return getPaceLabel(race.finishTime, race.distanceKm);
}, [race]);
if (isLoading) {
return (
<section className="page page--race-details" aria-busy="true">
<h1 className="page__title">Карточка старта</h1>
<p className="page__subtitle">Загружаем данные старта...</p>
</section>
);
}
if (errorMessage || !race) {
return (
<section className="page page--race-details" role="alert">
<h1 className="page__title">Карточка старта</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage ?? "Старт не найден."}</p>
<Link className="page-link" to="/races">
Вернуться к списку стартов
</Link>
</section>
);
}
const isCompleted = race.status === "completed";
return (
<section className="page page--race-details">
<div className="race-details-header">
<div className="race-details-header__main">
<h1 className="page__title">{race.title}</h1>
<p className="page__subtitle">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span
className={
isCompleted
? "race-card__status race-card__status--completed"
: "race-card__status race-card__status--planned"
}
>
{getRaceStatusLabel(race.status)}
</span>
</div>
<div className="race-details-grid">
<article className="race-details-card">
<h2 className="race-details-card__title">Основная информация</h2>
<dl className="race-details-meta">
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Дата</dt>
<dd className="race-details-meta__value">{formatRaceDate(race.date)}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Дистанция</dt>
<dd className="race-details-meta__value">{formatDistance(race.distanceKm)}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Статус</dt>
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status)}</dd>
</div>
</dl>
</article>
<article className="race-details-card">
<h2 className="race-details-card__title">Completed-метрики</h2>
{isCompleted ? (
<dl className="race-details-meta">
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Время</dt>
<dd className="race-details-meta__value">{race.finishTime ?? "время не указано"}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Темп</dt>
<dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Стартовый номер</dt>
<dd className="race-details-meta__value">{race.bibNumber ?? "не указан"}</dd>
</div>
</dl>
) : (
<p className="race-details-card__empty">
Метрики появятся после завершения старта и ввода результата.
</p>
)}
</article>
</div>
<article className="race-details-card race-details-card--notes">
<h2 className="race-details-card__title">Заметки</h2>
<p className="race-details-card__notes">{race.notes?.trim() ? race.notes : "Заметок пока нет."}</p>
</article>
<Link className="page-link" to="/races">
Назад к календарю стартов
</Link>
</section>
);
}