Compare commits
18 Commits
feature/ra
...
docs/add-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 564c223f77 | |||
| 4c77efe76a | |||
| 4f61d9a106 | |||
| d2c54614af | |||
| 36c2042da5 | |||
| 9cde2ec55f | |||
| 69cfd3bf3e | |||
| 186accdcfb | |||
| 880d57dc59 | |||
| 570004f5be | |||
| 1b0e524e0e | |||
| 47f90c5389 | |||
| 1b8235fa6e | |||
| b9b98d2164 | |||
|
|
4e6b283aa0 | ||
| b4caf731eb | |||
|
|
821f9167ca | ||
| e882e8ded6 |
117
CHANGELOG.md
117
CHANGELOG.md
@@ -5,24 +5,39 @@
|
|||||||
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
|
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
|
||||||
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
|
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
|
||||||
|
|
||||||
## В работе
|
## Backend
|
||||||
|
|
||||||
### Backend 1.6.0
|
### [1.6.0] — 2026-07-13
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|
||||||
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
|
|
||||||
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
|
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
|
||||||
|
|
||||||
### Frontend 0.9.0
|
### [1.5.3] — 2026-07-12
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Миграции базы данных автоматически выполняются перед запуском backend-контейнера.
|
||||||
|
|
||||||
|
### [1.5.2] — 2026-07-12
|
||||||
|
|
||||||
#### Added
|
#### 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
|
### [1.4.2] — 2026-07-12
|
||||||
|
|
||||||
@@ -93,6 +108,73 @@
|
|||||||
|
|
||||||
## Frontend
|
## 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
|
### [0.7.1] — 2026-07-12
|
||||||
|
|
||||||
#### Changed
|
#### 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
|
### [0.4.0] — 2026-04-13
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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 — локально
|
## Backend — локально
|
||||||
|
|
||||||
1. `cd backend && npm install`
|
1. `cd backend && npm ci`
|
||||||
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
|
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`
|
4. `cd backend && npm run db:migrate && npm run seed`
|
||||||
5. Dev-режим: `npm run dev`
|
5. Dev-режим: `npm run dev`
|
||||||
6. Или production: `npm run build && npm start`
|
6. Или production: `npm run build && npm start`
|
||||||
@@ -22,15 +22,15 @@
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
npm install
|
npm ci
|
||||||
npm run dev
|
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: 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
|
```bash
|
||||||
docker compose -f docker-compose.stack.yml up -d --build
|
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
|
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)
|
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
||||||
- [Эксплуатация backend](docs/backend.md)
|
- [Эксплуатация backend](docs/backend.md)
|
||||||
|
- [Продуктовый бэклог](docs/product-backlog.md)
|
||||||
|
- [Changelog](CHANGELOG.md)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Backlog Calendar Run
|
# Backlog Calendar Run
|
||||||
|
|
||||||
**Срез репозитория: 12 июля 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей.
|
**Срез репозитория: 14 августа 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей.
|
||||||
|
|
||||||
## Текущая база
|
## Текущая база
|
||||||
|
|
||||||
| Контур | Версия | Уже работает |
|
| Контур | Версия | Уже работает |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Backend | `1.6.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, фильтры истории, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита и восстановление обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
|
| Backend | `1.6.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, фильтры истории, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита и восстановление обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
|
||||||
| Frontend | `0.9.0` | Защищённые маршруты и auth-экраны, экран аккаунта, поиск/фильтры/экспорт истории, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и графиком AG Charts, версия FE/BE в футере. |
|
| Frontend | `0.10.2` | Защищённые маршруты и auth-экраны, экран аккаунта, поиск/фильтры/экспорт истории, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и графиком AG Charts, версия FE/BE в футере. |
|
||||||
|
|
||||||
## Правила версионирования и выпуска
|
## Правила версионирования и выпуска
|
||||||
|
|
||||||
@@ -27,6 +27,19 @@
|
|||||||
|
|
||||||
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
|
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
|
||||||
|
|
||||||
|
## Ближайшие изменения — CI/CD
|
||||||
|
|
||||||
|
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
||||||
|
|---|---|---|---|
|
||||||
|
| OPS-CICD-01 | P0 | Стабилизировать Gitea Runner | Workflow не остаётся в `queued`: runner автоматически забирает job `ubuntu-latest`, изолированно выполняет текущий backend/frontend CI и возвращает итоговый статус в PR. |
|
||||||
|
| OPS-CICD-02 | P0 | Закрыть пробелы текущего CI | Добавлены frontend-регрессионный тест сортировки стартов, полный TypeScript type-check, `git diff --check`, проверка согласованности package/lock версий и сверка CHANGELOG с SemVer-историей. |
|
||||||
|
| OPS-CICD-03 | P0 | Проверять production-образы | CI собирает оба Dockerfile и запускает минимальный smoke-test стека: frontend отвечает `200`, `/api/health` возвращает актуальную backend-версию, `/api/ready` подтверждает PostgreSQL. |
|
||||||
|
| OPS-CICD-04 | P0 | Защитить `main` | Merge разрешён только для актуальной ветки без конфликтов после успешного обязательного `build-and-test`; прямой push в `main` запрещён. |
|
||||||
|
| OPS-CICD-05 | P1 | Публиковать immutable-образы | После зелёного CI в Gitea Container Registry публикуются frontend/backend-образы с тегом commit SHA; SemVer-тег добавляется при релизе, `latest` не используется как единственный источник версии. |
|
||||||
|
| OPS-CICD-06 | P1 | Добавить ручной production deploy | После зелёного CI отдельный manual workflow по SSH обновляет Synology на выбранный SHA, использует Gitea Secrets, не перезаписывает `.env`/данные, выполняет Compose update и итоговые health/readiness-проверки. |
|
||||||
|
| OPS-CICD-07 | P1 | Обеспечить rollback | Перед deploy фиксируется предыдущий SHA образов; при неуспешном smoke-test стек возвращается на него. Процедура проверена без восстановления базы, а миграции до запуска классифицированы как обратимые либо требуют backup. |
|
||||||
|
| OPS-CICD-08 | P2 | Добавить эксплуатационные проверки | По расписанию выполняются dependency/security scan и очистка старых образов; ошибки CI/CD отправляются владельцу. Backup/restore остаются частью `BE-170-01`, а не маскируются успешным deploy. |
|
||||||
|
|
||||||
## P0 — BE 1.4.2 / FE 0.7.1
|
## P0 — BE 1.4.2 / FE 0.7.1
|
||||||
|
|
||||||
### Backend 1.4.2
|
### Backend 1.4.2
|
||||||
@@ -92,6 +105,7 @@
|
|||||||
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
|
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
|
||||||
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
|
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
|
||||||
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
|
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
|
||||||
|
| FE-090-04 | P2 | Исправить полную TypeScript-проверку frontend | `tsc -b` проходит без ошибки `Cannot find name 'Worker'` в типах Vite; конфигурации browser/node остаются корректно разделены, проверка исходников не ослабляется. |
|
||||||
|
|
||||||
## P3 — BE 1.8.0 / FE 1.1.0
|
## P3 — BE 1.8.0 / FE 1.1.0
|
||||||
|
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-frontend",
|
"name": "calendar-run-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.10.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "calendar-run-frontend",
|
"name": "calendar-run-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.10.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ag-charts-community": "^14.0.0",
|
"ag-charts-community": "^14.0.0",
|
||||||
"ag-charts-react": "^14.0.0",
|
"ag-charts-react": "^14.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-frontend",
|
"name": "calendar-run-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.0",
|
"version": "0.10.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -14,15 +14,13 @@ type PaceDatum = {
|
|||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
dateLabel: string;
|
dateLabel: string;
|
||||||
minutes: number;
|
paceSeconds: number;
|
||||||
race: Race;
|
race: Race;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAxisTime(minutes: number): string {
|
function formatAxisPace(seconds: number): string {
|
||||||
const totalSeconds = Math.round(minutes * 60);
|
const roundedSeconds = Math.round(seconds);
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
return `${Math.floor(roundedSeconds / 60)}:${String(roundedSeconds % 60).padStart(2, "0")}`;
|
||||||
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 formatShortDate(date: string): string {
|
function formatShortDate(date: string): string {
|
||||||
@@ -36,7 +34,7 @@ function cssToken(name: string, fallback: string): string {
|
|||||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
|
/** Линейный график: темп по завершённым стартам выбранной дистанции. */
|
||||||
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
|
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const series = useMemo<PaceDatum[]>(
|
const series = useMemo<PaceDatum[]>(
|
||||||
@@ -47,7 +45,7 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
|
|||||||
id: race.id,
|
id: race.id,
|
||||||
date: race.date,
|
date: race.date,
|
||||||
dateLabel: formatShortDate(race.date),
|
dateLabel: formatShortDate(race.date),
|
||||||
minutes: parseFinishTimeToSeconds(race.finishTime)! / 60,
|
paceSeconds: parseFinishTimeToSeconds(race.finishTime)! / race.distanceKm,
|
||||||
race,
|
race,
|
||||||
})),
|
})),
|
||||||
[distanceKm, races],
|
[distanceKm, races],
|
||||||
@@ -66,16 +64,18 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
|
|||||||
y: {
|
y: {
|
||||||
type: "number",
|
type: "number",
|
||||||
position: "left",
|
position: "left",
|
||||||
title: { text: "Время" },
|
reverse: true,
|
||||||
label: { formatter: ({ value }) => formatAxisTime(Number(value)) },
|
min: 0,
|
||||||
|
title: { text: "Темп, мин/км" },
|
||||||
|
label: { formatter: ({ value }) => formatAxisPace(Number(value)) },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
series: [{
|
series: [{
|
||||||
type: "line",
|
type: "line",
|
||||||
xKey: "dateLabel",
|
xKey: "dateLabel",
|
||||||
xName: "Дата старта",
|
xName: "Дата старта",
|
||||||
yKey: "minutes",
|
yKey: "paceSeconds",
|
||||||
yName: "Время",
|
yName: "Темп",
|
||||||
stroke: cssToken("--color-accent", "#1168d8"),
|
stroke: cssToken("--color-accent", "#1168d8"),
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
marker: {
|
marker: {
|
||||||
@@ -106,7 +106,7 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pace-chart" role="group" aria-label="Динамика времени на дистанции. Выберите точку, чтобы открыть старт.">
|
<div className="pace-chart" role="group" aria-label="Динамика темпа на дистанции. Выберите точку, чтобы открыть старт.">
|
||||||
<AgCharts options={options} />
|
<AgCharts options={options} />
|
||||||
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
|
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { AllCommunityModule, ModuleRegistry } from "ag-charts-community";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
import { AuthProvider } from "./app/auth/AuthContext";
|
import { AuthProvider } from "./app/auth/AuthContext";
|
||||||
import { appRouter } from "./app/router";
|
import { appRouter } from "./app/router";
|
||||||
import "./styles/tokens.css";
|
import "./styles/tokens.css";
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ export function DashboardPage(): JSX.Element {
|
|||||||
>
|
>
|
||||||
<div className="dashboard-hero__content">
|
<div className="dashboard-hero__content">
|
||||||
<p className="dashboard-hero__eyebrow">Календарь сезона</p>
|
<p className="dashboard-hero__eyebrow">Календарь сезона</p>
|
||||||
<h1 className="dashboard-hero__title">Беговой штаб</h1>
|
|
||||||
<p className="dashboard-hero__text">
|
<p className="dashboard-hero__text">
|
||||||
Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям.
|
Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям.
|
||||||
</p>
|
</p>
|
||||||
@@ -304,10 +303,10 @@ export function DashboardPage(): JSX.Element {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="dashboard-section" aria-label="Динамика времени на дистанции">
|
<section className="dashboard-section" aria-label="Динамика темпа на дистанции">
|
||||||
<h2 className="dashboard-section__title">Прогресс по времени</h2>
|
<h2 className="dashboard-section__title">Прогресс по темпу</h2>
|
||||||
<p className="dashboard-section__intro">
|
<p className="dashboard-section__intro">
|
||||||
Линия по завершённым стартам выбранной дистанции: выше — лучше время (короче гонка).
|
Линия по завершённым стартам выбранной дистанции: выше — темп быстрее (меньше минут на километр).
|
||||||
</p>
|
</p>
|
||||||
<label className="races-filter__field">
|
<label className="races-filter__field">
|
||||||
<span className="races-filter__label">Дистанция для графика</span>
|
<span className="races-filter__label">Дистанция для графика</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getRaceStatusClassName,
|
getRaceStatusClassName,
|
||||||
getRaceStatusLabel,
|
getRaceStatusLabel,
|
||||||
parseRaceDate,
|
parseRaceDate,
|
||||||
|
sortByDateDesc,
|
||||||
downloadTextFile,
|
downloadTextFile,
|
||||||
racesToCsv,
|
racesToCsv,
|
||||||
racesToIcs,
|
racesToIcs,
|
||||||
@@ -36,6 +37,7 @@ const VIEW_STORAGE_KEY = "races-view-mode";
|
|||||||
type ViewMode = "list" | "calendar";
|
type ViewMode = "list" | "calendar";
|
||||||
type RaceListTab = "upcoming" | "completed";
|
type RaceListTab = "upcoming" | "completed";
|
||||||
type RaceSort = "dateAsc" | "dateDesc" | "titleAsc" | "distanceAsc";
|
type RaceSort = "dateAsc" | "dateDesc" | "titleAsc" | "distanceAsc";
|
||||||
|
type RaceSortOption = RaceSort | "";
|
||||||
|
|
||||||
function yearSelectOptions(races: Race[], selectedYear = ""): number[] {
|
function yearSelectOptions(races: Race[], selectedYear = ""): number[] {
|
||||||
const current = new Date().getFullYear();
|
const current = new Date().getFullYear();
|
||||||
@@ -64,7 +66,10 @@ function RaceList(props: { title: string; races: Race[]; variant: RaceListTab })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`race-list race-list--${variant}`} aria-label={title}>
|
<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 ? (
|
{races.length > 0 ? (
|
||||||
<ul className="race-list__items">
|
<ul className="race-list__items">
|
||||||
{races.map((race) => {
|
{races.map((race) => {
|
||||||
@@ -142,9 +147,12 @@ export function RacesPage(): JSX.Element {
|
|||||||
const [distanceMin, setDistanceMin] = useState<string>(() => searchParams.get("distanceMin") ?? "");
|
const [distanceMin, setDistanceMin] = useState<string>(() => searchParams.get("distanceMin") ?? "");
|
||||||
const [distanceMax, setDistanceMax] = useState<string>(() => searchParams.get("distanceMax") ?? "");
|
const [distanceMax, setDistanceMax] = useState<string>(() => searchParams.get("distanceMax") ?? "");
|
||||||
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get("q") ?? "");
|
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 [viewMode, setViewMode] = useState<ViewMode>(() => searchParams.get("view") === "calendar" ? "calendar" : readInitialViewMode());
|
||||||
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
|
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
|
||||||
|
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState<boolean>(() =>
|
||||||
|
["dateFrom", "dateTo", "distanceMin", "distanceMax", "sort"].some((key) => searchParams.has(key)),
|
||||||
|
);
|
||||||
|
|
||||||
const setViewModePersist = useCallback((mode: ViewMode) => {
|
const setViewModePersist = useCallback((mode: ViewMode) => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
@@ -256,7 +264,7 @@ export function RacesPage(): JSX.Element {
|
|||||||
if (distanceMin) next.set("distanceMin", distanceMin);
|
if (distanceMin) next.set("distanceMin", distanceMin);
|
||||||
if (distanceMax) next.set("distanceMax", distanceMax);
|
if (distanceMax) next.set("distanceMax", distanceMax);
|
||||||
if (searchQuery) next.set("q", searchQuery);
|
if (searchQuery) next.set("q", searchQuery);
|
||||||
if (sortOrder !== "dateAsc") next.set("sort", sortOrder);
|
if (sortOrder) next.set("sort", sortOrder);
|
||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, searchQuery, setSearchParams, sortOrder, statusFilter, viewMode, yearFilter]);
|
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, searchQuery, setSearchParams, sortOrder, statusFilter, viewMode, yearFilter]);
|
||||||
|
|
||||||
@@ -274,9 +282,11 @@ export function RacesPage(): JSX.Element {
|
|||||||
const { upcoming, completed } = useMemo(
|
const { upcoming, completed } = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
upcoming: filteredRaces.filter((race) => race.status !== "completed"),
|
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(() => {
|
const statusMessage = useMemo(() => {
|
||||||
if (errorMessage && !isLoading) {
|
if (errorMessage && !isLoading) {
|
||||||
@@ -297,6 +307,13 @@ export function RacesPage(): JSX.Element {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
const activeAdvancedFilterCount = [
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
distanceMin,
|
||||||
|
distanceMax,
|
||||||
|
sortOrder,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
if (errorMessage && races.length === 0 && !isLoading) {
|
if (errorMessage && races.length === 0 && !isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -309,7 +326,7 @@ export function RacesPage(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page page--races">
|
<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">
|
<div className="races-hero__content">
|
||||||
<p className="races-hero__eyebrow">Сезонная афиша</p>
|
<p className="races-hero__eyebrow">Сезонная афиша</p>
|
||||||
<h1 className="page__title">Календарь стартов</h1>
|
<h1 className="page__title">Календарь стартов</h1>
|
||||||
@@ -331,87 +348,156 @@ export function RacesPage(): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="races-hero__filters">
|
{!isAdvancedFiltersOpen ? (
|
||||||
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
<div className="races-hero__filters">
|
||||||
<label className="races-filter__field">
|
<div className="races-filter" role="search" aria-label="Основные фильтры">
|
||||||
<span className="races-filter__label">Год</span>
|
<div className="races-filter__primary">
|
||||||
<select
|
<label className="races-filter__field">
|
||||||
className="races-filter__select"
|
<span className="races-filter__label">Год</span>
|
||||||
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
<select
|
||||||
onChange={(event) => {
|
className="races-filter__select"
|
||||||
setYearFilter(event.target.value);
|
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}
|
<span>Дополнительные фильтры{activeAdvancedFilterCount > 0 ? ` · ${activeAdvancedFilterCount} активн.` : ""}</span>
|
||||||
{yearSelectOptions(races, yearFilter).map((y) => (
|
</button>
|
||||||
<option key={y} value={String(y)}>
|
</div>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="races-filter__actions">
|
) : null}
|
||||||
<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>
|
</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">
|
<div className="races-status" aria-live="polite">
|
||||||
<p
|
<p
|
||||||
className={statusClassName}
|
className={statusClassName}
|
||||||
|
|||||||
@@ -370,10 +370,19 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.race-list__title {
|
.race-list__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
font-size: var(--font-size-h2);
|
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 {
|
.race-list__items {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1564,10 +1573,131 @@ body {
|
|||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.races-hero__filters .races-filter {
|
||||||
|
display: block;
|
||||||
margin-top: 0;
|
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;
|
||||||
|
text-align: left;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__advanced-summary::after {
|
||||||
|
content: "+";
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__advanced[open] .races-filter__advanced-summary::after {
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__advanced-summary:focus-visible {
|
||||||
|
outline: 2px solid var(--color-lime);
|
||||||
|
outline-offset: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.races-hero__filters .races-filter__label {
|
||||||
color: rgba(255, 255, 255, 0.78);
|
color: rgba(255, 255, 255, 0.78);
|
||||||
}
|
}
|
||||||
@@ -1582,6 +1712,10 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.94);
|
background: rgba(255, 255, 255, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter-panel .races-filter__label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.races-view-toggle {
|
.races-view-toggle {
|
||||||
border-color: rgba(255, 255, 255, 0.24);
|
border-color: rgba(255, 255, 255, 0.24);
|
||||||
background: rgba(7, 25, 39, 0.28);
|
background: rgba(7, 25, 39, 0.28);
|
||||||
@@ -2010,6 +2144,10 @@ body {
|
|||||||
background-position: 62% center;
|
background-position: 62% center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter__panel-fields {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.race-details-header {
|
.race-details-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -2042,6 +2180,19 @@ body {
|
|||||||
border-radius: var(--radius-md);
|
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 {
|
.dashboard-hero__title {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
@@ -2059,6 +2210,10 @@ body {
|
|||||||
.races-cal__year {
|
.races-cal__year {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter__panel-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"lib": ["ES2020"],
|
"lib": ["ES2020", "DOM"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user