diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b00f5fb..90781b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.4.3", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.4.3", + "version": "0.5.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 4cf2890..2217a16 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.4.3", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/public/images/race-half.png b/frontend/public/images/race-half.png new file mode 100644 index 0000000..d665b27 Binary files /dev/null and b/frontend/public/images/race-half.png differ diff --git a/frontend/public/images/race-marathon.png b/frontend/public/images/race-marathon.png new file mode 100644 index 0000000..b93c2cb Binary files /dev/null and b/frontend/public/images/race-marathon.png differ diff --git a/frontend/public/images/race-night.png b/frontend/public/images/race-night.png new file mode 100644 index 0000000..b4a023c Binary files /dev/null and b/frontend/public/images/race-night.png differ diff --git a/frontend/public/images/race-short.png b/frontend/public/images/race-short.png new file mode 100644 index 0000000..9d1336b Binary files /dev/null and b/frontend/public/images/race-short.png differ diff --git a/frontend/public/images/race-trail.png b/frontend/public/images/race-trail.png new file mode 100644 index 0000000..266102b Binary files /dev/null and b/frontend/public/images/race-trail.png differ diff --git a/frontend/public/images/runner-hero.png b/frontend/public/images/runner-hero.png new file mode 100644 index 0000000..c06741d Binary files /dev/null and b/frontend/public/images/runner-hero.png differ diff --git a/frontend/src/components/DatePickerField.tsx b/frontend/src/components/DatePickerField.tsx new file mode 100644 index 0000000..7457c25 --- /dev/null +++ b/frontend/src/components/DatePickerField.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { buildMonthCells, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib"; + +const MONTH_NAMES_RU = [ + "Январь", + "Февраль", + "Март", + "Апрель", + "Май", + "Июнь", + "Июль", + "Август", + "Сентябрь", + "Октябрь", + "Ноябрь", + "Декабрь", +]; + +interface DatePickerFieldProps { + value: string; + name: string; + required?: boolean; + onChange: (value: string) => void; +} + +function parseYmd(value: string): { year: number; monthIndex: number; day: number } | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return null; + } + + const year = Number(value.slice(0, 4)); + const monthIndex = Number(value.slice(5, 7)) - 1; + const day = Number(value.slice(8, 10)); + + if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) { + return null; + } + if (monthIndex < 0 || monthIndex > 11) { + return null; + } + + return { year, monthIndex, day }; +} + +function getInitialVisibleMonth(value: string): { year: number; monthIndex: number } { + const parsed = parseYmd(value); + if (parsed) { + return { year: parsed.year, monthIndex: parsed.monthIndex }; + } + + const now = new Date(); + return { year: now.getFullYear(), monthIndex: now.getMonth() }; +} + +export function DatePickerField(props: DatePickerFieldProps): JSX.Element { + const { value, name, required, onChange } = props; + const [isOpen, setIsOpen] = useState(false); + const [visibleMonth, setVisibleMonth] = useState(() => getInitialVisibleMonth(value)); + const rootRef = useRef(null); + + useEffect(() => { + const parsed = parseYmd(value); + if (!parsed) { + return; + } + setVisibleMonth({ year: parsed.year, monthIndex: parsed.monthIndex }); + }, [value]); + + useEffect(() => { + if (!isOpen) { + return; + } + + function handlePointerDown(event: MouseEvent): void { + if (rootRef.current?.contains(event.target as Node)) { + return; + } + setIsOpen(false); + } + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") { + setIsOpen(false); + } + } + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen]); + + const selected = parseYmd(value); + const todayYmd = toYmd(new Date().getFullYear(), new Date().getMonth(), new Date().getDate()); + const cells = useMemo( + () => buildMonthCells(visibleMonth.year, visibleMonth.monthIndex), + [visibleMonth], + ); + const monthTitle = `${MONTH_NAMES_RU[visibleMonth.monthIndex]} ${visibleMonth.year}`; + + function shiftMonth(delta: number): void { + setVisibleMonth((prev) => { + const next = new Date(Date.UTC(prev.year, prev.monthIndex + delta, 1)); + return { year: next.getUTCFullYear(), monthIndex: next.getUTCMonth() }; + }); + } + + return ( +
+
+ { + onChange(event.target.value); + }} + onFocus={() => setIsOpen(true)} + placeholder="2026-05-03" + autoComplete="off" + required={required} + /> + +
+ + {isOpen ? ( +
+
+ +

{monthTitle}

+ +
+
+ {WEEKDAY_LABELS_SHORT_RU.map((weekday) => ( + + {weekday} + + ))} +
+
+ {cells.map((day, idx) => { + if (day === null) { + return ; + } + + const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day); + const isSelected = + selected?.year === visibleMonth.year && + selected.monthIndex === visibleMonth.monthIndex && + selected.day === day; + + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/PaceTrendChart.tsx b/frontend/src/components/PaceTrendChart.tsx index 1efd95b..f696305 100644 --- a/frontend/src/components/PaceTrendChart.tsx +++ b/frontend/src/components/PaceTrendChart.tsx @@ -55,16 +55,38 @@ export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element { .join(" "); const last = series[series.length - 1]!; + const best = series.reduce((currentBest, item) => (item.minutes < currentBest.minutes ? item : currentBest), series[0]!); + const dotPoints = series.map((s, i) => { + const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW); + const norm = (maxM - s.minutes) / range; + const y = pad + (1 - norm) * innerH; + return { x, y, id: s.race.id }; + }); return (
+ + + {dotPoints.map((point, index) => ( + + ))} -

- Последний пункт: {formatRaceDate(last.race.date)} — {last.race.finishTime} ( - {last.minutes.toFixed(1)} мин) -

+
+

+ Последний: {formatRaceDate(last.race.date)} · {last.race.finishTime} · {last.minutes.toFixed(1)} мин +

+

+ Лучший: {formatRaceDate(best.race.date)} · {best.race.finishTime} · {best.minutes.toFixed(1)} мин +

+
); } diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 7de53f0..f24a219 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,3 +1,4 @@ +export { DatePickerField } from "./DatePickerField"; 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 index 2eb0aa7..b974fdc 100644 --- a/frontend/src/lib/calendarUtils.ts +++ b/frontend/src/lib/calendarUtils.ts @@ -2,16 +2,20 @@ import type { Race } from "../api"; export const WEEKDAY_LABELS_SHORT_RU: string[] = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; -/** Monday-based week: Mon=0 … Sun=6 */ +/** Monday-based week: Mon=0 ... Sun=6 */ +function mondayIndexFromJsDay(jsDay: number): number { + return (jsDay + 6) % 7; +} + +/** Monday-based week: Mon=0 ... Sun=6 */ export function mondayIndexFromDate(d: Date): number { - return (d.getDay() + 6) % 7; + return mondayIndexFromJsDay(d.getDay()); } /** 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 lead = mondayIndexFromJsDay(new Date(Date.UTC(year, monthIndex, 1)).getUTCDay()); + const dim = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); const cells: (number | null)[] = []; for (let i = 0; i < lead; i += 1) { cells.push(null); diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index c8c1359..e048b75 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -16,3 +16,5 @@ export { } from "./raceMetrics"; export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils"; +export { getRaceVisual } from "./raceVisuals"; +export type { RaceVisualVariant } from "./raceVisuals"; diff --git a/frontend/src/lib/raceVisuals.ts b/frontend/src/lib/raceVisuals.ts new file mode 100644 index 0000000..eb17698 --- /dev/null +++ b/frontend/src/lib/raceVisuals.ts @@ -0,0 +1,206 @@ +import type { Race } from "../api"; + +export type RaceVisualVariant = "short" | "half" | "marathon" | "trail" | "night"; +export type RaceVisualFit = "cover" | "contain"; + +interface RaceVisual { + variant: RaceVisualVariant; + imageSrc: string; + fallbackSrc: string; + imageFit: RaceVisualFit; + label: string; +} + +interface OfficialRaceVisual { + keywords: string[]; + imageSrc: string; + imageFit?: RaceVisualFit; + label: string; +} + +const FALLBACK_VISUALS: Record = { + short: { + variant: "short", + imageSrc: "/images/race-short.png", + fallbackSrc: "/images/race-short.png", + imageFit: "cover", + label: "Городской темп", + }, + half: { + variant: "half", + imageSrc: "/images/race-half.png", + fallbackSrc: "/images/race-half.png", + imageFit: "cover", + label: "Полумарафон", + }, + marathon: { + variant: "marathon", + imageSrc: "/images/race-marathon.png", + fallbackSrc: "/images/race-marathon.png", + imageFit: "cover", + label: "Марафон", + }, + trail: { + variant: "trail", + imageSrc: "/images/race-trail.png", + fallbackSrc: "/images/race-trail.png", + imageFit: "cover", + label: "Трейл", + }, + night: { + variant: "night", + imageSrc: "/images/race-night.png", + fallbackSrc: "/images/race-night.png", + imageFit: "cover", + label: "Ночной старт", + }, +}; + +const OFFICIAL_VISUALS: OfficialRaceVisual[] = [ + { + keywords: ["забег апрель"], + imageSrc: "https://aprilrun5km.runc.run/uploads/page_card_photos/AprilRun_photo_1.jpg", + label: "Забег Апрель", + }, + { + keywords: ["быстрый пес"], + imageSrc: "https://fastdogxc.runc.run/uploads/page_card_photos/Dog_spring_2026-5.jpg", + label: "Кросс", + }, + { + keywords: ["лисья гора"], + imageSrc: "https://foxhillxc.runc.run/uploads/page_card_photos/Fox_Spring_2026-0.jpg", + label: "Кросс", + }, + { + keywords: ["казанский марафон"], + imageSrc: "https://static.tildacdn.com/tild3961-6436-4462-b738-356665613039/Frame_2131327895.png", + imageFit: "contain", + label: "Казанский марафон", + }, + { + keywords: ["мышкинский полумарафон", "по шести холмам"], + imageSrc: "https://static.tildacdn.com/tild6133-6137-4865-b166-623532313531/photo.jpg", + label: "Золотое кольцо", + }, + { + keywords: ["забег.рф", "забег рф"], + imageSrc: "https://xn--80acghh.xn--p1ai/zabeg.jpg", + label: "ЗаБег.РФ", + }, + { + keywords: ["переславский марафон", "александровские версты"], + imageSrc: "https://static.tildacdn.com/tild6432-3338-4533-b262-633339353335/photo_1.jpg", + label: "Золотое кольцо", + }, + { + keywords: ["красочный забег"], + imageSrc: "https://colorrun5km.runc.run/uploads/page_card_photos/ColorRun2026-1.jpg", + label: "Красочный забег", + }, + { + keywords: ["здорово кострома", "здорово, кострома"], + imageSrc: "https://static.tildacdn.com/tild6139-3539-4661-b232-386230336431/kostroma.jpg", + label: "Золотое кольцо", + }, + { + keywords: ["ночной забег москва"], + imageSrc: "https://nightrun10km.runc.run/uploads/page_card_photos/NightRun_2026-9.jpg", + label: "Ночной забег", + }, + { + keywords: ["белые ночи"], + imageSrc: "https://wnmarathon.runc.run/uploads/page_card_photos/WN_photo_01.jpg", + label: "Белые ночи", + }, + { + keywords: ["сергиевым путем", "сергиевым путём"], + imageSrc: "https://static.tildacdn.com/tild6236-3466-4239-b666-393061326338/serg.jpg", + label: "Золотое кольцо", + }, + { + keywords: ["ночной забег нижний новгород"], + imageSrc: "https://rrweb.russiarunning.com/-x740/generalimages/0531a1b8-3876-4620-8961-2fa374e474e5.png", + imageFit: "contain", + label: "Ночной забег", + }, + { + keywords: ["suvorov extreme"], + imageSrc: "https://goldenultra.ru/grut/files/photos/100.jpg", + label: "Трейл", + }, + { + keywords: ["рыбинский полумарафон", "великий хлебный путь"], + imageSrc: "https://static.tildacdn.com/tild6130-3230-4332-b932-366166366633/photo.jpg", + label: "Золотое кольцо", + }, + { + keywords: ["ярославский полумарафон", "золотое кольцо"], + imageSrc: "https://static.tildacdn.com/tild6331-6333-4635-b635-376262373361/photo.jpg", + label: "Золотое кольцо", + }, + { + keywords: ["моя столица"], + imageSrc: "https://static.tildacdn.com/tild3263-3036-4639-b830-653365663832/-min.jpg", + imageFit: "contain", + label: "Моя столица", + }, +]; + +function normalizeTitle(value: string): string { + return value + .toLowerCase() + .replaceAll("ё", "е") + .replace(/[«»|]/g, " ") + .replace(/[^\p{L}\p{N}.&]+/gu, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function getFallbackRaceVisual(race: Race): RaceVisual { + const title = normalizeTitle(race.title); + + if (title.includes("ночной")) { + return FALLBACK_VISUALS.night; + } + + if ( + title.includes("trail") || + title.includes("extreme") || + title.includes("suvorov") || + title.includes("трейл") || + title.includes("экстрим") + ) { + return FALLBACK_VISUALS.trail; + } + + if (Math.abs(race.distanceKm - 42.2) < 0.8) { + return FALLBACK_VISUALS.marathon; + } + + if (Math.abs(race.distanceKm - 21.1) < 0.4) { + return FALLBACK_VISUALS.half; + } + + return FALLBACK_VISUALS.short; +} + +export function getRaceVisual(race: Race): RaceVisual { + const fallback = getFallbackRaceVisual(race); + const title = normalizeTitle(race.title); + const official = OFFICIAL_VISUALS.find((visual) => + visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))), + ); + + if (!official) { + return fallback; + } + + return { + ...fallback, + imageSrc: official.imageSrc, + fallbackSrc: fallback.imageSrc, + imageFit: official.imageFit ?? fallback.imageFit, + label: official.label, + }; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 3127d49..2e077ae 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -132,6 +132,11 @@ export function DashboardPage(): JSX.Element { .sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU")); }, [races]); + const seasonProgress = + dashboardMetrics.seasonTotal > 0 + ? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100) + : 0; + if (isLoading) { return (
@@ -152,12 +157,43 @@ export function DashboardPage(): JSX.Element { return (
-

Обзор

-

Ключевые метрики по вашему календарю стартов.

+
+
+

Календарь сезона

+

Беговой штаб

+

+ Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям. +

+
+ + Смотреть старты + + + Добавить старт + +
+
+
+

Ближайший старт

+ {dashboardMetrics.nextRace ? ( + + {dashboardMetrics.nextRace.title} + + {formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)} + + + {getRaceCountdownLabel(dashboardMetrics.nextRace.date)} + + + ) : ( +

Запланируйте первый старт сезона.

+ )} +
+
{dashboardMetrics.nextRace ? (
{dashboardMetrics.lastResult ? (
{dashboardMetrics.lastPersonalRecord ? ( -
+

Сезон

{dashboardMetrics.seasonTotal}

стартов в этом году

Завершено: {dashboardMetrics.seasonCompletedCount}

+
+ +
diff --git a/frontend/src/pages/RaceDayPage.tsx b/frontend/src/pages/RaceDayPage.tsx index fbc1738..ca706dc 100644 --- a/frontend/src/pages/RaceDayPage.tsx +++ b/frontend/src/pages/RaceDayPage.tsx @@ -2,7 +2,14 @@ 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"; +import { + formatDistance, + formatRaceDate, + getRaceStatusClassName, + getRaceStatusLabel, + getRaceVisual, + sortByDateAsc, +} from "../lib"; function getErrorMessage(error: unknown): string { if (error instanceof ApiError) { @@ -70,24 +77,33 @@ export function RaceDayPage(): JSX.Element { if (!validYmd) { return (
-

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

-

+

+

Страница дня

+

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

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

+
); } return (
-

+

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

-

{heading}

+

Старты дня

+

{heading}

+

+ {isLoading + ? "Загружаем расписание..." + : races.length > 0 + ? `Запланировано стартов: ${races.length}` + : "Проверьте расписание или добавьте старт на эту дату."} +

+
{errorMessage ? (

@@ -107,19 +123,40 @@ export function RaceDayPage(): JSX.Element { {!isLoading && races.length > 0 ? (

    - {races.map((race) => ( -
  • - - {race.title} - - - {formatDistance(race.distanceKm)} ·{" "} - - {getRaceStatusLabel(race.status, race.date)} - - -
  • - ))} + {races.map((race) => { + const visual = getRaceVisual(race); + + return ( +
  • + + { + event.currentTarget.onerror = null; + event.currentTarget.classList.remove("race-day__image--contain"); + event.currentTarget.src = visual.fallbackSrc; + }} + /> + + {visual.label} + {race.title} + + {formatDistance(race.distanceKm)} ·{" "} + + {getRaceStatusLabel(race.status, race.date)} + + + + +
  • + ); + })}
) : null} diff --git a/frontend/src/pages/RaceDetailsPage.tsx b/frontend/src/pages/RaceDetailsPage.tsx index 83d3d29..0a87dfa 100644 --- a/frontend/src/pages/RaceDetailsPage.tsx +++ b/frontend/src/pages/RaceDetailsPage.tsx @@ -7,6 +7,7 @@ import { getPaceLabel, getRaceStatusClassName, getRaceStatusLabel, + getRaceVisual, raceNeedsResultEntry, } from "../lib"; import type { Race } from "../api"; @@ -139,18 +140,40 @@ export function RaceDetailsPage(): JSX.Element { } const isCompleted = race.status === "completed"; + const visual = getRaceVisual(race); return (
-
-
+
+ { + event.currentTarget.onerror = null; + event.currentTarget.classList.remove("race-details-hero__image--contain"); + event.currentTarget.src = visual.fallbackSrc; + }} + /> + +
{raceNeedsResultEntry(race) ? (

diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index 72178da..8d4a91a 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; 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 { DatePickerField, StartTimeSelects } from "../components"; import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib"; function slugify(text: string): string { @@ -274,17 +274,17 @@ export function RaceFormPage(): JSX.Element {

Основная информация -