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:
248
frontend/src/components/RacesCalendar.tsx
Normal file
248
frontend/src/components/RacesCalendar.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import type { Race } from "../api";
|
||||
import { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
|
||||
|
||||
const MONTH_NAMES_RU_SHORT = [
|
||||
"янв.",
|
||||
"февр.",
|
||||
"мар.",
|
||||
"апр.",
|
||||
"мая",
|
||||
"июн.",
|
||||
"июл.",
|
||||
"авг.",
|
||||
"сент.",
|
||||
"окт.",
|
||||
"нояб.",
|
||||
"дек.",
|
||||
];
|
||||
|
||||
const POPOVER_LEAVE_MS = 140;
|
||||
|
||||
interface RacesCalendarProps {
|
||||
displayYear: number;
|
||||
monthFilter: string;
|
||||
races: Race[];
|
||||
onMonthFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function DayPopover(props: {
|
||||
ymd: string;
|
||||
races: Race[];
|
||||
onCancelClose: () => void;
|
||||
onScheduleClose: () => void;
|
||||
}): JSX.Element {
|
||||
const { ymd, races, onCancelClose, onScheduleClose } = props;
|
||||
const hasRaces = races.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="races-cal__popover"
|
||||
role="tooltip"
|
||||
onMouseEnter={onCancelClose}
|
||||
onMouseLeave={onScheduleClose}
|
||||
>
|
||||
{hasRaces ? (
|
||||
<ul className="races-cal__popover-list">
|
||||
{races.map((r) => (
|
||||
<li key={r.id} className="races-cal__popover-item">
|
||||
<Link className="races-cal__popover-link" to={`/races/${r.id}`} onClick={onCancelClose}>
|
||||
{r.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="races-cal__popover-empty">Стартов нет</p>
|
||||
)}
|
||||
<Link
|
||||
className="btn btn--secondary races-cal__popover-add"
|
||||
to={`/races/new?date=${ymd}`}
|
||||
onClick={onCancelClose}
|
||||
>
|
||||
Добавить
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarMonthBlock(props: {
|
||||
year: number;
|
||||
monthIndex: number;
|
||||
racesByYmd: Map<string, Race[]>;
|
||||
compact: boolean;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
openYmd: string | null;
|
||||
setOpenYmd: (v: string | null) => void;
|
||||
scheduleClose: () => void;
|
||||
cancelClose: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
year,
|
||||
monthIndex,
|
||||
racesByYmd,
|
||||
compact,
|
||||
navigate,
|
||||
openYmd,
|
||||
setOpenYmd,
|
||||
scheduleClose,
|
||||
cancelClose,
|
||||
} = props;
|
||||
const cells = useMemo(() => buildMonthCells(year, monthIndex), [year, monthIndex]);
|
||||
const title = `${MONTH_NAMES_RU_SHORT[monthIndex]} ${year}`;
|
||||
|
||||
const blockClass = compact ? "races-cal__month races-cal__month--compact" : "races-cal__month";
|
||||
|
||||
return (
|
||||
<div className={blockClass}>
|
||||
<h3 className="races-cal__month-title">{title}</h3>
|
||||
<div className="races-cal__weekdays" aria-hidden>
|
||||
{WEEKDAY_LABELS_SHORT_RU.map((d) => (
|
||||
<span key={d} className="races-cal__weekday">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="races-cal__cells">
|
||||
{cells.map((day, idx) => {
|
||||
if (day === null) {
|
||||
return <div key={`e-${idx}`} className="races-cal__cell races-cal__cell--empty" />;
|
||||
}
|
||||
const ymd = toYmd(year, monthIndex, day);
|
||||
const dayRaces = racesByYmd.get(ymd) ?? [];
|
||||
const hasRaces = dayRaces.length > 0;
|
||||
const isOpen = openYmd === ymd;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ymd}
|
||||
className={`races-cal__cell${hasRaces ? " races-cal__cell--has-race" : ""}${isOpen ? " races-cal__cell--open" : ""}`}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpenYmd(ymd);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="races-cal__day-btn"
|
||||
onClick={() => {
|
||||
navigate(`/races/day/${ymd}`);
|
||||
}}
|
||||
onFocus={() => {
|
||||
cancelClose();
|
||||
setOpenYmd(ymd);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (next && e.currentTarget.closest(".races-cal__cell")?.contains(next)) {
|
||||
return;
|
||||
}
|
||||
scheduleClose();
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<DayPopover
|
||||
ymd={ymd}
|
||||
races={dayRaces}
|
||||
onCancelClose={cancelClose}
|
||||
onScheduleClose={scheduleClose}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
|
||||
const { displayYear, monthFilter, races, onMonthFilterChange } = props;
|
||||
const navigate = useNavigate();
|
||||
const [openYmd, setOpenYmd] = useState<string | null>(null);
|
||||
const leaveTimerRef = useRef<number | null>(null);
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
if (leaveTimerRef.current !== null) {
|
||||
window.clearTimeout(leaveTimerRef.current);
|
||||
leaveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
cancelClose();
|
||||
leaveTimerRef.current = window.setTimeout(() => {
|
||||
setOpenYmd(null);
|
||||
leaveTimerRef.current = null;
|
||||
}, POPOVER_LEAVE_MS);
|
||||
}, [cancelClose]);
|
||||
|
||||
const racesByYmd = useMemo(() => groupRacesByYmd(races), [races]);
|
||||
|
||||
const focusedMonthIndex = monthFilter === "" ? null : parseInt(monthFilter, 10) - 1;
|
||||
|
||||
return (
|
||||
<div className="races-cal">
|
||||
<p className="races-cal__hint">Наведите на дату — краткая информация. Клик — страница дня.</p>
|
||||
{focusedMonthIndex === null || Number.isNaN(focusedMonthIndex) ? (
|
||||
<div className="races-cal__year">
|
||||
{MONTH_NAMES_RU_SHORT.map((_, mi) => (
|
||||
<CalendarMonthBlock
|
||||
key={mi}
|
||||
year={displayYear}
|
||||
monthIndex={mi}
|
||||
racesByYmd={racesByYmd}
|
||||
compact
|
||||
navigate={navigate}
|
||||
openYmd={openYmd}
|
||||
setOpenYmd={setOpenYmd}
|
||||
scheduleClose={scheduleClose}
|
||||
cancelClose={cancelClose}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="races-cal__month-focus">
|
||||
<nav className="races-cal__month-nav" aria-label="Месяцы года">
|
||||
{MONTH_NAMES_RU_SHORT.map((label, mi) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`races-cal__month-nav-item${mi === focusedMonthIndex ? " races-cal__month-nav-item--active" : ""}`}
|
||||
onClick={() => {
|
||||
onMonthFilterChange(String(mi + 1));
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="races-cal__month-nav-item races-cal__month-nav-item--all"
|
||||
onClick={() => {
|
||||
onMonthFilterChange("");
|
||||
}}
|
||||
>
|
||||
Весь год
|
||||
</button>
|
||||
</nav>
|
||||
<CalendarMonthBlock
|
||||
year={displayYear}
|
||||
monthIndex={focusedMonthIndex}
|
||||
racesByYmd={racesByYmd}
|
||||
compact={false}
|
||||
navigate={navigate}
|
||||
openYmd={openYmd}
|
||||
setOpenYmd={setOpenYmd}
|
||||
scheduleClose={scheduleClose}
|
||||
cancelClose={cancelClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user