Files
runners-calendar/frontend/src/pages/RacesPage.tsx
Anton 3b8f41f905
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
fix: phase 1 bugs — CSS tokens, pluralization, error handling, cross-platform tests
- Add missing --space-1 CSS token used by filter and detail components

- Fix active nav link losing styles on hover (CSS specificity)

- Correct Russian day pluralization (21 день, 22 дня, 25 дней)

- Show filter error banner even when stale race data is present

- Add cross-env for Windows-compatible npm test

- Add global JSON error handler in Express for malformed request bodies

- Replace stateless mock DB with in-memory store for correct DELETE/UPDATE behavior

Made-with: Cursor
2026-04-07 17:46:46 +03:00

203 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race, RacesQuery } from "../api";
import { ApiError, getRaces } from "../api";
import {
formatDistance,
formatRaceDate,
getRaceStatusClassName,
getRaceStatusLabel,
splitRacesByDate,
} from "../lib";
const MONTH_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "Все месяцы" },
{ value: "1", label: "Январь" },
{ value: "2", label: "Февраль" },
{ value: "3", label: "Март" },
{ value: "4", label: "Апрель" },
{ value: "5", label: "Май" },
{ value: "6", label: "Июнь" },
{ value: "7", label: "Июль" },
{ value: "8", label: "Август" },
{ value: "9", label: "Сентябрь" },
{ value: "10", label: "Октябрь" },
{ value: "11", label: "Ноябрь" },
{ value: "12", label: "Декабрь" },
];
function yearSelectOptions(): number[] {
const current = new Date().getFullYear();
const start = current - 2;
const end = current + 4;
const years: number[] = [];
for (let y = start; y <= end; y += 1) {
years.push(y);
}
return years;
}
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="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={getRaceStatusClassName(race.status)}>{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);
const [yearFilter, setYearFilter] = useState<string>("");
const [monthFilter, setMonthFilter] = useState<string>("");
const listQuery = useMemo((): RacesQuery | undefined => {
const q: RacesQuery = {};
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
if (!Number.isNaN(y)) {
q.year = y;
}
}
if (monthFilter !== "") {
const m = parseInt(monthFilter, 10);
if (!Number.isNaN(m)) {
q.month = m;
}
}
return Object.keys(q).length > 0 ? q : undefined;
}, [yearFilter, monthFilter]);
useEffect(() => {
let isMounted = true;
async function loadRaces(): Promise<void> {
setIsLoading(true);
try {
const items = await getRaces(listQuery);
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;
};
}, [listQuery]);
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
if (errorMessage && races.length === 0 && !isLoading) {
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>
{errorMessage && !isLoading ? (
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}>
{errorMessage}
</p>
) : null}
<div className="races-filter" role="search" aria-label="Фильтр по дате">
<label className="races-filter__field">
<span className="races-filter__label">Год</span>
<select
className="races-filter__select"
value={yearFilter}
onChange={(event) => {
setYearFilter(event.target.value);
}}
>
<option value="">Все года</option>
{yearSelectOptions().map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Месяц</span>
<select
className="races-filter__select"
value={monthFilter}
onChange={(event) => {
setMonthFilter(event.target.value);
}}
>
{MONTH_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем данные...
</p>
) : null}
<div className="race-lists">
<RaceList title="Будущие" races={upcoming} />
<RaceList title="Прошедшие" races={past} />
</div>
</section>
);
}