Compare commits

..

11 Commits

9 changed files with 430 additions and 145 deletions

View File

@@ -5,24 +5,39 @@
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
## В работе
## Backend
### Backend 1.6.0
### [1.6.0] — 2026-07-13
#### Added
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
### Frontend 0.9.0
### [1.5.3] — 2026-07-12
#### Fixed
- Миграции базы данных автоматически выполняются перед запуском backend-контейнера.
### [1.5.2] — 2026-07-12
#### Added
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
- Поиск, фильтры, сортировка и экспорт истории; график прогресса переведён на AG Charts.
- Настраиваемое имя публичного профиля и раздельная видимость будущих и завершённых стартов.
- Публичные результаты финиша с учётом настроек видимости владельца.
## Backend
### [1.5.1] — 2026-07-12
#### Added
- Публичные страницы пользователей и API опубликованного календаря стартов.
### [1.5.0] — 2026-07-12
#### Added
- Проверка и смена текущего пароля.
- Список активных сессий, отзыв выбранной сессии или всех остальных сессий.
### [1.4.2] — 2026-07-12
@@ -93,6 +108,73 @@
## Frontend
### [0.10.2] — 2026-08-14
#### Fixed
- По умолчанию завершённые старты отображаются от самых свежих к более старым; явно выбранная сортировка применяется без переопределения.
### [0.10.1] — 2026-08-13
#### Changed
- На странице `/races` завершённые старты сортируются от ранних к поздним; рядом с разделами отображается количество стартов.
- График прогресса на дашборде показывает темп в мин/км: шкала идёт от медленного темпа внизу к нулю сверху.
- Подписи графика обновлены с времени на темп.
### [0.10.0] — 2026-08-13
#### Changed
- Основные фильтры календаря оставлены в hero-блоке, а расширенные фильтры перенесены в компактную раскрываемую панель со счётчиком активных условий.
#### Fixed
- В конфигурацию TypeScript для Vite добавлены DOM-типы.
### [0.9.3] — 2026-07-13
#### Changed
- Фильтры календаря сворачиваются и показывают число активных условий; hero-блок дашборда стал компактнее.
### [0.9.1] — 2026-07-13
#### Fixed
- Зарегистрированы необходимые модули AG Charts для корректного отображения графика.
### [0.9.0] — 2026-07-13
#### Added
- Поиск, фильтры и сортировка истории стартов с синхронизацией параметров в URL.
- Экспорт выбранных стартов в CSV и iCalendar.
- Интерактивный график прогресса на AG Charts.
- Фильтрация стартов по статусу, диапазону дат и дистанции.
### [0.8.2] — 2026-07-12
#### Added
- Имена публичных профилей и отдельные настройки видимости будущих и завершённых стартов.
- Отображение опубликованных результатов на публичной странице.
### [0.8.1] — 2026-07-12
#### Added
- Публичная страница пользователя с опубликованным календарём стартов.
- Управление видимостью публичного профиля в аккаунте.
### [0.8.0] — 2026-07-12
#### Added
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление активными сессиями.
- Фильтруемые «Последние старты» с пагинацией и переходом в старт на дашборде.
- Ссылки из карточек рекордов и интерактивный график прогресса.
### [0.7.1] — 2026-07-12
#### Changed
@@ -145,6 +227,25 @@
- Редизайн дашборда и визуального представления стартов.
### [0.4.3] — 2026-04-13
#### Fixed
- В календаре скрыты всплывающие карточки для дней без стартов.
### [0.4.2] — 2026-04-13
#### Fixed
- Анимация наведения применяется ко всей строке старта, а не только к внутренней ссылке.
### [0.4.1] — 2026-04-13
#### Fixed
- Старт автоматически получает статус «Пробежал» при вводе корректного времени финиша.
- Исправлены переходы с дашборда и поведение списка и календаря стартов.
### [0.4.0] — 2026-04-13
#### Added

View File

@@ -4,15 +4,15 @@
## Переменные окружения
Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория.
Один шаблон для локальной разработки и Docker-стека: [.env.example](.env.example) → скопируйте в `.env` в корне репозитория.
Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**` и `**CORS_ORIGIN**`.
Там же перечислены `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` (подключение бэкенда к БД), `PORT` / `API_PORT` и `CORS_ORIGIN`.
## Backend — локально
1. `cd backend && npm install`
1. `cd backend && npm ci`
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
3. Postgres: из корня `docker compose up -d` (см. `[docker-compose.yml](docker-compose.yml)`) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
3. Postgres: из корня `docker compose up -d` (см. [docker-compose.yml](docker-compose.yml)) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
4. `cd backend && npm run db:migrate && npm run seed`
5. Dev-режим: `npm run dev`
6. Или production: `npm run build && npm start`
@@ -22,15 +22,15 @@
```bash
cd frontend
cp .env.example .env
npm install
npm ci
npm run dev
```
Фронт всегда отправляет запросы на относительный префикс `**/api**`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
Фронт всегда отправляет запросы на относительный префикс `/api`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
## Docker: backend + frontend рядом с Postgres
Используйте `[docker-compose.stack.yml](docker-compose.stack.yml)`: общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `**.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `**CORS_ORIGIN=http://localhost:3033**`, если заходите на фронт с хоста на порту 3033.
Используйте [docker-compose.stack.yml](docker-compose.stack.yml): общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `.env` (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `CORS_ORIGIN=http://localhost:3033`, если заходите на фронт с хоста на порту 3033.
```bash
docker compose -f docker-compose.stack.yml up -d --build
@@ -38,9 +38,11 @@ docker compose -f docker-compose.stack.yml exec runners-calendar-backend node di
docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
```
Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
Фронт в браузере обращается к API по префиксу `/api` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
## Документация API и бэкенда
## Документация
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
- [Эксплуатация backend](docs/backend.md)
- [Продуктовый бэклог](docs/product-backlog.md)
- [Changelog](CHANGELOG.md)

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
"version": "0.9.3",
"version": "0.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.9.3",
"version": "0.10.2",
"dependencies": {
"ag-charts-community": "^14.0.0",
"ag-charts-react": "^14.0.0",

View File

@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.9.3",
"version": "0.10.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -14,15 +14,13 @@ type PaceDatum = {
id: string;
date: string;
dateLabel: string;
minutes: number;
paceSeconds: number;
race: Race;
};
function formatAxisTime(minutes: number): string {
const totalSeconds = Math.round(minutes * 60);
const hours = Math.floor(totalSeconds / 3600);
const rest = totalSeconds % 3600;
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
function formatAxisPace(seconds: number): string {
const roundedSeconds = Math.round(seconds);
return `${Math.floor(roundedSeconds / 60)}:${String(roundedSeconds % 60).padStart(2, "0")}`;
}
function formatShortDate(date: string): string {
@@ -36,7 +34,7 @@ function cssToken(name: string, fallback: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
/** Линейный график: темп по завершённым стартам выбранной дистанции. */
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
const navigate = useNavigate();
const series = useMemo<PaceDatum[]>(
@@ -47,7 +45,7 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
id: race.id,
date: race.date,
dateLabel: formatShortDate(race.date),
minutes: parseFinishTimeToSeconds(race.finishTime)! / 60,
paceSeconds: parseFinishTimeToSeconds(race.finishTime)! / race.distanceKm,
race,
})),
[distanceKm, races],
@@ -66,16 +64,18 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
y: {
type: "number",
position: "left",
title: { text: "Время" },
label: { formatter: ({ value }) => formatAxisTime(Number(value)) },
reverse: true,
min: 0,
title: { text: "Темп, мин/км" },
label: { formatter: ({ value }) => formatAxisPace(Number(value)) },
},
},
series: [{
type: "line",
xKey: "dateLabel",
xName: "Дата старта",
yKey: "minutes",
yName: "Время",
yKey: "paceSeconds",
yName: "Темп",
stroke: cssToken("--color-accent", "#1168d8"),
strokeWidth: 3,
marker: {
@@ -106,7 +106,7 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
}
return (
<div className="pace-chart" role="group" aria-label="Динамика времени на дистанции. Выберите точку, чтобы открыть старт.">
<div className="pace-chart" role="group" aria-label="Динамика темпа на дистанции. Выберите точку, чтобы открыть старт.">
<AgCharts options={options} />
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
</div>

View File

@@ -303,10 +303,10 @@ export function DashboardPage(): JSX.Element {
</article>
</div>
<section className="dashboard-section" aria-label="Динамика времени на дистанции">
<h2 className="dashboard-section__title">Прогресс по времени</h2>
<section className="dashboard-section" aria-label="Динамика темпа на дистанции">
<h2 className="dashboard-section__title">Прогресс по темпу</h2>
<p className="dashboard-section__intro">
Линия по завершённым стартам выбранной дистанции: выше лучше время (короче гонка).
Линия по завершённым стартам выбранной дистанции: выше темп быстрее (меньше минут на километр).
</p>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция для графика</span>

View File

@@ -10,6 +10,7 @@ import {
getRaceStatusClassName,
getRaceStatusLabel,
parseRaceDate,
sortByDateDesc,
downloadTextFile,
racesToCsv,
racesToIcs,
@@ -36,6 +37,7 @@ const VIEW_STORAGE_KEY = "races-view-mode";
type ViewMode = "list" | "calendar";
type RaceListTab = "upcoming" | "completed";
type RaceSort = "dateAsc" | "dateDesc" | "titleAsc" | "distanceAsc";
type RaceSortOption = RaceSort | "";
function yearSelectOptions(races: Race[], selectedYear = ""): number[] {
const current = new Date().getFullYear();
@@ -64,7 +66,10 @@ function RaceList(props: { title: string; races: Race[]; variant: RaceListTab })
return (
<section className={`race-list race-list--${variant}`} aria-label={title}>
<h2 className="race-list__title">{title}</h2>
<h2 className="race-list__title">
{title}
<span className="race-list__count">{races.length}</span>
</h2>
{races.length > 0 ? (
<ul className="race-list__items">
{races.map((race) => {
@@ -142,11 +147,11 @@ export function RacesPage(): JSX.Element {
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 [sortOrder, setSortOrder] = useState<RaceSortOption>(() => (searchParams.get("sort") as RaceSort | null) ?? "");
const [viewMode, setViewMode] = useState<ViewMode>(() => searchParams.get("view") === "calendar" ? "calendar" : readInitialViewMode());
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
const [isFiltersOpen, setIsFiltersOpen] = useState<boolean>(() =>
["year", "month", "status", "dateFrom", "dateTo", "distanceMin", "distanceMax", "q", "sort"].some((key) => searchParams.has(key)),
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState<boolean>(() =>
["dateFrom", "dateTo", "distanceMin", "distanceMax", "sort"].some((key) => searchParams.has(key)),
);
const setViewModePersist = useCallback((mode: ViewMode) => {
@@ -259,7 +264,7 @@ export function RacesPage(): JSX.Element {
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);
if (sortOrder) next.set("sort", sortOrder);
setSearchParams(next, { replace: true });
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, searchQuery, setSearchParams, sortOrder, statusFilter, viewMode, yearFilter]);
@@ -277,9 +282,11 @@ export function RacesPage(): JSX.Element {
const { upcoming, completed } = useMemo(
() => ({
upcoming: filteredRaces.filter((race) => race.status !== "completed"),
completed: filteredRaces.filter((race) => race.status === "completed"),
completed: sortOrder
? filteredRaces.filter((race) => race.status === "completed")
: sortByDateDesc(filteredRaces.filter((race) => race.status === "completed")),
}),
[filteredRaces],
[filteredRaces, sortOrder],
);
const statusMessage = useMemo(() => {
if (errorMessage && !isLoading) {
@@ -300,16 +307,12 @@ export function RacesPage(): JSX.Element {
]
.filter(Boolean)
.join(" ");
const activeFilterCount = [
viewMode === "list" ? yearFilter : "",
monthFilter,
statusFilter,
searchQuery,
const activeAdvancedFilterCount = [
dateFrom,
dateTo,
distanceMin,
distanceMax,
sortOrder !== "dateAsc" ? sortOrder : "",
sortOrder,
].filter(Boolean).length;
if (errorMessage && races.length === 0 && !isLoading) {
@@ -323,7 +326,7 @@ export function RacesPage(): JSX.Element {
return (
<section className="page page--races">
<section className="races-hero" aria-label="Календарь стартов">
<section className={`races-hero${isAdvancedFiltersOpen ? " races-hero--filters-open" : ""}`} aria-label="Календарь стартов">
<div className="races-hero__content">
<p className="races-hero__eyebrow">Сезонная афиша</p>
<h1 className="page__title">Календарь стартов</h1>
@@ -345,94 +348,156 @@ export function RacesPage(): JSX.Element {
</button>
</div>
</div>
<details
className="races-hero__filters"
open={isFiltersOpen}
onToggle={(event) => setIsFiltersOpen(event.currentTarget.open)}
>
<summary className="races-hero__filters-summary">
<span>Фильтры{activeFilterCount > 0 ? ` · ${activeFilterCount} активн.` : ""}</span>
</summary>
<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);
}}
{!isAdvancedFiltersOpen ? (
<div className="races-hero__filters">
<div className="races-filter" role="search" aria-label="Основные фильтры">
<div className="races-filter__primary">
<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(races, yearFilter).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>
<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>
</div>
<button
type="button"
className="races-filter__advanced-summary"
aria-expanded={false}
onClick={() => setIsAdvancedFiltersOpen(true)}
>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions(races, yearFilter).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>
<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>
<span>Дополнительные фильтры{activeAdvancedFilterCount > 0 ? ` · ${activeAdvancedFilterCount} активн.` : ""}</span>
</button>
</div>
</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>
</details>
) : null}
</section>
{isAdvancedFiltersOpen ? (
<section className="races-filter-panel" aria-label="Расширенные фильтры стартов">
<div className="races-filter-panel__header">
<div>
<p className="races-filter-panel__eyebrow">Расширенный поиск</p>
<h2 className="races-filter-panel__title">Фильтры стартов</h2>
</div>
<button type="button" className="btn btn--secondary races-filter-panel__close" aria-expanded={true} onClick={() => setIsAdvancedFiltersOpen(false)}>
Свернуть фильтры
</button>
</div>
<div className="races-filter races-filter--panel" role="search" aria-label="Все фильтры стартов">
<div className="races-filter__panel-fields">
<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(races, yearFilter).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>
<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 RaceSortOption)}>
<option value="">По умолчанию</option>
<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>
) : null}
<div className="races-status" aria-live="polite">
<p
className={statusClassName}

View File

@@ -370,10 +370,19 @@ a {
}
.race-list__title {
display: flex;
align-items: baseline;
gap: var(--space-2);
margin: 0 0 var(--space-4);
font-size: var(--font-size-h2);
}
.race-list__count {
color: var(--color-text-muted);
font-size: var(--font-size-caption);
font-weight: 700;
}
.race-list__items {
margin: 0;
padding: 0;
@@ -1564,22 +1573,60 @@ body {
backdrop-filter: blur(14px);
}
.races-hero__filters-summary {
.races-hero--filters-open {
grid-template-columns: 1fr;
min-height: 15rem;
}
.races-hero--filters-open .races-hero__content {
max-width: 42rem;
}
.races-hero__filters .races-filter {
display: block;
margin-top: 0;
}
.races-filter__primary,
.races-filter__advanced-fields {
display: grid;
gap: var(--space-4);
}
.races-filter__primary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.races-filter__primary .races-filter__field,
.races-filter__advanced-fields .races-filter__field,
.races-filter__panel-fields .races-filter__field {
min-width: 0;
}
.races-filter__advanced-summary {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: var(--space-5);
padding-top: var(--space-4);
padding-right: 0;
padding-bottom: 0;
padding-left: 0;
border-top: 1px solid rgba(255, 255, 255, 0.2);
gap: var(--space-3);
color: #ffffff;
font: inherit;
font-weight: 700;
cursor: pointer;
list-style: none;
text-align: left;
border-right: 0;
border-bottom: 0;
border-left: 0;
background: transparent;
}
.races-hero__filters-summary::-webkit-details-marker {
display: none;
}
.races-hero__filters-summary::after {
.races-filter__advanced-summary::after {
content: "+";
display: inline-flex;
align-items: center;
@@ -1593,17 +1640,62 @@ body {
line-height: 1;
}
.races-hero__filters[open] .races-hero__filters-summary::after {
.races-filter__advanced[open] .races-filter__advanced-summary::after {
content: "";
}
.races-hero__filters-summary:focus-visible {
.races-filter__advanced-summary:focus-visible {
outline: 2px solid var(--color-lime);
outline-offset: var(--space-1);
}
.races-hero__filters .races-filter {
.races-filter-panel {
display: grid;
gap: var(--space-5);
margin-top: var(--space-5);
padding: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.races-filter-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
}
.races-filter-panel__eyebrow {
margin: 0 0 var(--space-1);
color: var(--color-accent);
font-size: var(--font-size-caption);
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.races-filter-panel__title {
margin: 0;
color: var(--color-bg-deep);
font-size: var(--font-size-h2);
}
.races-filter-panel .races-filter {
display: block;
margin-top: 0;
}
.races-filter__panel-fields {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-4);
}
.races-filter-panel .races-filter__select,
.races-filter-panel .races-filter__input {
width: 100%;
}
.races-hero__filters .races-filter__label {
@@ -1620,6 +1712,10 @@ body {
background: rgba(255, 255, 255, 0.94);
}
.races-filter-panel .races-filter__label {
color: var(--color-text-muted);
}
.races-view-toggle {
border-color: rgba(255, 255, 255, 0.24);
background: rgba(7, 25, 39, 0.28);
@@ -2048,6 +2144,10 @@ body {
background-position: 62% center;
}
.races-filter__panel-fields {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.race-details-header {
flex-direction: column;
}
@@ -2080,6 +2180,19 @@ body {
border-radius: var(--radius-md);
}
.races-filter-panel {
padding: var(--space-5);
}
.races-filter-panel__header {
align-items: flex-start;
flex-direction: column;
}
.races-filter-panel__close {
width: 100%;
}
.dashboard-hero__title {
font-size: 3rem;
}
@@ -2097,6 +2210,10 @@ body {
.races-cal__year {
grid-template-columns: 1fr;
}
.races-filter__panel-fields {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020"],
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,