Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- Set status to completed when finish time parses (input + submit) - Dashboard: last personal record by recent date+time; links on top 3 cards - Hover scale+shadow on all dashboard-card; linked card padding via BEM - Race list: full row links to race detail; same hover as before - Calendar year grid: 3 columns, 2 on tablet, 1 on narrow - Version 0.4.1 Made-with: Cursor
291 lines
9.1 KiB
TypeScript
291 lines
9.1 KiB
TypeScript
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,
|
||
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: "Декабрь" },
|
||
];
|
||
|
||
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 (
|
||
<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 race-card--action">
|
||
<Link
|
||
className="race-card__link-surface"
|
||
to={`/races/${race.id}`}
|
||
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>
|
||
</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 [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);
|
||
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<void> {
|
||
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, 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>
|
||
|
||
<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}
|
||
</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={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
||
onChange={(event) => {
|
||
setYearFilter(event.target.value);
|
||
}}
|
||
>
|
||
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||
{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>
|
||
|
||
{viewMode === "calendar" && monthFilter === "" ? (
|
||
<p className="page__subtitle races-cal__filter-hint">Выберите месяц, чтобы увидеть его крупным планом.</p>
|
||
) : null}
|
||
|
||
{isLoading ? (
|
||
<p className="page__subtitle" aria-busy="true">
|
||
Загружаем данные...
|
||
</p>
|
||
) : null}
|
||
|
||
{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>
|
||
);
|
||
}
|