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

@@ -1,12 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race, RacesQuery } from "../api";
import { ApiError, getRaces } from "../api";
import { RacesCalendar } from "../components/RacesCalendar";
import {
formatDistance,
formatRaceDate,
getRaceStatusClassName,
getRaceStatusLabel,
raceNeedsResultEntry,
splitRacesByDate,
} from "../lib";
@@ -26,6 +28,10 @@ const MONTH_OPTIONS: { value: string; label: string }[] = [
{ value: "12", label: "Декабрь" },
];
const VIEW_STORAGE_KEY = "races-view-mode";
type ViewMode = "list" | "calendar";
function yearSelectOptions(): number[] {
const current = new Date().getFullYear();
const start = current - 2;
@@ -44,6 +50,15 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить календарь стартов.";
}
function readInitialViewMode(): ViewMode {
try {
const v = sessionStorage.getItem(VIEW_STORAGE_KEY);
return v === "calendar" ? "calendar" : "list";
} catch {
return "list";
}
}
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
const { title, races } = props;
@@ -52,21 +67,49 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
<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}
{races.map((race) => {
const needsResult = raceNeedsResultEntry(race);
if (needsResult) {
return (
<li key={race.id} className="race-card race-card--action">
<Link
className="race-card__link-surface"
to={`/races/${race.id}/edit`}
aria-label={`${race.title}, внести результат`}
>
<div className="race-card__main">
<p className="race-card__title">
<span className="race-card__title-text">{race.title}</span>
</p>
<p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</Link>
</p>
<p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
</li>
))}
</li>
);
}
return (
<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, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</li>
);
})}
</ul>
) : (
<p className="race-list__empty">Пока нет данных в этом разделе.</p>
@@ -81,8 +124,34 @@ export function RacesPage(): JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [yearFilter, setYearFilter] = useState<string>("");
const [monthFilter, setMonthFilter] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>(() => readInitialViewMode());
const setViewModePersist = useCallback((mode: ViewMode) => {
setViewMode(mode);
try {
sessionStorage.setItem(VIEW_STORAGE_KEY, mode);
} catch {
/* ignore */
}
}, []);
const handleViewList = useCallback(() => {
setViewModePersist("list");
}, [setViewModePersist]);
const handleViewCalendar = useCallback(() => {
setViewModePersist("calendar");
setYearFilter((prev) => (prev === "" ? String(new Date().getFullYear()) : prev));
}, [setViewModePersist]);
const listQuery = useMemo((): RacesQuery | undefined => {
if (viewMode === "calendar") {
const y = yearFilter !== "" ? parseInt(yearFilter, 10) : new Date().getFullYear();
if (!Number.isNaN(y)) {
return { year: y };
}
return undefined;
}
const q: RacesQuery = {};
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
@@ -97,7 +166,15 @@ export function RacesPage(): JSX.Element {
}
}
return Object.keys(q).length > 0 ? q : undefined;
}, [yearFilter, monthFilter]);
}, [viewMode, yearFilter, monthFilter]);
const displayYear = useMemo(() => {
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
return Number.isNaN(y) ? new Date().getFullYear() : y;
}
return new Date().getFullYear();
}, [yearFilter]);
useEffect(() => {
const ac = new AbortController();
@@ -147,6 +224,23 @@ export function RacesPage(): JSX.Element {
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
<div className="races-view-toggle" role="group" aria-label="Вид отображения">
<button
type="button"
className={`races-view-toggle__btn${viewMode === "list" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewList}
>
Список
</button>
<button
type="button"
className={`races-view-toggle__btn${viewMode === "calendar" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewCalendar}
>
Календарь
</button>
</div>
{errorMessage && !isLoading ? (
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}>
{errorMessage}
@@ -158,12 +252,12 @@ export function RacesPage(): JSX.Element {
<span className="races-filter__label">Год</span>
<select
className="races-filter__select"
value={yearFilter}
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
onChange={(event) => {
setYearFilter(event.target.value);
}}
>
<option value="">Все года</option>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions().map((y) => (
<option key={y} value={String(y)}>
{y}
@@ -189,16 +283,31 @@ export function RacesPage(): JSX.Element {
</label>
</div>
{viewMode === "calendar" && monthFilter === "" ? (
<p className="page__subtitle races-cal__filter-hint">Выберите месяц, чтобы увидеть его крупным планом.</p>
) : null}
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем данные...
</p>
) : null}
<div className="race-lists">
<RaceList title="Будущие" races={upcoming} />
<RaceList title="Прошедшие" races={past} />
</div>
{viewMode === "list" ? (
<div className="race-lists">
<RaceList title="Будущие" races={upcoming} />
<RaceList title="Прошедшие" races={past} />
</div>
) : (
<div className="races-cal-wrap">
<RacesCalendar
displayYear={displayYear}
monthFilter={monthFilter}
races={races}
onMonthFilterChange={setMonthFilter}
/>
</div>
)}
</section>
);
}