Compare commits

..

5 Commits

Author SHA1 Message Date
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
Anton
a332703e2f feat(frontend): add dashboard and race calendar views
Implement dashboard metrics and split race lists with BEM-styled cards using the typed races API.

Made-with: Cursor
2026-04-06 17:17:08 +03:00
1d89e2bce2 Merge pull request 'feat(frontend): add typed API contract layer' (#1) from feat/backend-postgres-migrations into main
Reviewed-on: #1
2026-04-06 13:47:17 +00:00
Anton
800fbfa560 docs: fix plan 2026-04-06 16:14:14 +03:00
8 changed files with 768 additions and 9 deletions

2
.gitignore vendored
View File

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

View File

@@ -2,6 +2,7 @@ import { createBrowserRouter } from "react-router-dom";
import { AppLayout } from "./layouts/AppLayout"; import { AppLayout } from "./layouts/AppLayout";
import { DashboardPage } from "../pages/DashboardPage"; import { DashboardPage } from "../pages/DashboardPage";
import { RacesPage } from "../pages/RacesPage"; import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
export const appRouter = createBrowserRouter([ export const appRouter = createBrowserRouter([
{ {
@@ -9,7 +10,8 @@ export const appRouter = createBrowserRouter([
element: <AppLayout />, element: <AppLayout />,
children: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: "races", element: <RacesPage /> } { path: "races", element: <RacesPage /> },
] { path: "races/:raceId", element: <RaceDetailsPage /> },
} ],
},
]); ]);

View File

@@ -1 +1,11 @@
export {}; export {
formatDistance,
formatRaceDate,
getPaceLabel,
getRaceCountdownLabel,
getRaceStatusLabel,
parseFinishTimeToSeconds,
sortByDateAsc,
sortByDateDesc,
splitRacesByDate,
} from "./raceMetrics";

View 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 "планирую";
}

View File

@@ -1,8 +1,167 @@
import { useEffect, useMemo, useState } from "react";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import {
formatDistance,
formatRaceDate,
getRaceCountdownLabel,
parseFinishTimeToSeconds,
splitRacesByDate,
} from "../lib";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить данные dashboard.";
}
export function DashboardPage(): JSX.Element { 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]);
if (isLoading) {
return ( return (
<section className="page page--dashboard"> <section className="page page--dashboard" aria-busy="true">
<h1 className="page__title">Dashboard</h1> <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> </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,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 ( return (
<section className="page page--races"> <section className="race-list" aria-label={title}>
<h1 className="page__title">Races</h1> <h2 className="race-list__title">{title}</h2>
<p className="page__subtitle">Upcoming and completed race lists will be added in the next task.</p> {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> </section>
); );
} }

View File

@@ -91,3 +91,210 @@ a {
margin: 0; margin: 0;
color: var(--color-text-muted); 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);
}
.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;
}
}