Merge pull request 'feat(frontend): add race details page with completed metrics' (#3) from feat/race-details-pace-metrics into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
*plan*
|
||||
*PLAN*
|
||||
@@ -2,6 +2,7 @@ import { createBrowserRouter } from "react-router-dom";
|
||||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { RacesPage } from "../pages/RacesPage";
|
||||
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
|
||||
|
||||
export const appRouter = createBrowserRouter([
|
||||
{
|
||||
@@ -9,7 +10,8 @@ export const appRouter = createBrowserRouter([
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "races", element: <RacesPage /> }
|
||||
]
|
||||
}
|
||||
{ path: "races", element: <RacesPage /> },
|
||||
{ path: "races/:raceId", element: <RaceDetailsPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
159
frontend/src/pages/RaceDetailsPage.tsx
Normal file
159
frontend/src/pages/RaceDetailsPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Race } from "../api";
|
||||
import { ApiError, getRaces } from "../api";
|
||||
import { formatDistance, formatRaceDate, getRaceStatusLabel, splitRacesByDate } from "../lib";
|
||||
@@ -21,7 +22,11 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
|
||||
{races.map((race) => (
|
||||
<li key={race.id} className="race-card">
|
||||
<div className="race-card__main">
|
||||
<p className="race-card__title">{race.title}</p>
|
||||
<p className="race-card__title">
|
||||
<Link className="race-card__link" to={`/races/${race.id}`}>
|
||||
{race.title}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="race-card__meta">
|
||||
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
||||
</p>
|
||||
|
||||
@@ -183,6 +183,12 @@ a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.race-card__link:hover,
|
||||
.race-card__link:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.race-card__meta {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
@@ -209,9 +215,86 @@ a {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.page-link {
|
||||
margin-top: var(--space-4);
|
||||
display: inline-flex;
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-link:hover,
|
||||
.page-link:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.race-details-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.race-details-grid {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.race-details-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.race-details-card--notes {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.race-details-card__title {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.race-details-card__empty,
|
||||
.race-details-card__notes {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.race-details-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.race-details-meta__item {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.race-details-meta__key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.race-details-meta__value {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-grid,
|
||||
.race-lists {
|
||||
.race-lists,
|
||||
.race-details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.race-details-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user