feat(frontend): race form, start time selects, calendar views, day page
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
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:
133
frontend/src/pages/RaceDayPage.tsx
Normal file
133
frontend/src/pages/RaceDayPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { ApiError, createRace, getRaceById, updateRace } from "../api";
|
||||
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
|
||||
import { StartTimeSelects } from "../components/StartTimeSelects";
|
||||
import { isRaceDateInPast } from "../lib";
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
@@ -94,6 +96,7 @@ function validateForm(form: FormData): string[] {
|
||||
export function RaceFormPage(): JSX.Element {
|
||||
const { raceId } = useParams<{ raceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isEditMode = Boolean(raceId);
|
||||
|
||||
const [form, setForm] = useState<FormData>(EMPTY_FORM);
|
||||
@@ -135,6 +138,16 @@ export function RaceFormPage(): JSX.Element {
|
||||
};
|
||||
}, [raceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) {
|
||||
return;
|
||||
}
|
||||
const d = searchParams.get("date");
|
||||
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) {
|
||||
setForm((prev) => (prev.date === d ? prev : { ...prev, date: d }));
|
||||
}
|
||||
}, [isEditMode, searchParams]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = event.target;
|
||||
@@ -214,6 +227,7 @@ export function RaceFormPage(): JSX.Element {
|
||||
[form, isEditMode, raceId, navigate],
|
||||
);
|
||||
|
||||
const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date);
|
||||
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
|
||||
|
||||
if (isLoading) {
|
||||
@@ -303,51 +317,57 @@ export function RaceFormPage(): JSX.Element {
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Организация</legend>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Сайт организатора</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="url"
|
||||
name="officialUrl"
|
||||
value={form.officialUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
{hideOrgScheduleFields ? null : (
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Сайт организатора</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="url"
|
||||
name="officialUrl"
|
||||
value={form.officialUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Время старта</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="startTime"
|
||||
value={form.startTime}
|
||||
onChange={handleChange}
|
||||
placeholder="09:30"
|
||||
/>
|
||||
</label>
|
||||
{hideOrgScheduleFields ? null : (
|
||||
<div className="race-form__field">
|
||||
<span className="race-form__label">Время старта</span>
|
||||
<StartTimeSelects
|
||||
value={form.startTime}
|
||||
onChange={(next) => {
|
||||
setForm((prev) => ({ ...prev, startTime: next }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Расписание кластеров</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="clusterSchedule"
|
||||
value={form.clusterSchedule}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
{hideOrgScheduleFields ? null : (
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Расписание кластеров</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="clusterSchedule"
|
||||
value={form.clusterSchedule}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Выдача номеров</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="bibPickup"
|
||||
value={form.bibPickup}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
{hideOrgScheduleFields ? null : (
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Выдача номеров</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="bibPickup"
|
||||
value={form.bibPickup}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Стартовый номер</span>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user