Compare commits
7 Commits
feat/backe
...
feat/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7845d8d961 | ||
| 0ddf37683a | |||
|
|
92cf94aa5b | ||
| 36e103dd41 | |||
|
|
a332703e2f | ||
| 1d89e2bce2 | |||
|
|
800fbfa560 |
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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1 +1,11 @@
|
||||
export {};
|
||||
export {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
getPaceLabel,
|
||||
getRaceCountdownLabel,
|
||||
getRaceStatusLabel,
|
||||
parseFinishTimeToSeconds,
|
||||
sortByDateAsc,
|
||||
sortByDateDesc,
|
||||
splitRacesByDate,
|
||||
} from "./raceMetrics";
|
||||
|
||||
110
frontend/src/lib/raceMetrics.ts
Normal file
110
frontend/src/lib/raceMetrics.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Race } from "../api";
|
||||
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseRaceDate(date: string): Date {
|
||||
return new Date(`${date}T00:00:00`);
|
||||
}
|
||||
|
||||
export function parseFinishTimeToSeconds(value: string | null): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = value.split(":").map((part) => Number(part));
|
||||
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
const [minutes, seconds] = parts;
|
||||
return minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
const [hours, minutes, seconds] = parts;
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDistance(distanceKm: number): string {
|
||||
return `${distanceKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км`;
|
||||
}
|
||||
|
||||
export function formatRaceDate(date: string): string {
|
||||
return parseRaceDate(date).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function sortByDateAsc(races: Race[]): Race[] {
|
||||
return [...races].sort((left, right) => parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
|
||||
}
|
||||
|
||||
export function sortByDateDesc(races: Race[]): Race[] {
|
||||
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
|
||||
}
|
||||
|
||||
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const upcoming: Race[] = [];
|
||||
const past: Race[] = [];
|
||||
|
||||
for (const race of races) {
|
||||
if (parseRaceDate(race.date).getTime() >= today.getTime()) {
|
||||
upcoming.push(race);
|
||||
} else {
|
||||
past.push(race);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upcoming: sortByDateAsc(upcoming),
|
||||
past: sortByDateDesc(past),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const target = parseRaceDate(date);
|
||||
const days = Math.ceil((target.getTime() - today.getTime()) / MS_IN_DAY);
|
||||
|
||||
if (days <= 0) {
|
||||
return "сегодня";
|
||||
}
|
||||
if (days === 1) {
|
||||
return "через 1 день";
|
||||
}
|
||||
if (days < 5) {
|
||||
return `через ${days} дня`;
|
||||
}
|
||||
return `через ${days} дней`;
|
||||
}
|
||||
|
||||
export function getPaceLabel(finishTime: string | null, distanceKm: number): string | null {
|
||||
const totalSeconds = parseFinishTimeToSeconds(finishTime);
|
||||
if (!totalSeconds || distanceKm <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paceSeconds = Math.round(totalSeconds / distanceKm);
|
||||
const paceMinutes = Math.floor(paceSeconds / 60);
|
||||
const paceRemainder = paceSeconds % 60;
|
||||
|
||||
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
|
||||
}
|
||||
|
||||
export function getRaceStatusLabel(status: Race["status"]): string {
|
||||
if (status === "completed") {
|
||||
return "пробежал";
|
||||
}
|
||||
return "планирую";
|
||||
}
|
||||
@@ -1,8 +1,276 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Race } from "../api";
|
||||
import { ApiError, getRaces } from "../api";
|
||||
import {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
getRaceCountdownLabel,
|
||||
getPaceLabel,
|
||||
parseFinishTimeToSeconds,
|
||||
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 "Не удалось загрузить данные dashboard.";
|
||||
}
|
||||
|
||||
function isSameDistance(left: number, right: number): boolean {
|
||||
return Math.abs(left - right) < 0.05;
|
||||
}
|
||||
|
||||
export function DashboardPage(): JSX.Element {
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadDashboardData(): Promise<void> {
|
||||
try {
|
||||
const items = await getRaces();
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setRaces(items);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDashboardData();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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) => new Date(`${race.date}T00:00:00`).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 (
|
||||
<section className="page page--dashboard">
|
||||
race.status === "completed" &&
|
||||
isSameDistance(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: new Date(`${race.date}T00:00:00`).getFullYear(),
|
||||
title: race.title,
|
||||
distance: formatDistance(race.distanceKm),
|
||||
finishTime: race.finishTime ?? "время не указано",
|
||||
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
|
||||
place: "нет данных",
|
||||
}))
|
||||
.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">Dashboard</h1>
|
||||
<p className="page__subtitle">Overview cards and quick actions will be added in the next task.</p>
|
||||
<p className="page__subtitle">Загружаем ваши старты...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<section className="page page--dashboard" role="alert">
|
||||
<h1 className="page__title">Dashboard</h1>
|
||||
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page page--dashboard">
|
||||
<h1 className="page__title">Dashboard</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">Недостаточно данных для PR.</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">PR по дистанциям</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">Нет completed-стартов для сравнения.</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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,8 +1,118 @@
|
||||
export function RacesPage(): JSX.Element {
|
||||
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";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.message;
|
||||
}
|
||||
return "Не удалось загрузить календарь стартов.";
|
||||
}
|
||||
|
||||
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
|
||||
const { title, races } = props;
|
||||
|
||||
return (
|
||||
<section className="page page--races">
|
||||
<h1 className="page__title">Races</h1>
|
||||
<p className="page__subtitle">Upcoming and completed race lists will be added in the next task.</p>
|
||||
<section className="race-list" aria-label={title}>
|
||||
<h2 className="race-list__title">{title}</h2>
|
||||
{races.length > 0 ? (
|
||||
<ul className="race-list__items">
|
||||
{races.map((race) => (
|
||||
<li key={race.id} className="race-card">
|
||||
<div className="race-card__main">
|
||||
<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>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
race.status === "completed"
|
||||
? "race-card__status race-card__status--completed"
|
||||
: "race-card__status race-card__status--planned"
|
||||
}
|
||||
>
|
||||
{getRaceStatusLabel(race.status)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="race-list__empty">Пока нет данных в этом разделе.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RacesPage(): JSX.Element {
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadRaces(): Promise<void> {
|
||||
try {
|
||||
const items = await getRaces();
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setRaces(items);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRaces();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="page page--races" aria-busy="true">
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
<p className="page__subtitle">Загружаем данные...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<section className="page page--races" role="alert">
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page page--races">
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
|
||||
|
||||
<div className="race-lists">
|
||||
<RaceList title="Будущие" races={upcoming} />
|
||||
<RaceList title="Прошедшие" races={past} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,3 +91,253 @@ a {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.page__subtitle--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.dashboard-card__title {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-card__value {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-h2);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dashboard-card__meta {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dashboard-card__hint,
|
||||
.dashboard-card__empty {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.dashboard-section__title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
.dashboard-grid--pr {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.comparison-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: var(--space-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comparison-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.race-lists {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.race-list {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.race-list__title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
.race-list__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.race-list__empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.race-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.race-card__title {
|
||||
margin: 0;
|
||||
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);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.race-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.race-card__status--planned {
|
||||
background: #edf3ff;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.race-card__status--completed {
|
||||
background: #ecf8f1;
|
||||
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-details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.race-details-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user