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, getRaceVisual, getRaceStatusClassName, getRaceStatusLabel, parseRaceDate, sortByDateAsc, sortByDateDesc, } 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: "Декабрь" }, ]; const VIEW_STORAGE_KEY = "races-view-mode"; type ViewMode = "list" | "calendar"; 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 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; return ( {title} {races.length > 0 ? ( {races.map((race) => { const visual = getRaceVisual(race); const parsedDate = parseRaceDate(race.date); const day = parsedDate.toLocaleDateString("ru-RU", { day: "2-digit" }); const month = parsedDate.toLocaleDateString("ru-RU", { month: "short" }); return ( { event.currentTarget.onerror = null; event.currentTarget.classList.remove("race-card__image--contain"); event.currentTarget.src = visual.fallbackSrc; }} /> {day} {month} {visual.label} {race.title} {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} {getRaceStatusLabel(race.status, race.date)} Открыть ); })} ) : ( Пока нет данных в этом разделе. )} ); } export function RacesPage(): JSX.Element { const [races, setRaces] = useState([]); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); const [yearFilter, setYearFilter] = useState(""); const [monthFilter, setMonthFilter] = useState(""); const [viewMode, setViewMode] = useState(() => 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); 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; }, [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(); let isMounted = true; async function loadRaces(): Promise { setIsLoading(true); try { const items = await getRaces(listQuery, { signal: ac.signal }); if (!isMounted || ac.signal.aborted) { return; } setRaces(items); setErrorMessage(null); } catch (error) { if (ac.signal.aborted || !isMounted) { return; } setErrorMessage(getErrorMessage(error)); } finally { if (isMounted && !ac.signal.aborted) { setIsLoading(false); } } } void loadRaces(); return () => { isMounted = false; ac.abort(); }; }, [listQuery]); const { upcoming, completed } = useMemo( () => ({ upcoming: sortByDateAsc(races.filter((race) => race.status !== "completed")), completed: sortByDateDesc(races.filter((race) => race.status === "completed")), }), [races], ); const statusMessage = useMemo(() => { if (errorMessage && !isLoading) { return errorMessage; } if (isLoading) { return "Загружаем данные..."; } if (viewMode === "calendar" && monthFilter === "") { return "Выберите месяц, чтобы увидеть его крупным планом."; } return ""; }, [errorMessage, isLoading, monthFilter, viewMode]); const statusClassName = [ "races-status__message", !statusMessage ? "races-status__message--empty" : "", errorMessage && !isLoading ? "races-status__message--error" : "", ] .filter(Boolean) .join(" "); if (errorMessage && races.length === 0 && !isLoading) { return ( Календарь стартов {errorMessage} ); } return ( Сезонная афиша Календарь стартов Будущие и прошедшие старты в одном месте. Список Календарь Год { setYearFilter(event.target.value); }} > {viewMode === "list" ? Все года : null} {yearSelectOptions().map((y) => ( {y} ))} Месяц { setMonthFilter(event.target.value); }} > {MONTH_OPTIONS.map((opt) => ( {opt.label} ))} {statusMessage || "\u00a0"} {viewMode === "list" ? ( ) : ( )} ); }
{visual.label}
{race.title}
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
Пока нет данных в этом разделе.
{errorMessage}
Сезонная афиша
Будущие и прошедшие старты в одном месте.
{statusMessage || "\u00a0"}