feat(frontend): race form, start time selects, calendar views, day page
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

- Hide org schedule fields when editing a past race; isRaceDateInPast helper
- StartTimeSelects (HH:mm:ss) and optional ?date= prefill on new race
- Full-card link to edit for races needing result entry; shadow token
- List/calendar toggle (sessionStorage); year grid and month focus views
- Date hover popover and /races/day/:ymd page with Add button
- Docs plan-korrektirovok-starty.md and startTime API note; client 0.4.0

Made-with: Cursor
This commit is contained in:
Vaka.pro
2026-04-13 22:07:37 +03:00
parent b997dcb01e
commit 3c6baa66a1
16 changed files with 1193 additions and 69 deletions

View File

@@ -0,0 +1,133 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel, sortByDateAsc } from "../lib";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить старты.";
}
export function RaceDayPage(): JSX.Element {
const { ymd } = useParams<{ ymd: string }>();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const validYmd = ymd && /^\d{4}-\d{2}-\d{2}$/.test(ymd) ? ymd : null;
const year = validYmd ? parseInt(validYmd.slice(0, 4), 10) : NaN;
useEffect(() => {
if (!validYmd || Number.isNaN(year)) {
setIsLoading(false);
setRaces([]);
return;
}
const ac = new AbortController();
let mounted = true;
async function load(): Promise<void> {
setIsLoading(true);
try {
const items = await getRaces({ year }, { signal: ac.signal });
if (!mounted || ac.signal.aborted) {
return;
}
const forDay = items.filter((r) => r.date.slice(0, 10) === validYmd);
setRaces(sortByDateAsc(forDay));
setErrorMessage(null);
} catch (e) {
if (ac.signal.aborted || !mounted) {
return;
}
setErrorMessage(getErrorMessage(e));
setRaces([]);
} finally {
if (mounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
}
void load();
return () => {
mounted = false;
ac.abort();
};
}, [validYmd, year]);
const heading = useMemo(() => {
if (!validYmd) {
return "Дата не указана";
}
return formatRaceDate(validYmd);
}, [validYmd]);
if (!validYmd) {
return (
<section className="page page--race-day">
<h1 className="page__title">Некорректная дата</h1>
<p className="page__subtitle">
<Link className="page-link" to="/races">
Вернуться к календарю стартов
</Link>
</p>
</section>
);
}
return (
<section className="page page--race-day">
<p className="page__subtitle">
<Link className="page-link" to="/races">
Календарь стартов
</Link>
</p>
<h1 className="page__title">{heading}</h1>
{errorMessage ? (
<p className="page__subtitle page__subtitle--error" role="alert">
{errorMessage}
</p>
) : null}
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем
</p>
) : null}
{!isLoading && !errorMessage && races.length === 0 ? (
<p className="page__subtitle">На эту дату стартов нет.</p>
) : null}
{!isLoading && races.length > 0 ? (
<ul className="race-day__list">
{races.map((race) => (
<li key={race.id} className="race-day__item">
<Link className="race-day__link" to={`/races/${race.id}`}>
{race.title}
</Link>
<span className="race-day__meta">
{formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</span>
</li>
))}
</ul>
) : null}
<div className="race-day__actions">
<Link className="btn btn--primary" to={`/races/new?date=${validYmd}`}>
Добавить
</Link>
</div>
</section>
);
}