diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index b8813ea..7b1041b 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -207,7 +207,7 @@ GET /api/races?year=2026&month=5 | `distanceKm` | number | да | да | Дистанция в км | | `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` | | `officialUrl` | string \| null | нет | да | URL организатора | -| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` | +| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) | | `clusterSchedule` | string \| null | нет | да | Расписание кластеров | | `bibPickup` | string \| null | нет | да | Выдача номеров | | `bibNumber` | string \| null | нет | да | Стартовый номер | diff --git a/docs/plan-korrektirovok-starty.md b/docs/plan-korrektirovok-starty.md new file mode 100644 index 0000000..2678e62 --- /dev/null +++ b/docs/plan-korrektirovok-starty.md @@ -0,0 +1,52 @@ +# План корректировок: форма старта, время, календарь стартов + +Краткое описание реализованных изменений в клиенте **runners-calendar** (версия клиента см. в футере приложения). + +## 1. Форма старта (редактирование прошедшего события) + +При **редактировании** старта, чья **дата уже в прошлом**, в блоке «Организация» скрыты поля, неактуальные после забега: + +- сайт организатора; +- время старта; +- расписание кластеров; +- выдача номеров. + +Значения по-прежнему хранятся в состоянии формы и отправляются при сохранении (не затираются). Утилита: `isRaceDateInPast` в `frontend/src/lib/raceMetrics.ts`. + +## 2. Время старта + +Вместо свободного текста — три селекта (часы, минуты, секунды), компонент `StartTimeSelects` в `frontend/src/components/StartTimeSelects.tsx`. Сохраняется строка `HH:mm:ss` или пусто → `null` в API. Поддерживается разбор старых значений `HH:mm` при загрузке. + +## 3. Список на странице «Календарь стартов» + +Для стартов со статусом **«внесите результат»** вся карточка — ссылка на `/races/:id/edit` с лёгким увеличением и тенью при наведении/фокусе (токен `--shadow-card-lift`). + +## 4. Виды: список и календарь + +- Переключатель **Список / Календарь**, выбор сохраняется в `sessionStorage` (`races-view-mode`). +- **Календарь:** загрузка гонок за выбранный **год** (без фильтра месяца в запросе), отображение сетки месяцев. +- При выборе **месяца** в фильтре — крупная сетка этого месяца и компактная навигация по остальным месяцам + «Весь год». + +## 5. Ячейка даты в календаре + +- Наведение или фокус: всплывающая панель — либо «Стартов нет» и кнопка **Добавить** (`/races/new?date=YYYY-MM-DD`), либо список стартов со ссылками на карточки и **Добавить**. +- Клик по числу — страница дня `/races/day/:ymd`. + +## 6. Страница дня + +Маршрут `races/day/:ymd`: список стартов на дату, пустое состояние, кнопка **Добавить** с предзаполнением даты через query. + +## 7. Новый старт с датой из календаря + +`RaceFormPage` читает query-параметр `?date=YYYY-MM-DD` при создании старта. + +## Основные файлы + +| Область | Файлы | +|--------|--------| +| Метрики даты | `frontend/src/lib/raceMetrics.ts`, `frontend/src/lib/calendarUtils.ts` | +| Форма | `frontend/src/pages/RaceFormPage.tsx`, `frontend/src/components/StartTimeSelects.tsx` | +| Список и календарь | `frontend/src/pages/RacesPage.tsx`, `frontend/src/components/RacesCalendar.tsx` | +| День | `frontend/src/pages/RaceDayPage.tsx`, `frontend/src/app/router.tsx` | +| Стили | `frontend/src/styles/global.css`, `frontend/src/styles/tokens.css` | +| API-док | `docs/backend-api-for-frontend.md` (формат `startTime`) | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cf608e6..10a354d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.3.1", + "version": "0.4.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 1e6af99..a2a58cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.3.1", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index d802e2e..307e461 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -4,6 +4,7 @@ import { DashboardPage } from "../pages/DashboardPage"; import { RacesPage } from "../pages/RacesPage"; import { RaceDetailsPage } from "../pages/RaceDetailsPage"; import { RaceFormPage } from "../pages/RaceFormPage"; +import { RaceDayPage } from "../pages/RaceDayPage"; export const appRouter = createBrowserRouter([ { @@ -13,6 +14,7 @@ export const appRouter = createBrowserRouter([ { index: true, element: }, { path: "races", element: }, { path: "races/new", element: }, + { path: "races/day/:ymd", element: }, { path: "races/:raceId", element: }, { path: "races/:raceId/edit", element: }, ], diff --git a/frontend/src/components/RacesCalendar.tsx b/frontend/src/components/RacesCalendar.tsx new file mode 100644 index 0000000..895a264 --- /dev/null +++ b/frontend/src/components/RacesCalendar.tsx @@ -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 ( +
+ {hasRaces ? ( +
    + {races.map((r) => ( +
  • + + {r.title} + +
  • + ))} +
+ ) : ( +

Стартов нет

+ )} + + Добавить + +
+ ); +} + +function CalendarMonthBlock(props: { + year: number; + monthIndex: number; + racesByYmd: Map; + compact: boolean; + navigate: ReturnType; + 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 ( +
+

{title}

+
+ {WEEKDAY_LABELS_SHORT_RU.map((d) => ( + + {d} + + ))} +
+
+ {cells.map((day, idx) => { + if (day === null) { + return
; + } + const ymd = toYmd(year, monthIndex, day); + const dayRaces = racesByYmd.get(ymd) ?? []; + const hasRaces = dayRaces.length > 0; + const isOpen = openYmd === ymd; + + return ( +
{ + cancelClose(); + setOpenYmd(ymd); + }} + onMouseLeave={scheduleClose} + > + + {isOpen ? ( + + ) : null} +
+ ); + })} +
+
+ ); +} + +export function RacesCalendar(props: RacesCalendarProps): JSX.Element { + const { displayYear, monthFilter, races, onMonthFilterChange } = props; + const navigate = useNavigate(); + const [openYmd, setOpenYmd] = useState(null); + const leaveTimerRef = useRef(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 ( +
+

Наведите на дату — краткая информация. Клик — страница дня.

+ {focusedMonthIndex === null || Number.isNaN(focusedMonthIndex) ? ( +
+ {MONTH_NAMES_RU_SHORT.map((_, mi) => ( + + ))} +
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/StartTimeSelects.tsx b/frontend/src/components/StartTimeSelects.tsx new file mode 100644 index 0000000..89996fb --- /dev/null +++ b/frontend/src/components/StartTimeSelects.tsx @@ -0,0 +1,157 @@ +import { useCallback, useMemo } from "react"; + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +function parseToParts(value: string): { h: number | null; m: number | null; s: number | null } { + const t = value.trim(); + if (!t) { + return { h: null, m: null, s: null }; + } + const parts = t.split(":").map((p) => p.trim()); + if (parts.length === 2) { + const h = Number(parts[0]); + const m = Number(parts[1]); + if (Number.isInteger(h) && Number.isInteger(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) { + return { h, m, s: 0 }; + } + } + if (parts.length >= 3) { + const h = Number(parts[0]); + const m = Number(parts[1]); + const s = Number(parts[2]); + if ( + Number.isInteger(h) && + Number.isInteger(m) && + Number.isInteger(s) && + h >= 0 && + h <= 23 && + m >= 0 && + m <= 59 && + s >= 0 && + s <= 59 + ) { + return { h, m, s }; + } + } + return { h: null, m: null, s: null }; +} + +function partsToString(h: number | null, m: number | null, s: number | null): string { + if (h === null || m === null || s === null) { + return ""; + } + return `${pad2(h)}:${pad2(m)}:${pad2(s)}`; +} + +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const MIN_SEC = Array.from({ length: 60 }, (_, i) => i); + +interface StartTimeSelectsProps { + value: string; + onChange: (next: string) => void; + disabled?: boolean; +} + +export function StartTimeSelects(props: StartTimeSelectsProps): JSX.Element { + const { value, onChange, disabled } = props; + const { h, m, s } = useMemo(() => parseToParts(value), [value]); + + const emit = useCallback( + (nextH: number | null, nextM: number | null, nextS: number | null) => { + onChange(partsToString(nextH, nextM, nextS)); + }, + [onChange], + ); + + const hourVal = h === null ? "" : String(h); + const minVal = m === null ? "" : String(m); + const secVal = s === null ? "" : String(s); + + return ( +
+ + + : + + + + : + + +
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 07eecb3..7de53f0 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1 +1,3 @@ export { PaceTrendChart } from "./PaceTrendChart"; +export { RacesCalendar } from "./RacesCalendar"; +export { StartTimeSelects } from "./StartTimeSelects"; diff --git a/frontend/src/lib/calendarUtils.ts b/frontend/src/lib/calendarUtils.ts new file mode 100644 index 0000000..2eb0aa7 --- /dev/null +++ b/frontend/src/lib/calendarUtils.ts @@ -0,0 +1,49 @@ +import type { Race } from "../api"; + +export const WEEKDAY_LABELS_SHORT_RU: string[] = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; + +/** Monday-based week: Mon=0 … Sun=6 */ +export function mondayIndexFromDate(d: Date): number { + return (d.getDay() + 6) % 7; +} + +/** Grid cells for one month: `null` = empty, `1..31` = day of month. Padded to full weeks, at least 6 rows. */ +export function buildMonthCells(year: number, monthIndex: number): (number | null)[] { + const first = new Date(year, monthIndex, 1); + const lead = mondayIndexFromDate(first); + const dim = new Date(year, monthIndex + 1, 0).getDate(); + const cells: (number | null)[] = []; + for (let i = 0; i < lead; i += 1) { + cells.push(null); + } + for (let day = 1; day <= dim; day += 1) { + cells.push(day); + } + while (cells.length % 7 !== 0) { + cells.push(null); + } + while (cells.length < 42) { + cells.push(null); + } + return cells; +} + +export function toYmd(year: number, monthIndex: number, day: number): string { + const m = String(monthIndex + 1).padStart(2, "0"); + const d = String(day).padStart(2, "0"); + return `${year}-${m}-${d}`; +} + +export function groupRacesByYmd(races: Race[]): Map { + const map = new Map(); + for (const race of races) { + const ymd = race.date.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) { + continue; + } + const list = map.get(ymd) ?? []; + list.push(race); + map.set(ymd, list); + } + return map; +} diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index 9ed3e2e..c8c1359 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -6,6 +6,7 @@ export { getRaceStatusClassName, getRaceStatusLabel, isCloseDistance, + isRaceDateInPast, parseFinishTimeToSeconds, parseRaceDate, raceNeedsResultEntry, @@ -13,3 +14,5 @@ export { sortByDateDesc, splitRacesByDate, } from "./raceMetrics"; + +export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils"; diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts index 1bdba62..0e988c6 100644 --- a/frontend/src/lib/raceMetrics.ts +++ b/frontend/src/lib/raceMetrics.ts @@ -12,6 +12,13 @@ export function parseRaceDate(date: string): Date { return parsed; } +/** Дата старта (календарный день) строго раньше сегодняшней полуночи по локали. */ +export function isRaceDateInPast(raceDate: string, now: Date = new Date()): boolean { + const today = new Date(now); + today.setHours(0, 0, 0, 0); + return parseRaceDate(raceDate).getTime() < today.getTime(); +} + export function parseFinishTimeToSeconds(value: string | null): number | null { if (!value) { return null; diff --git a/frontend/src/pages/RaceDayPage.tsx b/frontend/src/pages/RaceDayPage.tsx new file mode 100644 index 0000000..fbc1738 --- /dev/null +++ b/frontend/src/pages/RaceDayPage.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(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 { + 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 ( +
+

Некорректная дата

+

+ + Вернуться к календарю стартов + +

+
+ ); + } + + return ( +
+

+ + ← Календарь стартов + +

+

{heading}

+ + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + {isLoading ? ( +

+ Загружаем… +

+ ) : null} + + {!isLoading && !errorMessage && races.length === 0 ? ( +

На эту дату стартов нет.

+ ) : null} + + {!isLoading && races.length > 0 ? ( +
    + {races.map((race) => ( +
  • + + {race.title} + + + {formatDistance(race.distanceKm)} ·{" "} + + {getRaceStatusLabel(race.status, race.date)} + + +
  • + ))} +
+ ) : null} + +
+ + Добавить + +
+
+ ); +} diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index 56e3482..05c7bf4 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -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(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) => { 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 {
Организация - + {hideOrgScheduleFields ? null : ( + + )} - + {hideOrgScheduleFields ? null : ( +
+ Время старта + { + setForm((prev) => ({ ...prev, startTime: next })); + }} + /> +
+ )} - + {hideOrgScheduleFields ? null : ( + + )} - + {hideOrgScheduleFields ? null : ( + + )}
- {getRaceStatusLabel(race.status, race.date)} - - ))} + + ); + } + return ( +
  • +
    +

    + + {race.title} + +

    +

    + {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} +

    +
    + + {getRaceStatusLabel(race.status, race.date)} + +
  • + ); + })} ) : (

    Пока нет данных в этом разделе.

    @@ -81,8 +124,34 @@ export function RacesPage(): JSX.Element { 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); @@ -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 {

    Календарь стартов

    Будущие и прошедшие старты в одном месте.

    +
    + + +
    + {errorMessage && !isLoading ? (

    {errorMessage} @@ -158,12 +252,12 @@ export function RacesPage(): JSX.Element { Год