diff --git a/.gitignore b/.gitignore index aa0926a..4c13be9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ .env *.log +*plan* +*PLAN* \ No newline at end of file diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index a8ddedc..7386efe 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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: , children: [ { index: true, element: }, - { path: "races", element: } - ] - } + { path: "races", element: }, + { path: "races/:raceId", element: }, + ], + }, ]); diff --git a/frontend/src/pages/RaceDetailsPage.tsx b/frontend/src/pages/RaceDetailsPage.tsx new file mode 100644 index 0000000..39cb6a9 --- /dev/null +++ b/frontend/src/pages/RaceDetailsPage.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let isMounted = true; + + async function loadRace(): Promise { + 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 ( +
+

Карточка старта

+

Загружаем данные старта...

+
+ ); + } + + if (errorMessage || !race) { + return ( +
+

Карточка старта

+

{errorMessage ?? "Старт не найден."}

+ + Вернуться к списку стартов + +
+ ); + } + + const isCompleted = race.status === "completed"; + + return ( +
+
+
+

{race.title}

+

+ {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} +

+
+ + {getRaceStatusLabel(race.status)} + +
+ +
+
+

Основная информация

+
+
+
Дата
+
{formatRaceDate(race.date)}
+
+
+
Дистанция
+
{formatDistance(race.distanceKm)}
+
+
+
Статус
+
{getRaceStatusLabel(race.status)}
+
+
+
+ +
+

Completed-метрики

+ {isCompleted ? ( +
+
+
Время
+
{race.finishTime ?? "время не указано"}
+
+
+
Темп
+
{paceLabel ?? "не удалось вычислить"}
+
+
+
Стартовый номер
+
{race.bibNumber ?? "не указан"}
+
+
+ ) : ( +

+ Метрики появятся после завершения старта и ввода результата. +

+ )} +
+
+ +
+

Заметки

+

{race.notes?.trim() ? race.notes : "Заметок пока нет."}

+
+ + + Назад к календарю стартов + +
+ ); +} diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx index 7e3fca3..1fcba88 100644 --- a/frontend/src/pages/RacesPage.tsx +++ b/frontend/src/pages/RacesPage.tsx @@ -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) => (
  • -

    {race.title}

    +

    + + {race.title} + +

    {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}

    diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index eee2a88..766e92b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; + } }