feat: complete race history sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.8.2",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.8.2",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"ag-charts-community": "^14.0.0",
|
||||
"ag-charts-react": "^14.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
@@ -1221,6 +1223,50 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-community": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-community/-/ag-charts-community-14.0.0.tgz",
|
||||
"integrity": "sha512-u/O7T/EGxj8s6cZjbYU1CaRObnjMzdfXZVido9uXbeAwBkT4rxXF6OWSq1SkkSrZD1+pwusHn3cbul4Ly3yzPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-core": "14.0.0",
|
||||
"ag-charts-locale": "14.0.0",
|
||||
"ag-charts-types": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-core": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-core/-/ag-charts-core-14.0.0.tgz",
|
||||
"integrity": "sha512-xcFSQ0Ces8JvH5RpKoZIjrFzdvju91gIXODj33Byg6ShwkixrHyLaCf17addfSsqerSSdMkw0iiceD9afU83UQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-locale": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-locale/-/ag-charts-locale-14.0.0.tgz",
|
||||
"integrity": "sha512-14mGnsQgG7VzdQwt7Jq2MFPGPtO8y6jAECN4nae7t3fJAPq162mmrgDaqpwHdAZxKPkMNte98DUQ8ULrtSbUpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-charts-react": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-react/-/ag-charts-react-14.0.0.tgz",
|
||||
"integrity": "sha512-PTwfdmyFbePzaKHuGWfB2o8KNXO5wR5fOCyUCRqdUNnCEK8cBPwlDsfe28CRsevQSLPZqTNpW2y5qJhHC4q6EQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-community": "14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.0.tgz",
|
||||
"integrity": "sha512-89X4J/OeOTzTp505126JayGEn3k6QUKz3UHSR0/ggoVZYzVqtMN13k/B136S1+YBwgnzyJ/NARnnIRTPWsWG9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.15",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.8.2",
|
||||
"version": "0.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"ag-charts-community": "^14.0.0",
|
||||
"ag-charts-react": "^14.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
|
||||
@@ -47,24 +47,25 @@ function normalizeRace(input: unknown): Race {
|
||||
});
|
||||
}
|
||||
|
||||
const validRace = race as Race;
|
||||
return {
|
||||
id: race.id,
|
||||
slug: race.slug,
|
||||
date: race.date,
|
||||
title: race.title,
|
||||
distanceKm: race.distanceKm,
|
||||
status: race.status,
|
||||
officialUrl: race.officialUrl,
|
||||
coverImageUrl: race.coverImageUrl ?? null,
|
||||
startTime: race.startTime,
|
||||
clusterSchedule: race.clusterSchedule,
|
||||
bibPickup: race.bibPickup,
|
||||
bibNumber: race.bibNumber,
|
||||
finishTime: race.finishTime,
|
||||
finishPlace: race.finishPlace,
|
||||
notes: race.notes,
|
||||
createdAt: race.createdAt,
|
||||
updatedAt: race.updatedAt,
|
||||
id: validRace.id,
|
||||
slug: validRace.slug,
|
||||
date: validRace.date,
|
||||
title: validRace.title,
|
||||
distanceKm: validRace.distanceKm,
|
||||
status: validRace.status,
|
||||
officialUrl: validRace.officialUrl,
|
||||
coverImageUrl: validRace.coverImageUrl ?? null,
|
||||
startTime: validRace.startTime,
|
||||
clusterSchedule: validRace.clusterSchedule,
|
||||
bibPickup: validRace.bibPickup,
|
||||
bibNumber: validRace.bibNumber,
|
||||
finishTime: validRace.finishTime,
|
||||
finishPlace: validRace.finishPlace,
|
||||
notes: validRace.notes,
|
||||
createdAt: validRace.createdAt,
|
||||
updatedAt: validRace.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +81,21 @@ function buildRacesQuery(query?: RacesQuery): string {
|
||||
if (typeof query.month === "number") {
|
||||
params.set("month", String(query.month));
|
||||
}
|
||||
if (query.status) {
|
||||
params.set("status", query.status);
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
params.set("dateFrom", query.dateFrom);
|
||||
}
|
||||
if (query.dateTo) {
|
||||
params.set("dateTo", query.dateTo);
|
||||
}
|
||||
if (typeof query.distanceMin === "number") {
|
||||
params.set("distanceMin", String(query.distanceMin));
|
||||
}
|
||||
if (typeof query.distanceMax === "number") {
|
||||
params.set("distanceMax", String(query.distanceMax));
|
||||
}
|
||||
|
||||
const serialized = params.toString();
|
||||
return serialized ? `?${serialized}` : "";
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface Race {
|
||||
export interface RacesQuery {
|
||||
year?: number;
|
||||
month?: number;
|
||||
status?: RaceStatus;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
distanceMin?: number;
|
||||
distanceMax?: number;
|
||||
}
|
||||
|
||||
export interface CreateRacePayload {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { AgCharts } from "ag-charts-react";
|
||||
import type { AgChartOptions } from "ag-charts-community";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { Race } from "../api";
|
||||
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
|
||||
@@ -8,6 +10,14 @@ type PaceTrendChartProps = {
|
||||
distanceKm: number;
|
||||
};
|
||||
|
||||
type PaceDatum = {
|
||||
id: string;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
minutes: number;
|
||||
race: Race;
|
||||
};
|
||||
|
||||
function formatAxisTime(minutes: number): string {
|
||||
const totalSeconds = Math.round(minutes * 60);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
@@ -19,89 +29,86 @@ function formatShortDate(date: string): string {
|
||||
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
}
|
||||
|
||||
function cssToken(name: string, fallback: string): string {
|
||||
if (typeof window === "undefined") {
|
||||
return fallback;
|
||||
}
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
|
||||
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const series = races
|
||||
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
|
||||
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
|
||||
.map((race) => ({ race, minutes: parseFinishTimeToSeconds(race.finishTime)! / 60 }));
|
||||
const series = useMemo<PaceDatum[]>(
|
||||
() => races
|
||||
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
|
||||
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
|
||||
.map((race) => ({
|
||||
id: race.id,
|
||||
date: race.date,
|
||||
dateLabel: formatShortDate(race.date),
|
||||
minutes: parseFinishTimeToSeconds(race.finishTime)! / 60,
|
||||
race,
|
||||
})),
|
||||
[distanceKm, races],
|
||||
);
|
||||
|
||||
const options = useMemo<AgChartOptions<PaceDatum>>(() => ({
|
||||
data: series,
|
||||
autoSize: true,
|
||||
minHeight: 280,
|
||||
padding: { top: 16, right: 20, bottom: 16, left: 16 },
|
||||
background: { fill: "transparent" },
|
||||
animation: { enabled: false },
|
||||
keyboard: { enabled: true, tabIndex: 0, initialFocus: "data-start" },
|
||||
axes: {
|
||||
x: { type: "category", position: "bottom", title: { text: "Дата старта" } },
|
||||
y: {
|
||||
type: "number",
|
||||
position: "left",
|
||||
title: { text: "Время" },
|
||||
label: { formatter: ({ value }) => formatAxisTime(Number(value)) },
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
type: "line",
|
||||
xKey: "dateLabel",
|
||||
xName: "Дата старта",
|
||||
yKey: "minutes",
|
||||
yName: "Время",
|
||||
stroke: cssToken("--color-accent", "#1168d8"),
|
||||
strokeWidth: 3,
|
||||
marker: {
|
||||
enabled: true,
|
||||
size: 8,
|
||||
fill: cssToken("--color-surface", "#ffffff"),
|
||||
stroke: cssToken("--color-accent", "#1168d8"),
|
||||
strokeWidth: 2,
|
||||
},
|
||||
tooltip: {
|
||||
renderer: ({ datum }) => ({
|
||||
title: datum.race.title,
|
||||
data: [
|
||||
{ label: "Дата", value: formatRaceDate(datum.date) },
|
||||
{ label: "Время", value: datum.race.finishTime ?? "—" },
|
||||
{ label: "Темп", value: getPaceLabel(datum.race.finishTime, datum.race.distanceKm) ?? "—" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
listeners: {
|
||||
seriesNodeClick: ({ datum }) => navigate(`/races/${datum.race.id}`),
|
||||
},
|
||||
}), [navigate, series]);
|
||||
|
||||
if (series.length < 2) {
|
||||
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
|
||||
}
|
||||
|
||||
const values = series.map((item) => item.minutes);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const w = 100;
|
||||
const h = 58;
|
||||
const margin = { top: 6, right: 4, bottom: 14, left: 17 };
|
||||
const innerW = w - margin.left - margin.right;
|
||||
const innerH = h - margin.top - margin.bottom;
|
||||
const pointAt = (item: (typeof series)[number], index: number) => ({
|
||||
x: margin.left + (index / (series.length - 1)) * innerW,
|
||||
y: margin.top + ((max - item.minutes) / range) * innerH,
|
||||
});
|
||||
const points = series.map(pointAt);
|
||||
const line = points.map(({ x, y }) => `${x},${y}`).join(" ");
|
||||
const yTicks = [max, (max + min) / 2, min];
|
||||
const labelStep = Math.max(1, Math.ceil(series.length / 6));
|
||||
const active = series.find((item) => item.race.id === activeId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="pace-chart">
|
||||
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="group" aria-label="Динамика времени на дистанции. Нажмите точку, чтобы открыть старт.">
|
||||
<text className="pace-chart__axis-title" x="2" y={h / 2} transform={`rotate(-90 2 ${h / 2})`}>Время</text>
|
||||
{yTicks.map((tick) => {
|
||||
const y = margin.top + ((max - tick) / range) * innerH;
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line className="pace-chart__grid-line" x1={margin.left} y1={y} x2={w - margin.right} y2={y} />
|
||||
<text className="pace-chart__axis-label" x={margin.left - 2} y={y + 1} textAnchor="end">{formatAxisTime(tick)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line className="pace-chart__axis" x1={margin.left} y1={margin.top} x2={margin.left} y2={h - margin.bottom} />
|
||||
<line className="pace-chart__axis" x1={margin.left} y1={h - margin.bottom} x2={w - margin.right} y2={h - margin.bottom} />
|
||||
<polyline className="pace-chart__line" fill="none" points={line} />
|
||||
{series.map((item, index) => {
|
||||
const point = points[index]!;
|
||||
const isLabeled = index % labelStep === 0 || index === series.length - 1;
|
||||
return (
|
||||
<g
|
||||
key={item.race.id}
|
||||
className="pace-chart__point"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Открыть ${item.race.title}: ${item.race.finishTime}, ${getPaceLabel(item.race.finishTime, item.race.distanceKm)}`}
|
||||
onMouseEnter={() => setActiveId(item.race.id)}
|
||||
onMouseLeave={() => setActiveId(null)}
|
||||
onFocus={() => setActiveId(item.race.id)}
|
||||
onBlur={() => setActiveId(null)}
|
||||
onClick={() => navigate(`/races/${item.race.id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
navigate(`/races/${item.race.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<circle className={index === series.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"} cx={point.x} cy={point.y} r="1.05" />
|
||||
{isLabeled ? <text className="pace-chart__point-label" x={point.x} y={h - margin.bottom + 5} textAnchor="middle">{formatShortDate(item.race.date)}</text> : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<text className="pace-chart__axis-title" x={w / 2} y={h - 1} textAnchor="middle">Дата старта</text>
|
||||
</svg>
|
||||
{active ? (
|
||||
<div className="pace-chart__tooltip" role="status">
|
||||
<strong>{active.race.title}</strong>
|
||||
<span>{formatRaceDate(active.race.date)} · {active.race.finishTime} · {getPaceLabel(active.race.finishTime, active.race.distanceKm)}</span>
|
||||
</div>
|
||||
) : <p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>}
|
||||
<div className="pace-chart" role="group" aria-label="Динамика времени на дистанции. Выберите точку, чтобы открыть старт.">
|
||||
<AgCharts options={options} />
|
||||
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@ export {
|
||||
export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";
|
||||
export { getRaceVisual } from "./raceVisuals";
|
||||
export type { RaceVisualVariant } from "./raceVisuals";
|
||||
export { downloadTextFile, racesToCsv, racesToIcs } from "./raceExport";
|
||||
|
||||
60
frontend/src/lib/raceExport.ts
Normal file
60
frontend/src/lib/raceExport.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Race } from "../api";
|
||||
|
||||
function escapeCsvCell(value: string | number | null): string {
|
||||
const text = value == null ? "" : String(value);
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
function escapeIcsText(value: string): string {
|
||||
return value.replace(/[\\;,\n]/g, (character) => ({ "\\": "\\\\", ";": "\\;", ",": "\\,", "\n": "\\n" })[character]!);
|
||||
}
|
||||
|
||||
function toIcsDate(date: string): string {
|
||||
return date.slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
|
||||
function addOneDay(date: string): string {
|
||||
const next = new Date(`${date.slice(0, 10)}T00:00:00Z`);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
return next.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function racesToCsv(races: Race[]): string {
|
||||
const headers = ["Дата", "Название", "Дистанция (км)", "Статус", "Время", "Место", "Официальная страница"];
|
||||
const rows = races.map((race) => [
|
||||
race.date,
|
||||
race.title,
|
||||
race.distanceKm,
|
||||
race.status,
|
||||
race.finishTime,
|
||||
race.finishPlace,
|
||||
race.officialUrl,
|
||||
]);
|
||||
return `\uFEFF${[headers, ...rows].map((row) => row.map(escapeCsvCell).join(",")).join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
export function racesToIcs(races: Race[]): string {
|
||||
const events = races.map((race) => {
|
||||
const start = race.date.slice(0, 10);
|
||||
const end = addOneDay(start);
|
||||
return [
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${race.id}@calendar-run`,
|
||||
`DTSTART;VALUE=DATE:${toIcsDate(start)}`,
|
||||
`DTEND;VALUE=DATE:${toIcsDate(end)}`,
|
||||
`SUMMARY:${escapeIcsText(race.title)}`,
|
||||
`DESCRIPTION:${escapeIcsText(`${race.distanceKm} км${race.officialUrl ? `\n${race.officialUrl}` : ""}`)}`,
|
||||
"END:VEVENT",
|
||||
].join("\r\n");
|
||||
});
|
||||
return ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Calendar Run//Races//RU", ...events, "END:VCALENDAR", ""].join("\r\n");
|
||||
}
|
||||
|
||||
export function downloadTextFile(filename: string, content: string, type: string): void {
|
||||
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -412,19 +412,17 @@ export function RaceFormPage(): JSX.Element {
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Организация</legend>
|
||||
|
||||
{hideOrgScheduleFields ? null : (
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Сайт организатора</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="url"
|
||||
name="officialUrl"
|
||||
value={form.officialUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Сайт организатора</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="url"
|
||||
name="officialUrl"
|
||||
value={form.officialUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">URL обложки</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Race, RacesQuery } from "../api";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import type { Race, RaceStatus, RacesQuery } from "../api";
|
||||
import { ApiError, getRaces } from "../api";
|
||||
import { RacesCalendar } from "../components/RacesCalendar";
|
||||
import {
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
getRaceStatusClassName,
|
||||
getRaceStatusLabel,
|
||||
parseRaceDate,
|
||||
sortByDateAsc,
|
||||
sortByDateDesc,
|
||||
downloadTextFile,
|
||||
racesToCsv,
|
||||
racesToIcs,
|
||||
} from "../lib";
|
||||
|
||||
const MONTH_OPTIONS: { value: string; label: string }[] = [
|
||||
@@ -34,16 +35,12 @@ const VIEW_STORAGE_KEY = "races-view-mode";
|
||||
|
||||
type ViewMode = "list" | "calendar";
|
||||
type RaceListTab = "upcoming" | "completed";
|
||||
type RaceSort = "dateAsc" | "dateDesc" | "titleAsc" | "distanceAsc";
|
||||
|
||||
function yearSelectOptions(): number[] {
|
||||
function yearSelectOptions(races: Race[], selectedYear = ""): 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;
|
||||
const selected = Number(selectedYear);
|
||||
return [...new Set([current, ...races.map((race) => parseRaceDate(race.date).getFullYear()), ...(Number.isInteger(selected) ? [selected] : [])])].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
@@ -133,12 +130,20 @@ function RaceList(props: { title: string; races: Race[]; variant: RaceListTab })
|
||||
}
|
||||
|
||||
export function RacesPage(): JSX.Element {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
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 [yearFilter, setYearFilter] = useState<string>(() => searchParams.get("year") ?? "");
|
||||
const [monthFilter, setMonthFilter] = useState<string>(() => searchParams.get("month") ?? "");
|
||||
const [statusFilter, setStatusFilter] = useState<RaceStatus | "">(() => (searchParams.get("status") as RaceStatus | null) ?? "");
|
||||
const [dateFrom, setDateFrom] = useState<string>(() => searchParams.get("dateFrom") ?? "");
|
||||
const [dateTo, setDateTo] = useState<string>(() => searchParams.get("dateTo") ?? "");
|
||||
const [distanceMin, setDistanceMin] = useState<string>(() => searchParams.get("distanceMin") ?? "");
|
||||
const [distanceMax, setDistanceMax] = useState<string>(() => searchParams.get("distanceMax") ?? "");
|
||||
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get("q") ?? "");
|
||||
const [sortOrder, setSortOrder] = useState<RaceSort>(() => (searchParams.get("sort") as RaceSort | null) ?? "dateAsc");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => searchParams.get("view") === "calendar" ? "calendar" : readInitialViewMode());
|
||||
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
|
||||
|
||||
const setViewModePersist = useCallback((mode: ViewMode) => {
|
||||
@@ -160,28 +165,45 @@ export function RacesPage(): JSX.Element {
|
||||
}, [setViewModePersist]);
|
||||
|
||||
const listQuery = useMemo((): RacesQuery | undefined => {
|
||||
const q: RacesQuery = {};
|
||||
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;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (statusFilter) {
|
||||
q.status = statusFilter;
|
||||
}
|
||||
if (dateFrom) {
|
||||
q.dateFrom = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
q.dateTo = dateTo;
|
||||
}
|
||||
const min = Number(distanceMin);
|
||||
const max = Number(distanceMax);
|
||||
if (distanceMin && Number.isFinite(min)) {
|
||||
q.distanceMin = min;
|
||||
}
|
||||
if (distanceMax && Number.isFinite(max)) {
|
||||
q.distanceMax = max;
|
||||
}
|
||||
return Object.keys(q).length > 0 ? q : undefined;
|
||||
}, [viewMode, yearFilter, monthFilter]);
|
||||
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, statusFilter, viewMode, yearFilter]);
|
||||
|
||||
const displayYear = useMemo(() => {
|
||||
if (yearFilter !== "") {
|
||||
@@ -223,12 +245,38 @@ export function RacesPage(): JSX.Element {
|
||||
};
|
||||
}, [listQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams();
|
||||
if (viewMode === "calendar") next.set("view", viewMode);
|
||||
if (yearFilter) next.set("year", yearFilter);
|
||||
if (monthFilter) next.set("month", monthFilter);
|
||||
if (statusFilter) next.set("status", statusFilter);
|
||||
if (dateFrom) next.set("dateFrom", dateFrom);
|
||||
if (dateTo) next.set("dateTo", dateTo);
|
||||
if (distanceMin) next.set("distanceMin", distanceMin);
|
||||
if (distanceMax) next.set("distanceMax", distanceMax);
|
||||
if (searchQuery) next.set("q", searchQuery);
|
||||
if (sortOrder !== "dateAsc") next.set("sort", sortOrder);
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, searchQuery, setSearchParams, sortOrder, statusFilter, viewMode, yearFilter]);
|
||||
|
||||
const filteredRaces = useMemo(() => {
|
||||
const needle = searchQuery.trim().toLocaleLowerCase("ru-RU");
|
||||
const result = races.filter((race) => !needle || race.title.toLocaleLowerCase("ru-RU").includes(needle));
|
||||
return result.sort((left, right) => {
|
||||
if (sortOrder === "titleAsc") return left.title.localeCompare(right.title, "ru");
|
||||
if (sortOrder === "distanceAsc") return left.distanceKm - right.distanceKm || left.title.localeCompare(right.title, "ru");
|
||||
const direction = sortOrder === "dateDesc" ? -1 : 1;
|
||||
return direction * (parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
|
||||
});
|
||||
}, [races, searchQuery, sortOrder]);
|
||||
|
||||
const { upcoming, completed } = useMemo(
|
||||
() => ({
|
||||
upcoming: sortByDateAsc(races.filter((race) => race.status !== "completed")),
|
||||
completed: sortByDateDesc(races.filter((race) => race.status === "completed")),
|
||||
upcoming: filteredRaces.filter((race) => race.status !== "completed"),
|
||||
completed: filteredRaces.filter((race) => race.status === "completed"),
|
||||
}),
|
||||
[races],
|
||||
[filteredRaces],
|
||||
);
|
||||
const statusMessage = useMemo(() => {
|
||||
if (errorMessage && !isLoading) {
|
||||
@@ -295,7 +343,7 @@ export function RacesPage(): JSX.Element {
|
||||
}}
|
||||
>
|
||||
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||||
{yearSelectOptions().map((y) => (
|
||||
{yearSelectOptions(races, yearFilter).map((y) => (
|
||||
<option key={y} value={String(y)}>
|
||||
{y}
|
||||
</option>
|
||||
@@ -318,6 +366,48 @@ export function RacesPage(): JSX.Element {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Статус</span>
|
||||
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="planned">Планирую</option>
|
||||
<option value="registered">Зарегистрирован</option>
|
||||
<option value="completed">Пробежал</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Поиск</span>
|
||||
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дата от</span>
|
||||
<input className="races-filter__input" type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дата до</span>
|
||||
<input className="races-filter__input" type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция от, км</span>
|
||||
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMin} onChange={(event) => setDistanceMin(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция до, км</span>
|
||||
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMax} onChange={(event) => setDistanceMax(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Сортировка</span>
|
||||
<select className="races-filter__select" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as RaceSort)}>
|
||||
<option value="dateAsc">Дата: сначала ранние</option>
|
||||
<option value="dateDesc">Дата: сначала поздние</option>
|
||||
<option value="titleAsc">Название: А–Я</option>
|
||||
<option value="distanceAsc">Дистанция: по возрастанию</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="races-filter__actions">
|
||||
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.csv", racesToCsv(filteredRaces), "text/csv;charset=utf-8")}>Скачать CSV</button>
|
||||
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.ics", racesToIcs(filteredRaces), "text/calendar;charset=utf-8")}>Скачать календарь</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -367,7 +457,7 @@ export function RacesPage(): JSX.Element {
|
||||
<RacesCalendar
|
||||
displayYear={displayYear}
|
||||
monthFilter={monthFilter}
|
||||
races={races}
|
||||
races={filteredRaces}
|
||||
onMonthFilterChange={setMonthFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -542,7 +542,8 @@ a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.races-filter__select {
|
||||
.races-filter__select,
|
||||
.races-filter__input {
|
||||
font: inherit;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -551,6 +552,13 @@ a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.races-filter__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.pace-chart {
|
||||
margin-top: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -559,73 +567,9 @@ a {
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.pace-chart__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.pace-chart__line {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.pace-chart__axis {
|
||||
stroke: rgba(14, 31, 45, 0.42);
|
||||
stroke-width: 0.75;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.pace-chart__axis-label,
|
||||
.pace-chart__axis-title,
|
||||
.pace-chart__point-label {
|
||||
fill: var(--color-text-muted);
|
||||
font-size: 2.5px;
|
||||
}
|
||||
|
||||
.pace-chart__axis-title {
|
||||
font-size: 2.8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pace-chart__point-label {
|
||||
font-size: 2.25px;
|
||||
}
|
||||
|
||||
.pace-chart__point {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pace-chart__point:focus-visible .pace-chart__dot,
|
||||
.pace-chart__point:hover .pace-chart__dot {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.pace-chart__tooltip,
|
||||
.pace-chart__hint {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.pace-chart__tooltip {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.pace-chart__hint {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pace-chart__caption {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -1590,49 +1534,6 @@ body {
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.pace-chart__svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pace-chart__grid-line {
|
||||
stroke: rgba(14, 31, 45, 0.12);
|
||||
stroke-width: 0.8;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.pace-chart__line {
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
.pace-chart__dot {
|
||||
fill: #ffffff;
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.pace-chart__dot--last {
|
||||
fill: var(--color-lime);
|
||||
stroke: var(--color-bg-deep);
|
||||
}
|
||||
|
||||
.pace-chart__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.pace-chart__caption {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.pace-chart__caption--best {
|
||||
color: #0e5d3d;
|
||||
}
|
||||
|
||||
.races-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.58fr);
|
||||
@@ -1676,6 +1577,11 @@ body {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.races-hero__filters .races-filter__input {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.races-view-toggle {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
background: rgba(7, 25, 39, 0.28);
|
||||
|
||||
Reference in New Issue
Block a user