Compare commits

...

4 Commits

Author SHA1 Message Date
Anton
7845d8d961 feat(dashboard): add PR by distance and race comparison table
Made-with: Cursor
2026-04-06 19:07:13 +03:00
0ddf37683a Merge pull request 'feat(frontend): add race details page with completed metrics' (#3) from feat/race-details-pace-metrics into main
Reviewed-on: #3
2026-04-06 15:02:14 +00:00
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
36e103dd41 Merge pull request 'feat(frontend): add dashboard and race calendar views' (#2) from feat/dashboard-and-calendar into main
Reviewed-on: #2
2026-04-06 14:20:41 +00:00
6 changed files with 408 additions and 5 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules/
dist/
.env
*.log
*plan*
*PLAN*

View File

@@ -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 /> },
],
},
]);

View File

@@ -5,10 +5,13 @@ 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;
@@ -16,6 +19,10 @@ function getErrorMessage(error: unknown): string {
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);
@@ -86,6 +93,54 @@ export function DashboardPage(): JSX.Element {
};
}, [races]);
const personalRecordsByDistance = useMemo(() => {
return PR_DISTANCES.map((distanceKm) => {
const candidates = races.filter((race) => {
return (
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">
@@ -162,6 +217,60 @@ export function DashboardPage(): JSX.Element {
<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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -135,6 +135,49 @@ a {
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;
@@ -183,6 +226,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 +258,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;
}
}