Compare commits

...

68 Commits

Author SHA1 Message Date
4c77efe76a Merge pull request 'fix(frontend): show newest completed races first' (#50) from docs/complete-release-history into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #50
2026-08-13 21:43:36 +00:00
4f61d9a106 docs: complete release changelog history
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-08-14 00:38:08 +03:00
d2c54614af fix(frontend): show newest completed races first 2026-08-14 00:15:19 +03:00
36c2042da5 Merge pull request 'fix(frontend): sort completed races and show pace scale' (#49) from agent/runners-calendar:fix/races-order-and-pace-scale into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #49
2026-08-13 20:31:02 +00:00
9cde2ec55f merge: resolve PR 49 against main
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-08-13 23:29:55 +03:00
69cfd3bf3e docs: record race and pace chart changes
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-08-13 19:01:38 +03:00
186accdcfb fix(frontend): describe pace progress correctly
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-08-13 18:53:21 +03:00
880d57dc59 fix(frontend): sort completed races and show pace scale
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-08-13 18:39:24 +03:00
570004f5be docs: document compact race filters release
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-08-13 07:53:02 +03:00
1b0e524e0e Merge pull request 'feat(frontend): compact advanced race filters' (#48) from agent/runners-calendar:feat/compact-calendar-filters into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #48
2026-08-12 21:18:54 +00:00
47f90c5389 fix(frontend): include DOM types in Vite config
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-08-13 00:08:16 +03:00
1b8235fa6e feat(frontend): move advanced race filters below hero
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-08-12 23:46:20 +03:00
b9b98d2164 Merge pull request 'fix(frontend): simplify race calendar overview' (#47) from fix/collapse-start-filters into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #47
2026-07-13 21:14:10 +00:00
Vakanaut
4e6b283aa0 fix(frontend): simplify race calendar overview
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-14 00:13:38 +03:00
b4caf731eb Merge pull request 'fix(frontend): register AG Charts modules' (#46) from fix/register-ag-charts-modules into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #46
2026-07-13 17:48:41 +00:00
Vakanaut
821f9167ca fix(frontend): register AG Charts modules
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-13 20:47:55 +03:00
e882e8ded6 Merge pull request 'feat: complete race history sprint' (#45) from feature/race-history-covers-and-charts into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #45
2026-07-13 17:25:05 +00:00
Vakanaut
a8b87fc6b1 feat: complete race history sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-13 08:31:12 +03:00
46998a1a67 Merge pull request 'docs: plan race cover restoration' (#44) from fix/restore-race-covers into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #44
2026-07-12 15:34:12 +00:00
Vakanaut
2d1390ae63 docs: plan race cover restoration
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 18:33:14 +03:00
f6ad7b924a Merge pull request 'fix: run migrations before starting backend' (#43) from fix/auto-run-migrations into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #43
2026-07-12 14:08:00 +00:00
Vakanaut
e68df524ef fix: run migrations before starting backend
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 17:07:32 +03:00
ea7b8aff7a Merge pull request 'feature/profile-usernames' (#42) from feature/profile-usernames into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #42
2026-07-12 14:01:49 +00:00
Vakanaut
77790a646b Merge remote-tracking branch 'origin/main' into feature/profile-usernames
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
# Conflicts:
#	backend/package-lock.json
#	backend/package.json
#	backend/src/authMiddleware.ts
#	backend/src/authService.ts
#	backend/src/routes/auth.ts
#	backend/src/routes/users.ts
#	backend/test/app.test.ts
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/api/auth.ts
#	frontend/src/api/index.ts
#	frontend/src/api/types.ts
#	frontend/src/api/users.ts
#	frontend/src/app/router.tsx
#	frontend/src/pages/AccountPage.tsx
#	frontend/src/pages/PublicProfilePage.tsx
2026-07-12 17:01:23 +03:00
Vakanaut
d4c72115d8 feat: customize public profile visibility
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 16:57:43 +03:00
Vakanaut
103e3ca209 feat: add public user profiles 2026-07-12 16:51:18 +03:00
e68265d9f7 Merge pull request 'feat: add public user profiles' (#41) from feature/public-profiles into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #41
2026-07-12 13:29:56 +00:00
Vakanaut
07ab687bf3 feat: add public user profiles
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 16:21:53 +03:00
69931e81a8 Merge pull request 'feat: complete account and dashboard sprint' (#40) from feature/p1-account-dashboard into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #40
2026-07-12 12:59:09 +00:00
Vakanaut
793d51fdce feat: complete account and dashboard sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 15:56:26 +03:00
a6108ea927 Merge pull request 'feat: complete p0 hardening sprint' (#39) from feature/p0-hardening-auth-results into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #39
2026-07-12 12:13:45 +00:00
Vakanaut
a317c9b760 feat: complete p0 hardening sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 15:08:00 +03:00
b0ebdeca72 Merge pull request 'docs: add versioned backlog and changelog' (#38) from feature/versioned-backlog into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #38
2026-07-12 06:28:25 +00:00
Vaka.pro
c30d6b6041 docs: add versioned backlog and changelog
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 09:26:20 +03:00
5122ac9d1b Merge pull request 'feature/registration-auth' (#37) from feature/registration-auth into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #37
2026-05-24 11:36:51 +00:00
Vaka.pro
83fa04ecf2 Merge origin/main into feature/registration-auth
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-05-24 14:34:45 +03:00
Vaka.pro
fb246e2e55 fix: harden authentication security
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-05-24 14:27:22 +03:00
a22bb7e471 Merge pull request 'feat: add mobile race list tabs' (#36) from feature/mobile-race-list-tabs into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #36
2026-05-24 11:13:12 +00:00
Vaka.pro
85aff823fc feat: add mobile race list tabs
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-05-24 14:10:09 +03:00
Vaka.pro
35c3554742 feat: add registration and authentication 2026-05-21 00:01:35 +03:00
13dd8fa426 Merge pull request 'fix: remove fallback image from dashboard race hero' (#35) from fix/dashboard-hero-background-layering into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #35
2026-04-27 21:40:55 +00:00
Vaka.pro
f62be600cd fix: remove fallback image from dashboard race hero
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-28 00:40:01 +03:00
0f5249726b Merge pull request 'fix: use next race image as dashboard hero background' (#34) from fix/dashboard-hero-race-background into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #34
fix: use next race image as dashboard hero background
Set the dashboard hero background from the nearest upcoming race visual, using the existing race visual fallback chain. Add a BEM modifier for the image-backed hero state and bump the frontend patch version.
2026-04-27 20:31:25 +00:00
Vaka.pro
fdb0ba3d2d fix: use next race image as dashboard hero background
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 23:30:36 +03:00
367868cf1b Merge pull request 'fix: tolerate missing race cover image field' (#33) from fix/race-cover-api-backcompat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #33
2026-04-27 20:08:11 +00:00
Vaka.pro
78d0ab5ece fix: tolerate missing race cover image field
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 23:07:31 +03:00
e2eb71522d Merge pull request 'feat: add race cover image extraction' (#32) from feature/race-cover-images into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #32
2026-04-27 20:02:08 +00:00
Vaka.pro
00985732ec Merge remote-tracking branch 'origin/main' into feature/race-cover-images
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
# Conflicts:
#	frontend/package-lock.json
#	frontend/package.json
2026-04-27 23:01:19 +03:00
Vaka.pro
0153f223f2 feat: add race cover image extraction
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 22:56:41 +03:00
b1b363a7e8 Merge pull request 'feat(frontend): add service favicon' (#31) from feat/add-service-favicon into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #31
2026-04-27 11:29:02 +00:00
Anton
f5e16c44b3 feat(frontend): add service favicon
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 14:26:42 +03:00
c5ca511ea7 Merge pull request 'chore: fix versioning' (#30) from chore/frontend-version-0.5.1 into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #30
2026-04-27 11:20:31 +00:00
Anton
42057ddb1c chore: fix versioning
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 14:20:05 +03:00
1a37afd16f Merge pull request 'fix(frontend): prevent calendar loading layout shift' (#29) from fix/calendar-loading-layout-shift into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #29
2026-04-27 11:03:46 +00:00
Anton
f7b611bbbe fix(frontend): prevent calendar loading layout shift
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 14:02:20 +03:00
55fc23ec64 Merge pull request 'fix frontend calendar race states' (#28) from codex/calendar-race-ui-fixes into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #28
2026-04-27 09:32:39 +00:00
Anton
dffbb48d99 fix frontend calendar race states
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 12:31:29 +03:00
0b7ad23252 Merge pull request 'chore: resizes images' (#27) from chore/resize-images into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #27
2026-04-22 10:27:38 +00:00
Anton
19e9e59125 chore: resizes images
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-22 12:50:39 +03:00
bfbbaeae59 Merge pull request 'feat(frontend): redesign race dashboard' (#26) from feature/sport-dashboard-redesign into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #26
2026-04-22 08:48:30 +00:00
Anton
0da7454033 feat(frontend): redesign race dashboard
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-22 11:47:37 +03:00
7b0267f9ac Merge pull request 'fix(frontend): hide calendar popups on empty dates' (#25) from fix/calendar-hide-popover-empty-days into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #25
2026-04-13 19:59:16 +00:00
Vaka.pro
a581ffaaff fix(frontend): hide calendar popups on empty dates
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Keep race popovers limited to dates with events so empty days no longer show misleading hover details.

Made-with: Cursor
2026-04-13 22:58:36 +03:00
429a2924d7 Merge pull request 'fix(frontend): animate full race list row on hover (li, not inner link)' (#24) from fix/race-list-card-hover into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #24
2026-04-13 19:51:07 +00:00
Vaka.pro
afb0f7ef31 fix(frontend): animate full race list row on hover (li, not inner link)
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Move scale/shadow transition to .race-card--action; keyboard focus ring on link.
Version 0.4.2.

Made-with: Cursor
2026-04-13 22:47:06 +03:00
92c2360feb Merge pull request 'fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX' (#23) from fix/dashboard-form-races-ux into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #23
2026-04-13 19:36:10 +00:00
Vaka.pro
4ea8faf16f fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- Set status to completed when finish time parses (input + submit)
- Dashboard: last personal record by recent date+time; links on top 3 cards
- Hover scale+shadow on all dashboard-card; linked card padding via BEM
- Race list: full row links to race detail; same hover as before
- Calendar year grid: 3 columns, 2 on tablet, 1 on narrow
- Version 0.4.1

Made-with: Cursor
2026-04-13 22:34:39 +03:00
74f059593e Merge pull request 'feat(frontend): race form, start time selects, calendar views, day page' (#22) from feat/race-ui-plan-implementation into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #22
2026-04-13 19:09:11 +00:00
87 changed files with 7145 additions and 1123 deletions

View File

@@ -20,9 +20,6 @@ DB_PASSWORD=replace_with_strong_secret
# PORT=3000
API_PORT=3001
# ─── Режим без БД (только тесты / smoke API, не для migrate/seed) ─
# CALENDAR_RUN_MOCK_DB=1
# ─── CORS ────────────────────────────────────────────────────
# Должен совпадать с origin в браузере (схема + хост + порт, без пути), иначе API «молчит».
# Локальный Vite: http://localhost:5173
@@ -30,6 +27,34 @@ API_PORT=3001
# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru
CORS_ORIGIN=http://localhost:5173
# ─── Auth / sessions ─────────────────────────────────────────
# APP_BASE_URL is the only source for verify/reset email links.
APP_BASE_URL=http://localhost:5173
SESSION_SECRET=replace_with_32plus_char_random_secret
# Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http.
# SESSION_COOKIE_NAME=__Host-sid
# SESSION_COOKIE_SECURE=true
# SESSION_TTL_DAYS=30
# AUTH_CLEANUP_INTERVAL_HOURS=24
# ─── Cloudflare Turnstile ────────────────────────────────────
TURNSTILE_SECRET_KEY=replace_with_turnstile_secret
# Local tests/dev only, rejected in production:
# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token
# ─── SMTP email ──────────────────────────────────────────────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=replace_with_smtp_user
SMTP_PASSWORD=replace_with_smtp_password
SMTP_FROM=Calendar Run <no-reply@example.com>
# ─── Seed after auth ─────────────────────────────────────────
# Required once users exist, so seed never creates ownerless races or overwrites user edits.
# SEED_OWNER_USER_ID=
# SEED_OWNER_EMAIL=
# ─── Версия API (опционально) ─────────────────────────────────
# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health).
# APP_VERSION=1.0.0

View File

@@ -9,6 +9,20 @@ on:
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: calendar_run_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d calendar_run_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
@@ -23,10 +37,18 @@ jobs:
- name: Backend — install, build, test
working-directory: backend
env:
CALENDAR_RUN_MOCK_DB: "1"
NODE_ENV: test
DB_HOST: 127.0.0.1
DB_PORT: "5432"
DB_NAME: calendar_run_test
DB_USER: postgres
DB_PASSWORD: postgres
SESSION_SECRET: test-session-secret
TURNSTILE_BYPASS_TOKEN: mock-turnstile-token
run: |
npm ci
npm run build
npm run db:migrate
npm test
- name: Frontend — install, build

5
.gitignore vendored
View File

@@ -2,4 +2,7 @@ node_modules/
dist/
.env
*.log
.DS_Store
*.tsbuildinfo
vite.config.js
vite.config.d.ts
.DS_Store

291
CHANGELOG.md Normal file
View File

@@ -0,0 +1,291 @@
# Changelog
Все заметки о выпусках Calendar Run. Backend и frontend имеют независимые версии.
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
## Backend
### [1.6.0] — 2026-07-13
#### Added
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
### [1.5.3] — 2026-07-12
#### Fixed
- Миграции базы данных автоматически выполняются перед запуском backend-контейнера.
### [1.5.2] — 2026-07-12
#### Added
- Настраиваемое имя публичного профиля и раздельная видимость будущих и завершённых стартов.
- Публичные результаты финиша с учётом настроек видимости владельца.
### [1.5.1] — 2026-07-12
#### Added
- Публичные страницы пользователей и API опубликованного календаря стартов.
### [1.5.0] — 2026-07-12
#### Added
- Проверка и смена текущего пароля.
- Список активных сессий, отзыв выбранной сессии или всех остальных сессий.
### [1.4.2] — 2026-07-12
#### Changed
- CI и тесты используют настоящий PostgreSQL с миграциями; самодельный SQL mock удалён.
- Контракт API стартов актуализирован: UUID `id`, пользовательский `slug`, авторизация, CSRF и строгая валидация запросов.
- Получение обложки по сайту организатора ограничено безопасными публичными HTTP(S)-адресами, редиректами, размером и временем ответа.
### [1.4.1] — 2026-05-24
#### Changed
- Усилена безопасность аутентификации, сессий и production security profile.
- Добавлены проверки, предотвращающие использование dev-bypass Turnstile в production.
### [1.4.0] — 2026-05-24
#### Added
- Регистрация, вход/выход, подтверждение email, повторная отправка письма и сброс пароля.
- Cookie-сессии, CSRF-защита, rate limit и Turnstile.
- Владение стартами: пользователь видит и изменяет только свои записи.
- SMTP-уведомления и очистка истёкших auth-сущностей.
### [1.3.0] — 2026-04-27
#### Added
- Необязательное автоматическое извлечение обложки старта по сайту организатора.
- Поле `coverImageUrl` в модели, миграции и API.
### [1.2.2] — 2026-04-12
#### Changed
- Patch-релиз: удалено временное диагностическое логирование запросов.
### [1.2.1] — 2026-04-10
#### Added
- Временная диагностика `Host`/`Origin` и статуса ответа для расследования проблем подключения.
### [1.2.0] — 2026-04-08
#### Changed
- Унифицирован контракт API на префиксе `/api` для Express, Vite и nginx.
### [1.1.0] — 2026-04-08
#### Added
- `GET /api/meta` для отображения серверной версии в интерфейсе.
### [1.0.1] — 2026-04-08
#### Fixed
- Совместимость маршрутов API с прокси и клиентским префиксом `/api`.
### [1.0.0] — 2026-04-01
#### Added
- Базовый Express API для стартов, PostgreSQL-миграции, seed из CSV и health/readiness endpoints.
## 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
- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места.
- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки.
- Карточка личного рекорда отображается только при реальном улучшении результата.
- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек.
### [0.7.0] — 2026-05-24
#### Added
- Экраны регистрации, входа, подтверждения email и сброса пароля.
- Защищённые маршруты, состояние сессии и выход из аккаунта.
### [0.6.2] — 2026-05-24
#### Added
- Табы «Будущие» и «Прошедшие» для компактного мобильного списка стартов.
### [0.6.1] — 2026-04-27
#### Changed
- Обложка ближайшего старта используется фоном hero-блока дашборда.
### [0.6.0] — 2026-04-27
#### Added
- Поддержка пользовательской и автоматически полученной обложки старта в списках и карточке.
### [0.5.2] — 2026-04-27
#### Added
- Favicon сервиса.
### [0.5.1] — 2026-04-27
#### Fixed
- Корректное определение версии frontend из `package.json`.
### [0.5.0] — 2026-04-22
#### Changed
- Редизайн дашборда и визуального представления стартов.
### [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
- Форма старта с селектами времени, просмотр календаря годом и месяцем, страница конкретного дня.
- Предзаполнение даты при добавлении из календаря.
### [0.3.1] — 2026-04-12
#### Fixed
- Исправления UX списка и календаря, автоматическая установка статуса «Пробежал» при валидном времени финиша.
### [0.3.0] — 2026-04-08
#### Changed
- Единый клиентский путь `/api` и устойчивый разбор ответов сервера.
### [0.2.0] — 2026-04-08
#### Added
- Русский UI и футер с версиями frontend и backend.
### [0.1.1] — 2026-04-08
#### Fixed
- Получение версии приложения из `package.json` вместо Vite define.
### [0.1.0] — 2026-04-06
#### Added
- React/Vite приложение, типизированный API-клиент, список и карточка стартов, формы CRUD, дашборд и базовая аналитика результатов.
## Как добавлять запись при выпуске
1. Изменить версию соответствующего пакета по SemVer и обновить lockfile.
2. Добавить только фактически выпущенные изменения в раздел `Backend` или `Frontend`.
3. Если изменился API — обновить [docs/backend-api-for-frontend.md](docs/backend-api-for-frontend.md) в том же PR.
4. Перед выпуском проверить, что футер показывает новую версию frontend и фактическую версию backend.

View File

@@ -19,4 +19,4 @@ COPY backend/migrations ./migrations
COPY import ./import
EXPOSE 3000
ENV PORT=3000
CMD ["node", "dist/index.js"]
CMD ["sh", "-c", "node dist/migrate.js && node dist/index.js"]

View File

@@ -4,35 +4,33 @@
## Переменные окружения
Один шаблон для локальной разработки и для 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**`, опционально `**CALENDAR_RUN_MOCK_DB**` и `**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`
Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; `**db:migrate` и `seed` с mock не использовать**.
## Frontend — локально
```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
@@ -40,10 +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

@@ -0,0 +1,2 @@
ALTER TABLE races
ADD COLUMN IF NOT EXISTS cover_image_url TEXT;

View File

@@ -0,0 +1,114 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_normalized_key
ON users (LOWER(BTRIM(email)));
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
csrf_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
ON email_verification_tokens(user_id);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens(user_id);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
DO $$
DECLARE
id_type TEXT;
pk_name TEXT;
BEGIN
SELECT data_type INTO id_type
FROM information_schema.columns
WHERE table_name = 'races' AND column_name = 'id';
IF id_type IS NOT NULL AND id_type <> 'uuid' THEN
SELECT conname INTO pk_name
FROM pg_constraint
WHERE conrelid = 'races'::regclass AND contype = 'p'
LIMIT 1;
IF pk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE races DROP CONSTRAINT %I', pk_name);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'races' AND column_name = 'slug'
) THEN
ALTER TABLE races RENAME COLUMN id TO slug;
ELSE
ALTER TABLE races DROP COLUMN id;
END IF;
END IF;
END $$;
ALTER TABLE races ADD COLUMN IF NOT EXISTS id UUID DEFAULT gen_random_uuid();
UPDATE races SET id = gen_random_uuid() WHERE id IS NULL;
ALTER TABLE races ALTER COLUMN id SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'races'::regclass AND contype = 'p'
) THEN
ALTER TABLE races ADD CONSTRAINT races_pkey PRIMARY KEY (id);
END IF;
END $$;
ALTER TABLE races ADD COLUMN IF NOT EXISTS slug TEXT;
UPDATE races SET slug = id::text WHERE slug IS NULL OR BTRIM(slug) = '';
ALTER TABLE races ALTER COLUMN slug SET NOT NULL;
ALTER TABLE races ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE races ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user';
CREATE UNIQUE INDEX IF NOT EXISTS races_owner_slug_key
ON races(owner_user_id, slug)
WHERE owner_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS races_owner_user_id_idx ON races(owner_user_id);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_profile_public BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,12 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_username TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_future_races_public BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_completed_races_public BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET is_future_races_public = is_profile_public,
is_completed_races_public = is_profile_public
WHERE is_profile_public = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS users_profile_username_normalized_key
ON users (LOWER(profile_username))
WHERE profile_username IS NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE races
ADD COLUMN IF NOT EXISTS cover_image_manual BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -1,26 +1,33 @@
{
"name": "calendar-run-backend",
"version": "1.2.2",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.2.2",
"version": "1.6.0",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.1"
"express-rate-limit": "^8.4.1",
"helmet": "^8.1.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
@@ -44,7 +51,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
@@ -540,6 +546,15 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -589,6 +604,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -651,11 +676,20 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -773,6 +807,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/argon2": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@phc/format": "^1.0.0",
"cross-env": "^10.0.0",
"node-addon-api": "^8.5.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -908,6 +958,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -949,7 +1018,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
@@ -967,7 +1035,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -1233,6 +1300,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -1437,6 +1522,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1475,6 +1569,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1488,7 +1591,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/make-error": {
@@ -1582,6 +1684,35 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1638,7 +1769,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1655,7 +1785,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -1922,7 +2051,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -1935,7 +2063,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2207,7 +2334,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2261,7 +2387,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -2298,6 +2423,15 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.2.2",
"version": "1.6.0",
"private": true,
"scripts": {
"build": "tsc",
@@ -8,22 +8,30 @@
"start": "node dist/index.js",
"db:migrate": "ts-node src/migrate.ts",
"seed": "ts-node src/seed.ts",
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
"backfill:covers": "ts-node src/backfillRaceCovers.ts",
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
},
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.1"
"express-rate-limit": "^8.4.1",
"helmet": "^8.1.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",

View File

@@ -1,18 +1,61 @@
import express, { Request, Response, NextFunction } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { config } from "./config";
import { loadAuth, requireCsrf } from "./authMiddleware";
import authRouter from "./routes/auth";
import healthRouter from "./routes/health";
import racesRouter from "./routes/races";
import usersRouter from "./routes/users";
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
export function buildHelmetOptions(securityProfile: string) {
return {
contentSecurityPolicy:
securityProfile === "production"
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", TURNSTILE_ORIGIN],
styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", TURNSTILE_ORIGIN],
frameSrc: [TURNSTILE_ORIGIN],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
}
: false,
hsts:
securityProfile === "production"
? { maxAge: 31_536_000, includeSubDomains: true }
: false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" as const },
};
}
export function createApp(): express.Express {
const app = express();
app.use(helmet(buildHelmetOptions(config.securityProfile)));
app.use(
cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }),
cors({
origin: config.corsOrigin,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"],
}),
);
app.use(express.json());
app.use(cookieParser(config.session.secret));
app.use(loadAuth);
app.use(requireCsrf);
app.use("/api", healthRouter);
app.use("/api", authRouter);
app.use("/api", usersRouter);
app.use("/api", racesRouter);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {

View File

@@ -0,0 +1,86 @@
import { NextFunction, Request, Response } from "express";
import { config } from "./config";
import { csrfMatches, getSession } from "./authService";
declare global {
namespace Express {
interface Request {
auth?: {
user: {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
};
csrfTokenHash: string;
sessionToken: string;
};
}
}
}
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export function setSessionCookie(res: Response, sessionToken: string): void {
res.cookie(config.session.cookieName, sessionToken, {
httpOnly: true,
secure: config.session.secure,
sameSite: "lax",
path: "/",
maxAge: config.session.ttlDays * 24 * 60 * 60 * 1000,
});
}
export function clearSessionCookie(res: Response): void {
res.clearCookie(config.session.cookieName, {
httpOnly: true,
secure: config.session.secure,
sameSite: "lax",
path: "/",
});
}
export async function loadAuth(req: Request, _res: Response, next: NextFunction): Promise<void> {
const token = req.cookies?.[config.session.cookieName];
if (typeof token !== "string" || token.trim() === "") {
next();
return;
}
try {
const session = await getSession(token);
if (session) {
req.auth = { ...session, sessionToken: token };
}
} catch (error) {
next(error);
return;
}
next();
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (!req.auth) {
res.status(401).json({ error: "unauthorized", details: ["Authentication required"] });
return;
}
if (!req.auth.user.emailVerifiedAt) {
res.status(403).json({ error: "email_not_verified", details: ["Email verification required"] });
return;
}
next();
}
export function requireCsrf(req: Request, res: Response, next: NextFunction): void {
if (!MUTATING_METHODS.has(req.method) || !req.auth) {
next();
return;
}
const token = req.header("X-CSRF-Token");
if (!token || !csrfMatches(req.auth.csrfTokenHash, token)) {
res.status(403).json({ error: "csrf_error", details: ["Invalid CSRF token"] });
return;
}
next();
}

542
backend/src/authService.ts Normal file
View File

@@ -0,0 +1,542 @@
import { PoolClient } from "pg";
import { config } from "./config";
import { pool } from "./db";
import { sendMail } from "./mailer";
import {
addDays,
addHours,
hashPassword,
normalizeEmail,
randomToken,
sha256Hex,
timingSafeEqualHex,
verifyPassword,
} from "./security";
import { anonymizeEmail, securityLog } from "./securityLog";
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}
interface UserRow {
id: string;
email: string;
password_hash: string;
email_verified_at: Date | string | null;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionRow {
id: string;
user_id: string;
token_hash: string;
csrf_token_hash: string;
expires_at: Date | string;
email: string;
email_verified_at: Date | string | null;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionListRow {
id: string;
created_at: Date | string;
last_seen_at: Date | string;
expires_at: Date | string;
current: boolean;
}
export interface ActiveSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}
function toIso(value: Date | string | null): string | null {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : String(value);
}
function userFromRow(row: UserRow): AuthUser {
return {
id: row.id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
};
}
function appUrl(path: string, token: string): string {
const url = new URL(path, config.appBaseUrl);
url.searchParams.set("token", token);
return url.toString();
}
function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505";
}
export async function findUserByEmail(email: string): Promise<UserRow | null> {
const normalized = normalizeEmail(email);
const { rows } = await pool.query<UserRow>(
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1`,
[normalized],
);
return rows[0] ?? null;
}
export async function createVerificationToken(client: PoolClient, userId: string): Promise<string> {
await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL",
[userId],
);
const token = randomToken(32);
await client.query(
`INSERT INTO email_verification_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[userId, sha256Hex(token), addHours(new Date(), 24)],
);
return token;
}
export async function createResetToken(client: PoolClient, userId: string): Promise<string> {
await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL",
[userId],
);
const token = randomToken(32);
await client.query(
`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[userId, sha256Hex(token), addHours(new Date(), 1)],
);
return token;
}
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
await sendMail(
email,
"Подтвердите email в Calendar Run",
`Откройте ссылку для подтверждения email: ${appUrl("/verify-email", token)}`,
);
}
export async function sendResetEmail(email: string, token: string): Promise<void> {
await sendMail(
email,
"Сброс пароля Calendar Run",
`Откройте ссылку для сброса пароля: ${appUrl("/reset-password", token)}`,
);
}
export async function registerUser(email: string, password: string): Promise<void> {
const normalized = normalizeEmail(email);
const passwordHash = await hashPassword(password);
const client = await pool.connect();
try {
await client.query("BEGIN");
let rows: UserRow[];
try {
({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public`,
[normalized, passwordHash],
));
} catch (error) {
if (isUniqueViolation(error)) {
await client.query("ROLLBACK");
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
return;
}
throw error;
}
const user = rows[0];
if (user) {
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
securityLog("register.created", { emailHash: anonymizeEmail(normalized) });
return;
}
await client.query("COMMIT");
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function createSession(userId: string): Promise<{ sessionToken: string; csrfToken: string; user: AuthUser }> {
const sessionToken = randomToken(32);
const csrfToken = randomToken(32);
const expiresAt = addDays(new Date(), config.session.ttlDays);
const { rows } = await pool.query<SessionRow>(
`INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, token_hash, csrf_token_hash, expires_at,
(SELECT email FROM users WHERE id = $1) AS email,
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at,
(SELECT profile_username FROM users WHERE id = $1) AS profile_username,
(SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public,
(SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`,
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
);
const row = rows[0];
securityLog("session.issued", { userId });
return {
sessionToken,
csrfToken,
user: {
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
};
}
export async function loginUser(email: string, password: string): Promise<{ ok: false } | { ok: true; sessionToken: string; csrfToken: string; user: AuthUser }> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
const passwordOk = await verifyPassword(user?.password_hash ?? null, password);
if (!user || !passwordOk || !user.email_verified_at) {
securityLog("login.failed", { emailHash: anonymizeEmail(normalized) });
return { ok: false };
}
const session = await createSession(user.id);
securityLog("login.succeeded", { userId: user.id });
return { ok: true, ...session };
}
export async function revokeSession(sessionToken: string): Promise<void> {
await pool.query("UPDATE sessions SET revoked_at = NOW() WHERE token_hash = $1", [sha256Hex(sessionToken)]);
securityLog("session.revoked");
}
export async function listActiveSessions(userId: string, sessionToken: string): Promise<ActiveSession[]> {
const { rows } = await pool.query<SessionListRow>(
`SELECT id, created_at, last_seen_at, expires_at, token_hash = $2 AS current
FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC`,
[userId, sha256Hex(sessionToken)],
);
return rows.map((row) => ({
id: row.id,
createdAt: toIso(row.created_at)!,
lastSeenAt: toIso(row.last_seen_at)!,
expiresAt: toIso(row.expires_at)!,
current: row.current,
}));
}
export async function revokeSessionById(userId: string, sessionId: string, sessionToken: string): Promise<{ revoked: boolean; current: boolean }> {
const { rows } = await pool.query<{ current: boolean }>(
`UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()
RETURNING token_hash = $3 AS current`,
[sessionId, userId, sha256Hex(sessionToken)],
);
const row = rows[0];
if (!row) {
return { revoked: false, current: false };
}
securityLog("session.revoked", { userId });
return { revoked: true, current: row.current };
}
export async function revokeOtherSessions(userId: string, sessionToken: string): Promise<void> {
await pool.query(
`UPDATE sessions
SET revoked_at = NOW()
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > NOW()`,
[userId, sha256Hex(sessionToken)],
);
securityLog("session.others_revoked", { userId });
}
export async function changePassword(
userId: string,
sessionToken: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const newPasswordHash = await hashPassword(newPassword);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ password_hash: string }>(
"SELECT password_hash FROM users WHERE id = $1 FOR UPDATE",
[userId],
);
const user = rows[0];
if (!user || !(await verifyPassword(user.password_hash, currentPassword))) {
await client.query("ROLLBACK");
return false;
}
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [userId, newPasswordHash]);
await client.query(
"UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL",
[userId, sha256Hex(sessionToken)],
);
await client.query("COMMIT");
securityLog("password.changed", { userId });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function rotateCsrf(sessionToken: string): Promise<string | null> {
const csrfToken = randomToken(32);
const { rowCount } = await pool.query(
`UPDATE sessions
SET csrf_token_hash = $2, last_seen_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
[sha256Hex(sessionToken), sha256Hex(csrfToken)],
);
return rowCount && rowCount > 0 ? csrfToken : null;
}
export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> {
const tokenHash = sha256Hex(sessionToken);
const { rows } = await pool.query<SessionRow>(
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at,
u.profile_username, u.is_future_races_public, u.is_completed_races_public
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`,
[tokenHash],
);
const row = rows[0];
if (!row) {
return null;
}
await pool.query("UPDATE sessions SET last_seen_at = NOW(), expires_at = $2 WHERE id = $1", [
row.id,
addDays(new Date(), config.session.ttlDays),
]);
return {
user: {
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
csrfTokenHash: row.csrf_token_hash,
};
}
export async function updateProfile(
userId: string,
profile: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
},
): Promise<void> {
await pool.query(
`UPDATE users SET
profile_username = CASE WHEN $2 THEN $3 ELSE profile_username END,
is_future_races_public = CASE WHEN $4 THEN $5 ELSE is_future_races_public END,
is_completed_races_public = CASE WHEN $6 THEN $7 ELSE is_completed_races_public END,
updated_at = NOW()
WHERE id = $1`,
[
userId,
profile.profileUsername !== undefined,
profile.profileUsername,
profile.isFutureRacesPublic !== undefined,
profile.isFutureRacesPublic,
profile.isCompletedRacesPublic !== undefined,
profile.isCompletedRacesPublic,
],
);
}
export function csrfMatches(hash: string, token: string): boolean {
return timingSafeEqualHex(hash, sha256Hex(token));
}
export async function verifyEmailToken(token: string): Promise<boolean> {
const tokenHash = sha256Hex(token);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash
FROM email_verification_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
);
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) {
await client.query("ROLLBACK");
return false;
}
const consumed = await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]);
await client.query("SELECT pg_advisory_xact_lock(706365)");
const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE");
if (claimed.rows.length === 0) {
await client.query("UPDATE races SET owner_user_id = $1, updated_at = NOW() WHERE owner_user_id IS NULL", [row.user_id]);
await client.query(
`INSERT INTO app_settings (key, value)
VALUES ('orphan_races_claimed_by_user_id', $1)`,
[row.user_id],
);
}
await client.query("COMMIT");
securityLog("email.verified", { userId: row.user_id });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function requestPasswordReset(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user) {
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const token = await createResetToken(client, user.id);
await client.query("COMMIT");
await sendResetEmail(user.email, token);
securityLog("password_reset.requested", { userId: user.id });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function resetPassword(token: string, password: string): Promise<boolean> {
const tokenHash = sha256Hex(token);
const passwordHash = await hashPassword(password);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash
FROM password_reset_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
);
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) {
await client.query("ROLLBACK");
return false;
}
const consumed = await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]);
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]);
await client.query("COMMIT");
securityLog("password_reset.completed", { userId: row.user_id });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function resendVerification(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user || user.email_verified_at) {
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function cleanupExpiredAuthRows(): Promise<void> {
await pool.query("DELETE FROM sessions WHERE expires_at <= NOW() OR revoked_at < NOW() - INTERVAL '30 days'");
await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
}
export function startAuthCleanupSchedule(intervalHours = config.authCleanupIntervalHours): NodeJS.Timeout {
const runCleanup = () => {
void cleanupExpiredAuthRows().catch((error) => {
console.error("[auth-cleanup] Failed:", error);
});
};
runCleanup();
const safeHours = Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : 24;
const timer = setInterval(runCleanup, safeHours * 60 * 60 * 1000);
timer.unref?.();
return timer;
}

View File

@@ -0,0 +1,55 @@
import { pool } from "./db";
import { extractRaceCoverImage } from "./raceCoverImage";
type Candidate = { id: string; official_url: string };
export type CoverBackfillStats = {
candidates: number;
restored: number;
skipped: number;
};
export async function backfillRaceCovers(): Promise<CoverBackfillStats> {
const { rows } = await pool.query<Candidate>(
`SELECT id, official_url
FROM races
WHERE cover_image_url IS NULL
AND official_url IS NOT NULL
AND BTRIM(official_url) <> ''
ORDER BY race_date ASC, id ASC`,
);
const stats: CoverBackfillStats = { candidates: rows.length, restored: 0, skipped: 0 };
// ponytail: sequential network calls keep memory and outbound load bounded; parallelize per batch if volume requires it.
for (const row of rows) {
const coverImageUrl = await extractRaceCoverImage(row.official_url);
if (!coverImageUrl) {
stats.skipped += 1;
continue;
}
const result = await pool.query(
`UPDATE races
SET cover_image_url = $1, cover_image_manual = FALSE, updated_at = NOW()
WHERE id = $2 AND cover_image_url IS NULL`,
[coverImageUrl, row.id],
);
if (result.rowCount === 1) {
stats.restored += 1;
} else {
stats.skipped += 1;
}
}
return stats;
}
if (require.main === module) {
backfillRaceCovers()
.then((stats) => console.log(JSON.stringify(stats)))
.catch((error: unknown) => {
console.error("[backfill:covers] FAILED:", error instanceof Error ? error.message : error);
process.exitCode = 1;
})
.finally(() => pool.end());
}

View File

@@ -11,30 +11,68 @@ function requireEnv(name: string): string {
return value;
}
const useMockDb =
process.env.CALENDAR_RUN_MOCK_DB === "1" ||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
function optionalEnv(name: string): string | null {
const value = process.env[name]?.trim();
return value ? value : null;
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value == null || value.trim() === "") {
return fallback;
}
return value === "1" || value.toLowerCase() === "true";
}
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
export function resolveTurnstileBypassToken(params: {
rawBypassToken?: string;
securityProfile: string;
}): string {
const raw = params.rawBypassToken?.trim() ?? "";
if (raw && params.securityProfile === "production") {
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
}
return raw;
}
export const config = {
useMockDb,
db: useMockDb
? {
host: "mock",
port: 5432,
database: "mock",
user: "mock",
password: "mock",
}
: {
host: requireEnv("DB_HOST"),
port: parseInt(requireEnv("DB_PORT"), 10),
database: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
},
db: {
host: requireEnv("DB_HOST"),
port: parseInt(requireEnv("DB_PORT"), 10),
database: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
},
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
/** Одно значение или несколько через запятую (прод: https://домен) */
corsOrigin: parseCorsOrigins(),
appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173",
session: {
secret: requireEnv("SESSION_SECRET"),
cookieName:
process.env.SESSION_COOKIE_NAME?.trim() ||
(process.env.NODE_ENV === "production" ? "__Host-sid" : "sid"),
secure: parseBoolean(process.env.SESSION_COOKIE_SECURE, process.env.NODE_ENV === "production"),
ttlDays: parseInt(process.env.SESSION_TTL_DAYS || "30", 10),
},
smtp: {
host: optionalEnv("SMTP_HOST"),
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: parseBoolean(process.env.SMTP_SECURE, false),
user: optionalEnv("SMTP_USER"),
password: optionalEnv("SMTP_PASSWORD"),
from: process.env.SMTP_FROM?.trim() || "Calendar Run <no-reply@example.com>",
},
turnstile: {
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || "",
bypassToken: resolveTurnstileBypassToken({
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
securityProfile,
}),
},
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
securityProfile,
};
function parseCorsOrigins(): string | string[] {

View File

@@ -1,6 +1,5 @@
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
import { Pool, PoolConfig } from "pg";
import { config } from "./config";
import type { RaceRow } from "./mappers/race";
const poolConfig: PoolConfig = {
host: config.db.host,
@@ -13,158 +12,13 @@ const poolConfig: PoolConfig = {
connectionTimeoutMillis: 5_000,
};
function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
const match = sql.match(/INSERT INTO races\s*\(([^)]+)\)\s*VALUES/i);
const now = new Date();
if (!match) {
return {
id: String(params[0] ?? ""),
race_date: "",
title: "",
distance_km: "0",
status: null,
official_url: null,
start_time: null,
cluster_schedule: null,
bib_pickup: null,
bib_number: null,
finish_time: null,
finish_place: null,
notes: null,
created_at: now,
updated_at: null,
};
}
const cols = match[1].split(",").map((c) => c.trim());
const row: Record<string, unknown> = {};
cols.forEach((col, i) => {
row[col] = params[i];
});
return {
id: String(row.id ?? ""),
race_date: String(row.race_date ?? ""),
title: String(row.title ?? ""),
distance_km: String(row.distance_km ?? "0"),
status: row.status != null ? String(row.status) : null,
official_url: row.official_url != null ? String(row.official_url) : null,
start_time: row.start_time != null ? String(row.start_time) : null,
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
bib_number: row.bib_number != null ? String(row.bib_number) : null,
finish_time: row.finish_time != null ? String(row.finish_time) : null,
finish_place: row.finish_place != null ? String(row.finish_place) : null,
notes: row.notes != null ? String(row.notes) : null,
created_at: now,
updated_at: null,
};
}
export const pool = new Pool(poolConfig);
function createMockPool(): Pool {
const emptyResult = <T extends QueryResultRow>(): QueryResult<T> =>
({
rows: [],
rowCount: 0,
command: "",
oid: 0,
fields: [],
}) as QueryResult<T>;
const store = new Map<string, RaceRow>();
const mockQuery = async <T extends QueryResultRow>(
text: string,
params?: unknown[],
): Promise<QueryResult<T>> => {
const sql = text.replace(/\s+/g, " ").trim();
const p = params ?? [];
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
const row = mockRowFromInsert(text, p);
store.set(row.id, row);
return {
rows: [row as unknown as T],
rowCount: 1,
command: "INSERT",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("DELETE FROM races")) {
const id = String(p[0] ?? "");
const existed = store.delete(id);
return {
rows: [],
rowCount: existed ? 1 : 0,
command: "DELETE",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
const id = String(p[p.length - 1] ?? "");
const existing = store.get(id);
if (!existing) {
return emptyResult();
}
const updated = { ...existing, updated_at: new Date() };
store.set(id, updated);
return {
rows: [updated as unknown as T],
rowCount: 1,
command: "UPDATE",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("SELECT * FROM races WHERE id =")) {
const id = String(p[0] ?? "");
const row = store.get(id);
return row
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
: emptyResult();
}
if (sql.includes("SELECT * FROM races")) {
const rows = Array.from(store.values());
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
}
return emptyResult();
};
const mockPool = {
query: mockQuery,
connect: async () => {
throw new Error(
"CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.",
);
},
end: async () => {},
on() {
return mockPool;
},
};
return mockPool as unknown as Pool;
}
export const pool = config.useMockDb ? createMockPool() : new Pool(poolConfig);
if (!config.useMockDb) {
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err.message);
});
} else {
console.warn("[db] Mock database enabled (CALENDAR_RUN_MOCK_DB); no PostgreSQL connection is used.");
}
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err.message);
});
export async function checkDbConnection(): Promise<boolean> {
if (config.useMockDb) {
return true;
}
try {
const client = await pool.connect();
client.release();

View File

@@ -1,7 +1,9 @@
import { config } from "./config";
import { createApp } from "./app";
import { startAuthCleanupSchedule } from "./authService";
const app = createApp();
startAuthCleanupSchedule();
app.listen(config.apiPort, () => {
console.log(`[api] Listening on http://localhost:${config.apiPort}`);

26
backend/src/mailer.ts Normal file
View File

@@ -0,0 +1,26 @@
import nodemailer from "nodemailer";
import { config } from "./config";
export async function sendMail(to: string, subject: string, text: string): Promise<void> {
if (!config.smtp.host) {
console.info("[mail] SMTP is not configured; email content follows.", { to, subject, text });
return;
}
const transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
auth:
config.smtp.user && config.smtp.password
? { user: config.smtp.user, pass: config.smtp.password }
: undefined,
});
await transporter.sendMail({
from: config.smtp.from,
to,
subject,
text,
});
}

View File

@@ -4,11 +4,15 @@
*/
export interface RaceRow {
id: string;
slug: string;
owner_user_id: string | null;
race_date: string | Date;
title: string;
distance_km: string;
status: string | null;
official_url: string | null;
cover_image_url: string | null;
cover_image_manual: boolean;
start_time: string | null;
cluster_schedule: string | null;
bib_pickup: string | null;
@@ -23,11 +27,13 @@ export interface RaceRow {
/** API shape (camelCase). */
export interface RaceDto {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
status: string | null;
officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
@@ -59,11 +65,13 @@ function raceDateToApiValue(value: string | Date): string {
export function rowToDto(row: RaceRow): RaceDto {
return {
id: row.id,
slug: row.slug,
date: raceDateToApiValue(row.race_date),
title: row.title,
distanceKm: parseFloat(row.distance_km),
status: row.status,
officialUrl: row.official_url,
coverImageUrl: row.cover_image_url ?? null,
startTime: row.start_time,
clusterSchedule: row.cluster_schedule,
bibPickup: row.bib_pickup,
@@ -78,11 +86,13 @@ export function rowToDto(row: RaceRow): RaceDto {
/** Map incoming camelCase body fields to snake_case column names. */
const FIELD_MAP: Record<string, string> = {
slug: "slug",
date: "race_date",
title: "title",
distanceKm: "distance_km",
status: "status",
officialUrl: "official_url",
coverImageUrl: "cover_image_url",
startTime: "start_time",
clusterSchedule: "cluster_schedule",
bibPickup: "bib_pickup",

View File

@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
import { pool } from "./db";
async function migrate() {
export async function migrate() {
console.log("[migrate] Running migrations…");
const migrationsDir = path.resolve(__dirname, "../migrations");
@@ -36,11 +36,14 @@ async function migrate() {
console.log("[migrate] Done.");
} finally {
client.release();
await pool.end();
}
}
migrate().catch((err) => {
console.error("[migrate] FAILED:", err.message);
process.exit(1);
});
if (require.main === module) {
migrate()
.catch((err) => {
console.error("[migrate] FAILED:", err.message);
process.exitCode = 1;
})
.finally(() => pool.end());
}

View File

@@ -0,0 +1,190 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const IMAGE_META_KEYS = new Set([
"og:image",
"og:image:url",
"twitter:image",
"twitter:image:src",
]);
const FETCH_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 3;
const MAX_HTML_BYTES = 1_000_000;
function getAttribute(tag: string, name: string): string | null {
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, "i");
return tag.match(pattern)?.[1] ?? null;
}
function toHttpUrl(value: string, baseUrl: string): string | null {
try {
const url = new URL(value, baseUrl);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
function isPrivateIp(address: string): boolean {
if (isIP(address) === 4) {
const [first, second] = address.split(".").map(Number);
return first === 0 || first === 10 || first === 127 || first >= 224 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && (second === 0 || second === 168)) ||
(first === 198 && (second === 18 || second === 19));
}
const normalized = address.toLowerCase();
return normalized === "::" || normalized === "::1" || normalized.startsWith("::ffff:") ||
/^f[cd]/.test(normalized) || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff");
}
async function isSafePublicHttpUrl(value: string): Promise<boolean> {
const normalized = toHttpUrl(value, value);
if (!normalized) {
return false;
}
const hostname = new URL(normalized).hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
return false;
}
if (isIP(hostname)) {
return !isPrivateIp(hostname);
}
try {
const addresses = await lookup(hostname, { all: true, verbatim: true });
return addresses.length > 0 && addresses.every(({ address }) => !isPrivateIp(address));
} catch {
return false;
}
}
async function readHtml(response: Response): Promise<string | null> {
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_HTML_BYTES) {
return null;
}
const reader = response.body?.getReader();
if (!reader) {
return "";
}
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return new TextDecoder().decode(Buffer.concat(chunks));
}
size += value.byteLength;
if (size > MAX_HTML_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function isRuncRunUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
return hostname === "runc.run" || hostname.endsWith(".runc.run");
} catch {
return false;
}
}
function findRuncIntroImage(html: string, baseUrl: string): string | null {
const introMatch = html.match(/<div\b[^>]*class=["'][^"']*\brun-intro__image\b[^"']*["'][^>]*>[\s\S]*?<img\b[^>]*>/i);
if (!introMatch) {
return null;
}
const src = getAttribute(introMatch[0], "src");
return src ? toHttpUrl(src, baseUrl) : null;
}
function findMetaImage(html: string, baseUrl: string): string | null {
const tags = html.match(/<meta\b[^>]*>/gi) ?? [];
for (const tag of tags) {
const key = (getAttribute(tag, "property") || getAttribute(tag, "name") || "").toLowerCase();
if (!IMAGE_META_KEYS.has(key)) {
continue;
}
const content = getAttribute(tag, "content");
if (!content) {
continue;
}
const imageUrl = toHttpUrl(content, baseUrl);
if (imageUrl) {
return imageUrl;
}
}
return null;
}
export function extractRaceCoverImageFromHtml(html: string, pageUrl: string): string | null {
if (isRuncRunUrl(pageUrl)) {
const runcImage = findRuncIntroImage(html, pageUrl);
if (runcImage) {
return runcImage;
}
}
return findMetaImage(html, pageUrl);
}
export async function extractRaceCoverImage(officialUrl: string): Promise<string | null> {
const normalizedUrl = toHttpUrl(officialUrl, officialUrl);
if (!normalizedUrl) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
let pageUrl = normalizedUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!(await isSafePublicHttpUrl(pageUrl))) {
return null;
}
const response = await fetch(pageUrl, { redirect: "manual", signal: controller.signal });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
const nextUrl = location ? toHttpUrl(location, pageUrl) : null;
if (!nextUrl) {
return null;
}
pageUrl = nextUrl;
continue;
}
if (!response.ok) {
return null;
}
const html = await readHtml(response);
return html == null ? null : extractRaceCoverImageFromHtml(html, pageUrl);
}
return null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}

296
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,296 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import {
changePassword,
listActiveSessions,
loginUser,
registerUser,
requestPasswordReset,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSessionById,
rotateCsrf,
revokeSession,
updateProfile,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
import { isValidPassword, normalizeEmail } from "../security";
import { verifyTurnstileToken } from "../turnstile";
const router: Router = Router();
const genericOk = { ok: true };
const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] };
const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });
const loginLimiter = rateLimit({ windowMs: 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false });
const loginEmailLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `login:${normalizeEmail(String(req.body?.email ?? ""))}`,
});
const emailIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });
const emailAddressLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `email:${normalizeEmail(String(req.body?.email ?? ""))}`,
});
const passwordChangeLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `password:${req.auth?.user.id ?? req.ip}`,
});
const registerSchema = z.object({
email: z.string().email(),
password: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
turnstileToken: z.string().min(1),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const tokenSchema = z.object({
token: z.string().min(16),
});
const emailSchema = z.object({
email: z.string().email(),
});
const resetSchema = z.object({
token: z.string().min(16),
password: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
});
const passwordChangeSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
}).strict();
const sessionIdSchema = z.string().uuid();
const profileUsernameSchema = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
const profileSchema = z.object({
profileUsername: profileUsernameSchema.nullable().optional(),
isFutureRacesPublic: z.boolean().optional(),
isCompletedRacesPublic: z.boolean().optional(),
}).strict().refine((profile) => Object.keys(profile).length > 0);
function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
}
router.post("/auth/register", registerLimiter, async (req: Request, res: Response, next) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const captchaOk = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip);
if (!captchaOk) {
res.status(400).json({ error: "captcha_failed", details: ["Captcha verification failed"] });
return;
}
await registerUser(parsed.data.email, parsed.data.password);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/login", loginLimiter, loginEmailLimiter, async (req: Request, res: Response, next) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(401).json(genericAuthError);
return;
}
try {
const result = await loginUser(parsed.data.email, parsed.data.password);
if (!result.ok) {
res.status(401).json(genericAuthError);
return;
}
setSessionCookie(res, result.sessionToken);
res.json({ user: result.user, csrfToken: result.csrfToken });
} catch (error) {
next(error);
}
});
router.post("/auth/logout", async (req: Request, res: Response, next) => {
try {
if (req.auth) {
await revokeSession(req.auth.sessionToken);
}
clearSessionCookie(res);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get("/auth/me", async (req: Request, res: Response, next) => {
if (!req.auth) {
res.status(401).json({ error: "unauthorized", details: ["Authentication required"] });
return;
}
try {
const csrfToken = await rotateCsrf(req.auth.sessionToken);
res.json({ user: req.auth.user, csrfToken });
} catch (error) {
next(error);
}
});
router.patch("/auth/profile", requireAuth, async (req: Request, res: Response, next) => {
const parsed = profileSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await updateProfile(req.auth!.user.id, parsed.data);
res.json({ ok: true });
} catch (error) {
if (typeof error === "object" && error !== null && (error as { code?: string }).code === "23505") {
res.status(409).json({ error: "username_taken", details: ["Username is already in use"] });
return;
}
next(error);
}
});
router.get("/auth/sessions", requireAuth, async (req: Request, res: Response, next) => {
try {
res.json({ sessions: await listActiveSessions(req.auth!.user.id, req.auth!.sessionToken) });
} catch (error) {
next(error);
}
});
router.post("/auth/password", requireAuth, passwordChangeLimiter, async (req: Request, res: Response, next) => {
const parsed = passwordChangeSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const changed = await changePassword(req.auth!.user.id, req.auth!.sessionToken, parsed.data.currentPassword, parsed.data.newPassword);
if (!changed) {
res.status(400).json({ error: "invalid_current_password", details: ["Current password is incorrect"] });
return;
}
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.delete("/auth/sessions/:id", requireAuth, async (req: Request, res: Response, next) => {
const parsed = sessionIdSchema.safeParse(req.params.id);
if (!parsed.success) {
validationError(res);
return;
}
try {
const result = await revokeSessionById(req.auth!.user.id, parsed.data, req.auth!.sessionToken);
if (!result.revoked) {
res.status(404).json({ error: "not_found", details: ["Session not found"] });
return;
}
if (result.current) {
clearSessionCookie(res);
}
res.status(204).end();
} catch (error) {
next(error);
}
});
router.post("/auth/sessions/revoke-others", requireAuth, async (req: Request, res: Response, next) => {
try {
await revokeOtherSessions(req.auth!.user.id, req.auth!.sessionToken);
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/verify-email", emailIpLimiter, async (req: Request, res: Response, next) => {
const parsed = tokenSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const ok = await verifyEmailToken(parsed.data.token);
if (!ok) {
res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] });
return;
}
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/resend-verification", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => {
const parsed = emailSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await resendVerification(parsed.data.email);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/forgot-password", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => {
const parsed = emailSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await requestPasswordReset(parsed.data.email);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/reset-password", emailIpLimiter, async (req: Request, res: Response, next) => {
const parsed = resetSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const ok = await resetPassword(parsed.data.token, parsed.data.password);
if (!ok) {
res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] });
return;
}
clearSessionCookie(res);
res.json(genericOk);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -2,7 +2,7 @@ import { Router, Request, Response } from "express";
import { checkDbConnection } from "../db";
import { getBackendVersion } from "../version";
const router = Router();
const router: Router = Router();
router.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", version: getBackendVersion() });

View File

@@ -1,8 +1,12 @@
import { Router, Request, Response } from "express";
import { z } from "zod";
import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
import { requireAuth } from "../authMiddleware";
const router = Router();
const router: Router = Router();
router.use(requireAuth);
type ValidationErrorBody = {
error: "validation_error";
@@ -18,6 +22,87 @@ function validationError(res: Response, details: string[]) {
res.status(400).json(body);
}
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional();
const httpUrl = z.string().trim().max(2048).url().refine(
(value) => {
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
},
"Must be an HTTP(S) URL",
);
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD").refine((value) => {
const parsed = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
}, "Must be a valid calendar date");
const raceFields = {
slug: z.string().trim().min(1).max(120).regex(/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u, "Must be a slug"),
date,
title: z.string().trim().min(1).max(200),
distanceKm: z.number().finite().gt(0).max(999.999),
status: z.enum(["planned", "registered", "completed"]).nullable(),
officialUrl: httpUrl.nullable(),
coverImageUrl: httpUrl.nullable(),
startTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/, "Must be HH:MM or HH:MM:SS").nullable(),
clusterSchedule: optionalText(1_000),
bibPickup: optionalText(500),
bibNumber: optionalText(100),
finishTime: z.string().regex(/^(?:[0-5]\d|[0-5]?\d:[0-5]\d|\d+:[0-5]\d:[0-5]\d)$/, "Must be SS, MM:SS or H:MM:SS").nullable(),
finishPlace: optionalText(100),
notes: optionalText(5_000),
};
const createRaceSchema = z.object({
slug: raceFields.slug,
date: raceFields.date,
title: raceFields.title,
distanceKm: raceFields.distanceKm,
status: raceFields.status.optional(),
officialUrl: raceFields.officialUrl.optional(),
coverImageUrl: raceFields.coverImageUrl.optional(),
startTime: raceFields.startTime.optional(),
clusterSchedule: raceFields.clusterSchedule,
bibPickup: raceFields.bibPickup,
bibNumber: raceFields.bibNumber,
finishTime: raceFields.finishTime.optional(),
finishPlace: raceFields.finishPlace,
notes: raceFields.notes,
}).strict();
const updateRaceSchema = z.object({
slug: raceFields.slug.optional(),
date: raceFields.date.optional(),
title: raceFields.title.optional(),
distanceKm: raceFields.distanceKm.optional(),
status: raceFields.status.optional(),
officialUrl: raceFields.officialUrl.optional(),
coverImageUrl: raceFields.coverImageUrl.optional(),
startTime: raceFields.startTime.optional(),
clusterSchedule: raceFields.clusterSchedule,
bibPickup: raceFields.bibPickup,
bibNumber: raceFields.bibNumber,
finishTime: raceFields.finishTime.optional(),
finishPlace: raceFields.finishPlace,
notes: raceFields.notes,
}).strict().refine((body) => Object.keys(body).length > 0, "No updatable fields provided");
function schemaDetails(error: z.ZodError): string[] {
return error.issues.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`);
}
function raceIdFromRequest(req: Request, res: Response): string | null {
const parsed = z.string().uuid().safeParse(req.params.id);
if (parsed.success) {
return parsed.data;
}
validationError(res, ["id: Must be a UUID"]);
return null;
}
function parseOptionalIntegerQuery(
value: unknown,
fieldName: string,
@@ -51,12 +136,77 @@ function parseOptionalIntegerQuery(
return { value: parsed };
}
function parseOptionalNumberQuery(
value: unknown,
fieldName: string,
min?: number,
max?: number,
): { value?: number; error?: string } {
if (value == null) {
return {};
}
if (typeof value !== "string" || value.trim() === "") {
return { error: `${fieldName} must be a number` };
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return { error: `${fieldName} must be a number` };
}
if (min != null && parsed < min) {
return { error: `${fieldName} must be between ${min} and ${max}` };
}
if (max != null && parsed > max) {
return { error: `${fieldName} must be between ${min} and ${max}` };
}
return { value: parsed };
}
function parseOptionalDateQuery(value: unknown, fieldName: string): { value?: string; error?: string } {
if (value == null) {
return {};
}
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return { error: `${fieldName} must be YYYY-MM-DD` };
}
const parsed = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
return { error: `${fieldName} must be a valid calendar date` };
}
return { value };
}
/* ─── GET /races ──────────────────────────────────────────── */
router.get("/races", async (req: Request, res: Response) => {
const yearResult = parseOptionalIntegerQuery(req.query.year, "year");
const monthResult = parseOptionalIntegerQuery(req.query.month, "month", 1, 12);
const details = [yearResult.error, monthResult.error].filter(Boolean) as string[];
const statusResult = req.query.status == null
? {}
: typeof req.query.status === "string" && ["planned", "registered", "completed"].includes(req.query.status)
? { value: req.query.status }
: { error: "status must be planned, registered or completed" };
const dateFromResult = parseOptionalDateQuery(req.query.dateFrom, "dateFrom");
const dateToResult = parseOptionalDateQuery(req.query.dateTo, "dateTo");
const distanceMinResult = parseOptionalNumberQuery(req.query.distanceMin, "distanceMin", 0.001, 999.999);
const distanceMaxResult = parseOptionalNumberQuery(req.query.distanceMax, "distanceMax", 0.001, 999.999);
const details = [
yearResult.error,
monthResult.error,
statusResult.error,
dateFromResult.error,
dateToResult.error,
distanceMinResult.error,
distanceMaxResult.error,
].filter(Boolean) as string[];
if (dateFromResult.value && dateToResult.value && dateFromResult.value > dateToResult.value) {
details.push("dateFrom must not be after dateTo");
}
if (distanceMinResult.value != null && distanceMaxResult.value != null && distanceMinResult.value > distanceMaxResult.value) {
details.push("distanceMin must not be greater than distanceMax");
}
if (details.length > 0) {
validationError(res, details);
@@ -68,6 +218,9 @@ router.get("/races", async (req: Request, res: Response) => {
const params: unknown[] = [];
let idx = 1;
conditions.push(`owner_user_id = $${idx++}`);
params.push(req.auth!.user.id);
if (yearResult.value != null) {
conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`);
params.push(yearResult.value);
@@ -76,6 +229,26 @@ router.get("/races", async (req: Request, res: Response) => {
conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`);
params.push(monthResult.value);
}
if (statusResult.value != null) {
conditions.push(`status = $${idx++}`);
params.push(statusResult.value);
}
if (dateFromResult.value != null) {
conditions.push(`race_date >= $${idx++}`);
params.push(dateFromResult.value);
}
if (dateToResult.value != null) {
conditions.push(`race_date <= $${idx++}`);
params.push(dateToResult.value);
}
if (distanceMinResult.value != null) {
conditions.push(`distance_km >= $${idx++}`);
params.push(distanceMinResult.value);
}
if (distanceMaxResult.value != null) {
conditions.push(`distance_km <= $${idx++}`);
params.push(distanceMaxResult.value);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`;
@@ -91,10 +264,14 @@ router.get("/races", async (req: Request, res: Response) => {
/* ─── GET /races/:id ──────────────────────────────────────── */
router.get("/races/:id", async (req: Request, res: Response) => {
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
try {
const { rows } = await pool.query<RaceRow>(
"SELECT * FROM races WHERE id = $1",
[req.params.id],
"SELECT * FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
@@ -110,17 +287,26 @@ router.get("/races/:id", async (req: Request, res: Response) => {
/* ─── POST /races ─────────────────────────────────────────── */
router.post("/races", async (req: Request, res: Response) => {
const body = req.body;
if (!body.id || !body.date || !body.title || body.distanceKm == null) {
validationError(res, ["Fields id, date, title, distanceKm are required"]);
const parsed = createRaceSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res, schemaDetails(parsed.error));
return;
}
const { columns, values } = bodyToColumns(body);
columns.unshift("id");
values.unshift(body.id);
const payload = { ...parsed.data };
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
const officialUrl = payload.officialUrl;
const hasOfficialUrl = typeof officialUrl === "string" && officialUrl.trim() !== "";
if (!hasManualCover && hasOfficialUrl) {
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
}
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
columns.unshift("owner_user_id");
values.unshift(req.auth!.user.id);
columns.push("cover_image_manual");
values.push(hasManualCover);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -131,7 +317,7 @@ router.post("/races", async (req: Request, res: Response) => {
if (err.code === "23505") {
res.status(409).json({
error: "conflict",
details: ["Race with this id already exists"],
details: ["Race with this slug already exists"],
});
return;
}
@@ -143,19 +329,52 @@ router.post("/races", async (req: Request, res: Response) => {
/* ─── PATCH /races/:id ────────────────────────────────────── */
router.patch("/races/:id", async (req: Request, res: Response) => {
const { columns, values } = bodyToColumns(req.body);
if (columns.length === 0) {
validationError(res, ["No updatable fields provided"]);
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
const parsed = updateRaceSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res, schemaDetails(parsed.error));
return;
}
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(req.params.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`;
try {
const currentResult = await pool.query<Pick<RaceRow, "official_url" | "cover_image_url" | "cover_image_manual">>(
"SELECT official_url, cover_image_url, cover_image_manual FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (currentResult.rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
return;
}
const payload = { ...parsed.data };
const officialUrlChanged = Object.prototype.hasOwnProperty.call(payload, "officialUrl");
const current = currentResult.rows[0]!;
const hasCoverField = Object.prototype.hasOwnProperty.call(payload, "coverImageUrl");
const sameAsExistingCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl === current.cover_image_url;
const manualCoverChanged = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "" && !sameAsExistingCover;
let coverImageManual = current.cover_image_manual;
if (hasCoverField && !sameAsExistingCover) {
coverImageManual = manualCoverChanged;
}
const officialUrl = payload.officialUrl === undefined ? current.official_url : payload.officialUrl;
if (officialUrlChanged && !current.cover_image_manual && !manualCoverChanged && typeof officialUrl === "string" && officialUrl.trim() !== "") {
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
coverImageManual = false;
}
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
if (hasCoverField || officialUrlChanged) {
columns.push("cover_image_manual");
values.push(coverImageManual);
}
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(id);
values.push(req.auth!.user.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
const { rows } = await pool.query<RaceRow>(sql, values);
if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
@@ -171,10 +390,14 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
/* ─── DELETE /races/:id ───────────────────────────────────── */
router.delete("/races/:id", async (req: Request, res: Response) => {
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
try {
const { rowCount } = await pool.query(
"DELETE FROM races WHERE id = $1",
[req.params.id],
"DELETE FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });

View File

@@ -0,0 +1,63 @@
import { Request, Response, Router } from "express";
import { z } from "zod";
import { pool } from "../db";
const router: Router = Router();
interface PublicRaceRow {
race_date: string | Date;
title: string;
distance_km: string;
status: "planned" | "registered" | "completed" | null;
cover_image_url: string | null;
finish_time: string | null;
finish_place: string | null;
}
function raceDate(value: string | Date): string {
return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10);
}
router.get("/users/:username/races", async (req: Request, res: Response) => {
const parsed = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).safeParse(req.params.username);
if (!parsed.success) {
res.status(400).json({ error: "validation_error", details: ["username: Invalid username"] });
return;
}
try {
const profile = await pool.query(
`SELECT id, is_future_races_public, is_completed_races_public
FROM users
WHERE LOWER(profile_username) = $1
AND (is_future_races_public = TRUE OR is_completed_races_public = TRUE)`,
[parsed.data],
);
if (profile.rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Profile not found"] });
return;
}
const { rows } = await pool.query<PublicRaceRow>(
`SELECT race_date, title, distance_km, status, cover_image_url, finish_time, finish_place
FROM races
WHERE owner_user_id = $1
AND ((status = 'completed' AND $2) OR (status IS DISTINCT FROM 'completed' AND $3))
ORDER BY race_date ASC`,
[profile.rows[0].id, profile.rows[0].is_completed_races_public, profile.rows[0].is_future_races_public],
);
res.json(rows.map((row) => ({
date: raceDate(row.race_date),
title: row.title,
distanceKm: Number(row.distance_km),
status: row.status,
coverImageUrl: row.cover_image_url,
finishTime: row.status === "completed" ? row.finish_time : null,
finishPlace: row.status === "completed" ? row.finish_place : null,
})));
} catch (error) {
console.error("[GET /users/:username/races]", error);
res.status(503).json({ error: "database_unavailable" });
}
});
export default router;

63
backend/src/security.ts Normal file
View File

@@ -0,0 +1,63 @@
import crypto from "crypto";
import argon2 from "argon2";
export const PASSWORD_MIN_LENGTH = 15;
export const PASSWORD_MAX_LENGTH = 256;
export const ARGON2_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 65_536,
timeCost: 3,
parallelism: 1,
hashLength: 32,
};
const DUMMY_PASSWORD_HASH =
"$argon2id$v=19$m=65536,t=3,p=1$Jkdr1qT0c9cPK5v8m0tEMQ$SLnLmyorTDzBK74I1lrEF92E7S0c6DAm8iMG0YOyAIo";
export function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
export function isValidPassword(value: string): boolean {
return value.length >= PASSWORD_MIN_LENGTH && value.length <= PASSWORD_MAX_LENGTH;
}
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(hash: string | null, password: string): Promise<boolean> {
const candidate = hash ?? DUMMY_PASSWORD_HASH;
try {
const ok = await argon2.verify(candidate, password);
return hash ? ok : false;
} catch {
return false;
}
}
export function randomToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString("base64url");
}
export function sha256Hex(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
export function timingSafeEqualHex(left: string, right: string): boolean {
const a = Buffer.from(left, "hex");
const b = Buffer.from(right, "hex");
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
export function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
export function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}

View File

@@ -0,0 +1,9 @@
import { sha256Hex } from "./security";
export function anonymizeEmail(email: string): string {
return sha256Hex(email).slice(0, 16);
}
export function securityLog(event: string, details: Record<string, unknown> = {}): void {
console.info("[security]", event, details);
}

View File

@@ -24,6 +24,31 @@ function makeId(date: string, title: string): string {
return `${date}-${slugify(title)}`;
}
async function resolveSeedOwnerUserId(client: { query: typeof pool.query }): Promise<string | null> {
const explicit = process.env.SEED_OWNER_USER_ID?.trim();
if (explicit) {
return explicit;
}
const email = process.env.SEED_OWNER_EMAIL?.trim().toLowerCase();
if (email) {
const { rows } = await client.query<{ id: string }>(
"SELECT id FROM users WHERE LOWER(BTRIM(email)) = $1",
[email],
);
if (rows[0]) {
return rows[0].id;
}
throw new Error("SEED_OWNER_EMAIL does not match an existing user");
}
const { rows } = await client.query<{ count: string }>("SELECT COUNT(*)::text AS count FROM users");
const usersCount = Number(rows[0]?.count ?? "0");
if (usersCount > 0) {
throw new Error("Refusing to seed without SEED_OWNER_USER_ID or SEED_OWNER_EMAIL after users exist");
}
return null;
}
const CSV_NAME = "races_2026_calendar.csv";
function resolveCsvPath(): string | null {
@@ -56,23 +81,28 @@ async function seed() {
const client = await pool.connect();
try {
const ownerUserId = await resolveSeedOwnerUserId(client);
for (const row of records) {
const id = makeId(row.date, row.event);
const slug = makeId(row.date, row.event);
const distanceKm = parseFloat(row.distance_km);
await client.query(
`INSERT INTO races (id, race_date, title, distance_km, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
race_date = EXCLUDED.race_date,
title = EXCLUDED.title,
distance_km = EXCLUDED.distance_km,
status = EXCLUDED.status,
updated_at = NOW()`,
[id, row.date, row.event, distanceKm, "planned"],
);
if (ownerUserId) {
await client.query(
`INSERT INTO races (slug, owner_user_id, race_date, title, distance_km, status, source)
VALUES ($1, $2, $3, $4, $5, $6, 'seed')
ON CONFLICT DO NOTHING`,
[slug, ownerUserId, row.date, row.event, distanceKm, "planned"],
);
} else {
await client.query(
`INSERT INTO races (slug, race_date, title, distance_km, status, source)
VALUES ($1, $2, $3, $4, $5, 'seed')
ON CONFLICT DO NOTHING`,
[slug, row.date, row.event, distanceKm, "planned"],
);
}
console.log(`[seed] Upserted: ${id}`);
console.log(`[seed] Inserted if missing: ${slug}`);
}
console.log("[seed] Done.");

33
backend/src/turnstile.ts Normal file
View File

@@ -0,0 +1,33 @@
import { config } from "./config";
export async function verifyTurnstileToken(token: string, remoteIp?: string): Promise<boolean> {
if (config.turnstile.bypassToken && token === config.turnstile.bypassToken) {
return true;
}
if (!config.turnstile.secretKey) {
return false;
}
const body = new URLSearchParams();
body.set("secret", config.turnstile.secretKey);
body.set("response", token);
if (remoteIp) {
body.set("remoteip", remoteIp);
}
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return false;
}
const payload = (await response.json()) as { success?: boolean };
return payload.success === true;
} catch {
return false;
}
}

View File

@@ -1,9 +1,83 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs";
import path from "node:path";
import { after, test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app";
import { buildHelmetOptions, createApp } from "../src/app";
import { backfillRaceCovers } from "../src/backfillRaceCovers";
import {
cleanupExpiredAuthRows,
createResetToken,
createVerificationToken,
resetPassword,
verifyEmailToken,
} from "../src/authService";
import { resolveTurnstileBypassToken } from "../src/config";
import { pool } from "../src/db";
import { extractRaceCoverImage, extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
import { hashPassword, normalizeEmail } from "../src/security";
const app = createApp();
let userCounter = 0;
after(() => pool.end());
async function authAgent() {
userCounter += 1;
const email = normalizeEmail(`runner${userCounter}@example.com`);
const password = "correct horse battery staple";
const passwordHash = await hashPassword(password);
const inserted = await pool.query<{ id: string }>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id`,
[email, passwordHash],
);
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
inserted.rows[0].id,
]);
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function createVerifiedUser(email: string, password: string) {
return createUser(email, password, true);
}
async function createUnverifiedUser(email: string, password: string) {
return createUser(email, password, false);
}
async function createUser(email: string, password: string, verified: boolean) {
const passwordHash = await hashPassword(password);
const inserted = await pool.query<{ id: string }>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id`,
[normalizeEmail(email), passwordHash],
);
if (verified) {
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
inserted.rows[0].id,
]);
}
return inserted.rows[0].id;
}
async function loginAgent(email: string, password: string) {
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
[tokenHash],
);
return Number(rows[0]?.count ?? "0");
}
test("GET /api/health returns ok", async () => {
const res = await request(app).get("/api/health").expect(200);
@@ -18,30 +92,672 @@ test("GET /api/meta returns version for UI footer", async () => {
assert.ok(res.body.version.length > 0);
});
test("GET /api/ready succeeds with mock database", async () => {
test("GET /api/ready succeeds with PostgreSQL", async () => {
const res = await request(app).get("/api/ready").expect(200);
assert.equal(res.body.status, "ready");
assert.equal(res.body.db, "connected");
});
test("production config rejects Turnstile bypass token", () => {
assert.throws(
() =>
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
}),
/TURNSTILE_BYPASS_TOKEN/,
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "dev-bypass",
securityProfile: "development",
}),
"dev-bypass",
);
});
test("production CSP allows Turnstile without unsafe script directives", () => {
const options = buildHelmetOptions("production") as any;
const directives = options.contentSecurityPolicy.directives;
assert.ok(directives.scriptSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.frameSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.connectSrc.includes("https://challenges.cloudflare.com"));
assert.ok(!directives.scriptSrc.includes("'unsafe-inline'"));
assert.ok(!directives.scriptSrc.includes("'unsafe-eval'"));
assert.deepEqual(directives.frameAncestors, ["'none'"]);
assert.deepEqual(directives.objectSrc, ["'none'"]);
});
test("duplicate registration keeps generic accepted response", async () => {
const payload = {
email: "duplicate-register@example.com",
password: "correct horse battery staple",
turnstileToken: "mock-turnstile-token",
};
const first = await request(app).post("/api/auth/register").send(payload).expect(202);
const second = await request(app).post("/api/auth/register").send(payload).expect(202);
assert.deepEqual(first.body, { ok: true });
assert.deepEqual(second.body, { ok: true });
});
test("GET /api/races rejects invalid year", async () => {
const res = await request(app).get("/api/races?year=bad").expect(400);
const { agent } = await authAgent();
const res = await agent.get("/api/races?year=bad").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details));
});
test("GET /api/races rejects month out of range", async () => {
const res = await request(app).get("/api/races?month=13").expect(400);
const { agent } = await authAgent();
const res = await agent.get("/api/races?month=13").expect(400);
assert.equal(res.body.error, "validation_error");
});
test("GET /api/races accepts year and month", async () => {
const res = await request(app).get("/api/races?year=2026&month=5").expect(200);
const { agent } = await authAgent();
const res = await agent.get("/api/races?year=2026&month=5").expect(200);
assert.ok(Array.isArray(res.body));
});
test("GET /api/races/:id returns not_found", async () => {
const res = await request(app).get("/api/races/does-not-exist").expect(404);
assert.equal(res.body.error, "not_found");
test("GET /api/races filters by status, date and distance", async () => {
const { agent, csrfToken } = await authAgent();
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-05-01-filter-planned", date: "2026-05-01", title: "Planned 5", distanceKm: 5, status: "planned",
}).expect(201);
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-01-filter-completed", date: "2026-06-01", title: "Completed 10", distanceKm: 10, status: "completed",
}).expect(201);
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-07-01-filter-completed", date: "2026-07-01", title: "Completed 21", distanceKm: 21.1, status: "completed",
}).expect(201);
const res = await agent.get("/api/races?status=completed&dateFrom=2026-06-01&dateTo=2026-06-30&distanceMax=10").expect(200);
assert.deepEqual(res.body.map((race: { title: string }) => race.title), ["Completed 10"]);
});
test("GET /api/races rejects inverted filter ranges", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races?dateFrom=2026-07-01&dateTo=2026-06-01").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(res.body.details.some((detail: string) => detail.includes("dateFrom")));
});
test("GET /api/races/:id rejects a non-UUID id", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races/does-not-exist").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details));
});
test("GET /api/races requires authentication", async () => {
const res = await request(app).get("/api/races").expect(401);
assert.equal(res.body.error, "unauthorized");
});
test("public profile uses its username and separate visibility settings", async () => {
const { agent } = await authAgent();
const user = await agent.get("/api/auth/me").expect(200);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-08-01-planned-race",
date: "2026-08-01",
title: "Planned Race",
distanceKm: 10,
})
.expect(201);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-07-01-completed-race",
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
finishTime: "00:40:00",
finishPlace: "12",
notes: "Private note",
})
.expect(201);
await request(app).get("/api/users/public-runner/races").expect(404);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ profileUsername: "public-runner", isFutureRacesPublic: true, isCompletedRacesPublic: false })
.expect(200);
const futureRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(futureRaces.body, [{
date: "2026-08-01",
title: "Planned Race",
distanceKm: 10,
status: null,
coverImageUrl: null,
finishTime: null,
finishPlace: null,
}]);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isFutureRacesPublic: false, isCompletedRacesPublic: true })
.expect(200);
const completedRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(completedRaces.body, [{
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
coverImageUrl: null,
finishTime: "00:40:00",
finishPlace: "12",
}]);
});
test("login uses generic response for missing user and wrong password", async () => {
const password = "correct horse battery staple";
await createVerifiedUser("generic@example.com", password);
const wrongPassword = await request(app)
.post("/api/auth/login")
.send({ email: "generic@example.com", password: "wrong password" })
.expect(401);
const missingUser = await request(app)
.post("/api/auth/login")
.send({ email: "missing@example.com", password })
.expect(401);
assert.deepEqual(missingUser.body, wrongPassword.body);
});
test("account session endpoints list and revoke only the current user's sessions", async () => {
userCounter += 1;
const email = `sessions${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
const listed = await first.agent.get("/api/auth/sessions").expect(200);
assert.equal(listed.body.sessions.length, 2);
assert.ok(listed.body.sessions.every((session: { tokenHash?: unknown }) => session.tokenHash === undefined));
const other = listed.body.sessions.find((session: { current: boolean }) => !session.current);
assert.ok(other);
const outsider = await authAgent();
const foreign = (await outsider.agent.get("/api/auth/sessions").expect(200)).body.sessions[0];
await first.agent
.delete(`/api/auth/sessions/${foreign.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(404);
await first.agent
.delete(`/api/auth/sessions/${other.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(204);
await second.agent.get("/api/auth/me").expect(401);
await first.agent.get("/api/auth/sessions").expect(200).then((res) => assert.equal(res.body.sessions.length, 1));
});
test("password change checks the current password and revokes other sessions", async () => {
userCounter += 1;
const email = `password${userCounter}@example.com`;
const password = "correct horse battery staple";
const nextPassword = "another correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: "wrong password", newPassword: nextPassword })
.expect(400);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: password, newPassword: nextPassword })
.expect(200);
await second.agent.get("/api/auth/me").expect(401);
await request(app).post("/api/auth/login").send({ email, password }).expect(401);
await request(app).post("/api/auth/login").send({ email, password: nextPassword }).expect(200);
});
test("revoke other sessions retains the current session", async () => {
userCounter += 1;
const email = `revoke-others${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/sessions/revoke-others")
.set("X-CSRF-Token", first.csrfToken)
.expect(200);
await first.agent.get("/api/auth/me").expect(200);
await second.agent.get("/api/auth/me").expect(401);
});
test("GET /api/races/:id returns not_found for another user's race", async () => {
const first = await authAgent();
const created = await first.agent
.post("/api/races")
.set("X-CSRF-Token", first.csrfToken)
.send({
slug: "2026-07-01-private-race",
date: "2026-07-01",
title: "Private Race",
distanceKm: 10,
})
.expect(201);
const second = await authAgent();
const res = await second.agent.get(`/api/races/${created.body.id}`).expect(404);
assert.equal(res.body.error, "not_found");
});
test("new password reset token invalidates previous token", async () => {
const userId = await createVerifiedUser("reset@example.com", "correct horse battery staple");
const client = await pool.connect();
const first = await createResetToken(client, userId);
const second = await createResetToken(client, userId);
client.release();
assert.equal(await resetPassword(first, "new correct horse battery staple"), false);
assert.equal(await resetPassword(second, "new correct horse battery staple"), true);
assert.equal(await resetPassword(second, "new correct horse battery staple"), false);
});
test("email verification token is single use", async () => {
const userId = await createUnverifiedUser("verify-once@example.com", "correct horse battery staple");
const client = await pool.connect();
const token = await createVerificationToken(client, userId);
client.release();
assert.equal(await verifyEmailToken(token), true);
assert.equal(await verifyEmailToken(token), false);
});
test("auth cleanup removes expired rows and keeps active rows", async () => {
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
const past = new Date(Date.now() - 60_000);
const future = new Date(Date.now() + 60_000);
const expiredSession = "expired-session-token-hash";
const activeSession = "active-session-token-hash";
const expiredVerify = "expired-verify-token-hash";
const activeVerify = "active-verify-token-hash";
const expiredReset = "expired-reset-token-hash";
const activeReset = "active-reset-token-hash";
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, expiredSession, "expired-csrf", past],
);
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, activeSession, "active-csrf", future],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredVerify, past],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeVerify, future],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredReset, past],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeReset, future],
);
await cleanupExpiredAuthRows();
assert.equal(await countByTokenHash("sessions", expiredSession), 0);
assert.equal(await countByTokenHash("sessions", activeSession), 1);
assert.equal(await countByTokenHash("email_verification_tokens", expiredVerify), 0);
assert.equal(await countByTokenHash("email_verification_tokens", activeVerify), 1);
assert.equal(await countByTokenHash("password_reset_tokens", expiredReset), 0);
assert.equal(await countByTokenHash("password_reset_tokens", activeReset), 1);
});
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
const html = `
<meta property="og:image" content="https://example.com/og.jpg">
<div class="run-intro__image">
<div class="run-intro__image-left-shadow"></div>
<img src="/uploads/race_landing_header_backgrounds/header.jpg" alt="">
<div class="run-intro__image-right-shadow"></div>
</div>
`;
assert.equal(
extractRaceCoverImageFromHtml(html, "https://aprilrun5km.runc.run/"),
"https://aprilrun5km.runc.run/uploads/race_landing_header_backgrounds/header.jpg",
);
});
test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () => {
assert.equal(
extractRaceCoverImageFromHtml(
'<meta property="og:image" content="/cover.png">',
"https://example.com/race",
),
"https://example.com/cover.png",
);
assert.equal(
extractRaceCoverImageFromHtml(
'<meta name="twitter:image" content="https://cdn.example.com/twitter.jpg">',
"https://example.com/race",
),
"https://cdn.example.com/twitter.jpg",
);
});
test("POST /api/races stores manual coverImageUrl", async () => {
const { agent, csrfToken } = await authAgent();
const coverImageUrl = "https://example.com/manual.jpg";
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-01-manual-cover",
date: "2026-06-01",
title: "Manual Cover",
distanceKm: 10,
officialUrl: "https://example.com/race",
coverImageUrl,
})
.expect(201);
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("PATCH /api/races refreshes an automatic cover but preserves a manual cover", async () => {
const originalFetch = globalThis.fetch;
let imageName = "first.jpg";
globalThis.fetch = async () => new Response(`<meta property="og:image" content="/${imageName}">`, {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
const created = await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-06-auto-refresh", date: "2026-06-06", title: "Auto Refresh", distanceKm: 10, officialUrl: "https://example.com/first",
}).expect(201);
imageName = "second.jpg";
const refreshed = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/second",
}).expect(200);
assert.equal(refreshed.body.coverImageUrl, "https://example.com/second.jpg");
const manual = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/third", coverImageUrl: "https://example.com/manual.jpg",
}).expect(200);
imageName = "fourth.jpg";
const preserved = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/fourth",
}).expect(200);
assert.equal(manual.body.coverImageUrl, "https://example.com/manual.jpg");
assert.equal(preserved.body.coverImageUrl, "https://example.com/manual.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races rejects unknown and malformed fields", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "invalid-race",
date: "2026-02-30",
title: "Invalid Race",
distanceKm: 10,
startTime: "24:00",
unexpected: true,
})
.expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(res.body.details.some((detail: string) => detail.includes("date")));
assert.ok(res.body.details.some((detail: string) => detail.includes("startTime")));
assert.ok(res.body.details.some((detail: string) => detail.includes("Unrecognized")));
});
test("POST /api/races accepts a seconds-only finish time", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-01-seconds-only",
date: "2026-06-01",
title: "Seconds Only",
distanceKm: 1,
finishTime: "05",
})
.expect(201);
assert.equal(res.body.finishTime, "05");
});
test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response('<meta property="og:image" content="/auto.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-02-auto-cover",
date: "2026-06-02",
title: "Auto Cover",
distanceKm: 21.1,
officialUrl: "https://example.com/race",
})
.expect(201);
assert.equal(res.body.coverImageUrl, "https://example.com/auto.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("PATCH /api/races auto extracts a missing cover when officialUrl changes", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("", { status: 404 });
try {
const { agent, csrfToken } = await authAgent();
const created = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({ slug: "2026-06-04-patch-cover", date: "2026-06-04", title: "Patch Cover", distanceKm: 10 })
.expect(201);
globalThis.fetch = async () => new Response('<meta property="og:image" content="/patched.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
const updated = await agent
.patch(`/api/races/${created.body.id}`)
.set("X-CSRF-Token", csrfToken)
.send({ officialUrl: "https://example.com/patched" })
.expect(200);
assert.equal(updated.body.coverImageUrl, "https://example.com/patched.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover backfill restores only races without a cover", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response('<meta property="og:image" content="/backfill.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-05-backfill", date: "2026-06-05", title: "Backfill", distanceKm: 10,
}).expect(201);
await pool.query("UPDATE races SET official_url = $1 WHERE slug = $2", ["https://example.com/backfill", "2026-06-05-backfill"]);
const stats = await backfillRaceCovers();
assert.ok(stats.candidates >= 1);
assert.ok(stats.restored >= 1);
const result = await pool.query<{ cover_image_url: string }>("SELECT cover_image_url FROM races WHERE slug = $1", ["2026-06-05-backfill"]);
assert.equal(result.rows[0]?.cover_image_url, "https://example.com/backfill.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races succeeds when cover extraction fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("network down");
};
try {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-03-cover-fail",
date: "2026-06-03",
title: "Cover Fail",
distanceKm: 5,
officialUrl: "https://example.com/race",
})
.expect(201);
assert.equal(res.body.coverImageUrl, null);
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover extraction never fetches loopback URLs", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response("");
};
try {
assert.equal(await extractRaceCoverImage("http://127.0.0.1/private"), null);
assert.equal(calls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover extraction rejects a redirect to a private URL", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } });
};
try {
assert.equal(await extractRaceCoverImage("https://8.8.8.8/race"), null);
assert.equal(calls, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
const { agent, csrfToken } = await authAgent();
const created = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-04-patch-cover",
date: "2026-06-04",
title: "Patch Cover",
distanceKm: 10,
})
.expect(201);
const coverImageUrl = "https://example.com/patched.jpg";
const res = await agent
.patch(`/api/races/${created.body.id}`)
.set("X-CSRF-Token", csrfToken)
.send({ coverImageUrl })
.expect(200);
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("004 migrates legacy text race ids to UUIDs", async () => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("DROP TABLE races");
await client.query(`
CREATE TABLE races (
id TEXT PRIMARY KEY,
race_date DATE NOT NULL,
title TEXT NOT NULL,
distance_km NUMERIC(6,3) NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('planned', 'registered', 'completed')),
official_url TEXT,
start_time TEXT,
cluster_schedule TEXT,
bib_pickup TEXT,
bib_number TEXT,
finish_time TEXT,
notes TEXT,
finish_place TEXT,
cover_image_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
`);
await client.query(
"INSERT INTO races (id, race_date, title, distance_km) VALUES ($1, $2, $3, $4)",
["legacy-race", "2026-01-01", "Legacy Race", 10],
);
const migration = fs.readFileSync(path.resolve(__dirname, "../migrations/004_auth_and_race_ownership.sql"), "utf8");
await client.query(migration);
const { rows } = await client.query<{ id: string; slug: string; source: string }>(
"SELECT id, slug, source FROM races WHERE slug = $1",
["legacy-race"],
);
assert.equal(rows[0]?.slug, "legacy-race");
assert.equal(rows[0]?.source, "user");
assert.match(rows[0]?.id ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
} finally {
await client.query("ROLLBACK");
client.release();
}
});

21
backend/test/setup.ts Normal file
View File

@@ -0,0 +1,21 @@
process.env.NODE_ENV ??= "test";
process.env.SECURITY_PROFILE ??= "test";
process.env.SESSION_SECRET ??= "test-session-secret";
process.env.TURNSTILE_BYPASS_TOKEN ??= "mock-turnstile-token";
process.env.DB_HOST ??= "127.0.0.1";
process.env.DB_PORT ??= "5432";
process.env.DB_NAME ??= "calendar_run_test";
process.env.DB_USER ??= "postgres";
process.env.DB_PASSWORD ??= "postgres";
if (process.env.DB_NAME !== "calendar_run_test") {
throw new Error("Tests require DB_NAME=calendar_run_test");
}
const [{ migrate }, { pool }] = await Promise.all([
import("../src/migrate"),
import("../src/db"),
]);
await migrate();
await pool.query("TRUNCATE app_settings, races, users RESTART IDENTITY CASCADE");

13
docs/auth-rollout.md Normal file
View File

@@ -0,0 +1,13 @@
# Auth rollout
Before applying auth and ownership migrations in production:
1. Run `pg_dump` for the target database and keep the dump until verification is complete.
2. Record the rollback command for restoring that dump if migration or legacy race claiming fails.
3. Record the current race count with `SELECT count(*) FROM races;`.
4. Run migrations.
5. After the first email-verified user claims legacy races, verify:
- `SELECT count(*) FROM races;` matches the pre-migration count.
- `SELECT count(*) FROM races WHERE owner_user_id IS NULL;` is `0` before planning the follow-up `NOT NULL` migration.
Seed/import after auth requires `SEED_OWNER_USER_ID` or `SEED_OWNER_EMAIL` once users exist. It inserts missing seed races for that owner and does not overwrite user-edited races.

View File

@@ -1,237 +1,176 @@
# Backend API — шпаргалка для фронтенда
# Backend API — контракт для frontend
## 1. Base URL
## Base URL и авторизация
SPA всегда отправляет запросы на относительный префикс `/api` текущего origin.
SPA обращается к относительному префиксу `/api`. В dev Vite проксирует его на
`http://localhost:3001/api`; в Docker nginx проксирует на
`runners-calendar-backend:3000`.
- В dev (`npm run dev`): Vite proxy отправляет `/api/*` на `http://localhost:3001/api/*`.
- В Docker/проде: nginx фронта проксирует `/api/*` на хост `runners-calendar-backend:3000` в той же сети (уникальное имя сервиса Compose, без коллизий с чужими стеками).
Все маршруты `/api/races` требуют сессионную cookie авторизованного пользователя
с подтверждённым email. Записи принадлежат текущему пользователю: чужая запись
не видна и отвечает `404`.
## 2. CORS
После `POST /api/auth/login` или `GET /api/auth/me` клиент получает
`csrfToken`. Для каждого `POST`, `PATCH` и `DELETE` с активной сессией его нужно
передавать в заголовке `X-CSRF-Token`. Cookie передаётся браузером автоматически;
при отдельном origin запрос должен использовать credentials.
В dev-режиме бэкенд ожидает переменную:
## Аккаунт и сессии
```
CORS_ORIGIN=http://localhost:5173
```
Все маршруты этого раздела требуют авторизации и подтверждённого email. Для
изменяющих состояние запросов обязателен `X-CSRF-Token`.
Разрешены методы `GET`, `POST`, `PATCH`, `DELETE` и заголовок `Content-Type: application/json`.
### `GET /api/auth/sessions`
## 3. Эндпоинты
### `GET /api/health`
Liveness-проверка (без обращения к БД).
**Ответ 200:**
```json
{ "status": "ok" }
```
---
### `GET /api/ready`
Readiness-проверка (проверяет подключение к БД).
**Ответ 200:**
```json
{ "status": "ready", "db": "connected" }
```
**Ответ 503:**
```json
{ "error": "database_unavailable", "db": "disconnected" }
```
---
### `GET /api/races`
Список забегов, отсортированных по дате.
**Query-параметры (опциональные):**
| Параметр | Тип | Описание |
|---|---|---|
| `year` | number | Фильтр по году (напр. `2026`) |
| `month` | number | Фильтр по месяцу (112) |
- Без параметров — возвращает все забеги.
- Можно указать только `year`, только `month` или оба.
- `month` без `year` фильтрует по месяцу **всех** лет.
**Пример запроса:**
```
GET /api/races?year=2026&month=5
```
**Ответ 200:**
```json
[
{
"id": "2026-05-03-kazanskii-marafon",
"date": "2026-05-03",
"title": "Казанский марафон",
"distanceKm": 42.195,
"status": "planned",
"officialUrl": null,
"startTime": null,
"clusterSchedule": null,
"bibPickup": null,
"bibNumber": null,
"finishTime": null,
"finishPlace": null,
"notes": null,
"createdAt": "2026-03-31T12:00:00.000Z",
"updatedAt": null
}
]
```
---
### `GET /api/races/:id`
Одна запись по `id`.
**Ответ 200:** объект `Race` (см. модель ниже).
**Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать).
```json
{ "error": "not_found", "details": ["Race not found"] }
```
---
### `POST /api/races`
Создание забега.
**Тело запроса (JSON):**
Возвращает активные сессии текущего пользователя. Хеши и сами токены не
возвращаются.
```json
{
"id": "2026-06-01-my-race",
"sessions": [{
"id": "UUID",
"createdAt": "ISO datetime",
"lastSeenAt": "ISO datetime",
"expiresAt": "ISO datetime",
"current": true
}]
}
```
### `POST /api/auth/password`
```json
{ "currentPassword": "…", "newPassword": "не менее 15 символов" }
```
Проверяет текущий пароль, сохраняет новый и отзывает все остальные активные
сессии. Успех: `200 { "ok": true }`; неверный текущий пароль: `400`
`invalid_current_password`.
### `DELETE /api/auth/sessions/:id`
Отзывает указанную активную сессию текущего пользователя. Успех: `204`. Если
отозвана текущая сессия, cookie очищается. Чужая, неактивная или отсутствующая
сессия отвечает `404`.
### `POST /api/auth/sessions/revoke-others`
Отзывает все активные сессии, кроме текущей. Успех: `200 { "ok": true }`.
## Служебные маршруты
### `GET /api/health`
Liveness без проверки БД:
```json
{ "status": "ok", "version": "<backend version>" }
```
### `GET /api/meta`
Версия для footer:
```json
{ "version": "<backend version>" }
```
### `GET /api/ready`
Проверяет БД. Успех: `{ "status": "ready", "db": "connected" }`.
Недоступная БД: `503 { "error": "database_unavailable", "db": "disconnected" }`.
## Забеги
### `GET /api/races`
Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные
query-параметры: `year` (целое число), `month` (112), `status` (`planned`,
`registered` или `completed`), `dateFrom`/`dateTo` (`YYYY-MM-DD`) и
`distanceMin`/`distanceMax` (километры, от `0.001` до `999.999`). Диапазоны не
могут быть перевёрнуты; без результатов возвращается `[]`.
### `GET /api/races/:id`
Возвращает один забег. `id` — UUID, который создаёт сервер. Не путайте его с `slug`:
`slug` создаёт клиент как читаемый ключ и он уникален в рамках одного пользователя.
### `POST /api/races`
Создаёт забег. Обязательные поля: `slug`, `date`, `title`, `distanceKm`.
Неизвестные поля отклоняются.
```json
{
"slug": "2026-06-01-my-race",
"date": "2026-06-01",
"title": "Мой забег",
"distanceKm": 10,
"status": "planned",
"officialUrl": "https://example.com",
"startTime": "09:30",
"clusterSchedule": null,
"bibPickup": null,
"bibNumber": null,
"finishTime": null,
"finishPlace": null,
"notes": null
"startTime": "09:30"
}
```
**Обязательные поля:** `id`, `date`, `title`, `distanceKm`.
**Ответ 201:** созданный объект `Race`.
**Ответ 400:**
```json
{ "error": "validation_error", "details": ["Fields id, date, title, distanceKm are required"] }
```
**Ответ 409:**
```json
{ "error": "conflict", "details": ["Race with this id already exists"] }
```
---
Успех: `201` и объект `Race`. Одинаковый `slug` у того же пользователя: `409`
`{ "error": "conflict", "details": ["Race with this slug already exists"] }`.
Если `coverImageUrl` не передан, backend может найти Open Graph-обложку по
`officialUrl`; это best-effort и не мешает созданию при ошибке внешнего сайта.
### `PATCH /api/races/:id`
Частичное обновление — передавать **только** изменяемые поля.
**Тело запроса (JSON):**
```json
{
"finishTime": "1:45:30",
"finishPlace": "12/340",
"bibNumber": "1234",
"status": "completed"
}
```
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
**Ответ 200:** обновлённый объект `Race`.
**Ответ 400:**
```json
{ "error": "validation_error", "details": ["No updatable fields provided"] }
```
**Ответ 404:**
```json
{ "error": "not_found", "details": ["Race not found"] }
```
---
Частично обновляет забег. Передайте хотя бы одно поле из модели ниже; неизвестные
поля и пустой объект отклоняются. Успех: `200` и обновлённый `Race`.
### `DELETE /api/races/:id`
Удаление забега.
Удаляет забег. Успех: `204` без тела.
**Ответ 204:** пустое тело.
При изменении `officialUrl` backend best-effort получает обложку, если у старта
ещё нет обложки. Ручная `coverImageUrl` не перезаписывается.
**Ответ 404:**
Одноразовое восстановление обложек существующих стартов запускается командой
`npm run backfill:covers`. Команда идемпотентна и печатает только агрегированную
статистику.
```json
{ "error": "not_found", "details": ["Race not found"] }
```
## Модель `Race`
## 4. Модель `Race` (camelCase)
| Поле | Тип | POST | PATCH | Ограничение |
|---|---|---:|---:|---|
| `id` | UUID | сервер | — | Read-only UUID сервера |
| `slug` | string | да | да | 1120 символов: буквы/цифры, разделённые `-` |
| `date` | string | да | да | Реальная дата `YYYY-MM-DD` |
| `title` | string | да | да | 1200 символов |
| `distanceKm` | number | да | да | Больше 0, не более 999.999 |
| `status` | string/null | нет | да | `planned`, `registered` или `completed` |
| `officialUrl` | HTTP(S) URL/null | нет | да | До 2048 символов |
| `coverImageUrl` | HTTP(S) URL/null | нет | да | До 2048 символов |
| `startTime` | string/null | нет | да | `HH:MM` или `HH:MM:SS` |
| `clusterSchedule` | string/null | нет | да | До 1000 символов |
| `bibPickup` | string/null | нет | да | До 500 символов |
| `bibNumber` | string/null | нет | да | До 100 символов |
| `finishTime` | string/null | нет | да | `SS`, `MM:SS` или `H:MM:SS` |
| `finishPlace` | string/null | нет | да | До 100 символов |
| `notes` | string/null | нет | да | До 5000 символов |
| `createdAt` | ISO datetime | сервер | — | Read-only |
| `updatedAt` | ISO datetime/null | сервер | — | Read-only |
| Поле | Тип | POST обяз. | PATCH | Описание |
|---|---|---|---|---|
| `id` | string | да | — | Стабильный ключ, напр. `2026-05-03-kazan-marathon` |
| `date` | string | да | да | `YYYY-MM-DD` |
| `title` | string | да | да | Название забега |
| `distanceKm` | number | да | да | Дистанция в км |
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
| `officialUrl` | string \| null | нет | да | URL организатора |
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) |
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
| `bibPickup` | string \| null | нет | да | Выдача номеров |
| `bibNumber` | string \| null | нет | да | Стартовый номер |
| `finishTime` | string \| null | нет | да | Финишное время `H:MM:SS` или `MM:SS` |
| `finishPlace` | string \| null | нет | да | Место на финише (произвольная строка) |
| `notes` | string \| null | нет | да | Заметки |
| `createdAt` | string | — | — | ISO timestamp (read-only) |
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
## Ошибки
## 5. Фильтрация списка (`GET /api/races`)
| Статус | Тело | Когда |
|---:|---|---|
| 400 | `{ "error": "validation_error", "details": ["…"] }` | Неверный UUID, query или тело запроса |
| 401 | `{ "error": "unauthorized", "details": ["Authentication required"] }` | Нет сессии |
| 403 | `csrf_error` / `email_not_verified` | Неверный CSRF-токен или email не подтверждён |
| 404 | `{ "error": "not_found", "details": ["Race not found"] }` | Забега нет либо он принадлежит другому пользователю |
| 409 | `conflict` | Дублирующий `slug` пользователя |
| 500 | `unknown_error` | Непредвиденная ошибка приложения |
| 503 | `database_unavailable` | База недоступна |
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
- **`month`** — целое число 112, фильтрует по `EXTRACT(MONTH FROM race_date)`.
- Параметры можно комбинировать (`?year=2026&month=5`) или указывать по одному.
- Без параметров — все забеги без ограничения (пагинация пока не реализована).
- Если по фильтру нет результатов — пустой массив `[]`, статус `200`.
Некорректный JSON также отвечает `400 validation_error`.
## 6. Идемпотентность seed
## Seed
Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert** по полю `id` (`INSERT … ON CONFLICT (id) DO UPDATE`). Источник данных — `import/races_2026_calendar.csv` из корня репозитория. Повторный запуск безопасен.
## 7. Поведение при недоступной БД
- `GET /api/health` — всегда `200` (не проверяет БД).
- `GET /api/ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
- В логах сервера: строка ошибки с контекстом маршрута.
`npm run seed` выполняет upsert по `slug` и не перезаписывает пользовательские
изменения. После включения авторизации ему нужен `SEED_OWNER_USER_ID` или
`SEED_OWNER_EMAIL`.

View File

@@ -68,27 +68,26 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
| Переменная | Описание | По умолчанию |
|---|---|---|
| `DB_HOST` | Хост PostgreSQL | — (обязательна без mock, см. ниже) |
| `DB_PORT` | Порт PostgreSQL | — (обязательна без mock) |
| `DB_NAME` | Имя базы данных | — (обязательна без mock) |
| `DB_USER` | Пользователь БД | — (обязательна без mock) |
| `DB_PASSWORD` | Пароль БД | — (обязательна без mock) |
| `CALENDAR_RUN_MOCK_DB` | `1` или `true` — режим без PostgreSQL для HTTP API (dev/CI) | выкл. |
| `DB_HOST` | Хост PostgreSQL | — |
| `DB_PORT` | Порт PostgreSQL | — |
| `DB_NAME` | Имя базы данных | — |
| `DB_USER` | Пользователь БД | — |
| `DB_PASSWORD` | Пароль БД | — |
| `PORT` | Порт API (приоритетнее `API_PORT`) | — |
| `API_PORT` | Порт API-сервера | `3001` |
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
Фронтенд всегда обращается к API по префиксу `/api` на текущем origin. В локальной разработке этот префикс проксирует Vite (`frontend/vite.config.ts`) на `http://localhost:3001`; в Docker-стеке — nginx фронта проксирует на `runners-calendar-backend:3000`.
**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
При отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
**С `CALENDAR_RUN_MOCK_DB=1`:** переменные `DB_*` не обязательны; реальный пул не поднимается. **Не использовать** mock для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и корректные `DB_*`.
Для тестов нужен отдельный PostgreSQL database `calendar_run_test`; `npm test` применяет миграции и очищает его перед запуском.
## Поведение при недоступной БД
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
- **`GET /api/health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
- **`GET /api/ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
- **`GET /api/ready`** — пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет.
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
## Структура каталога
@@ -101,7 +100,7 @@ backend/
├── src/
│ ├── app.ts # фабрика Express (тесты)
│ ├── config.ts # загрузка и валидация env
│ ├── db.ts # pg Pool или mock
│ ├── db.ts # pg Pool
│ ├── index.ts # запуск сервера
│ ├── migrate.ts # раннер миграций
│ ├── seed.ts # разовый импорт CSV

147
docs/product-backlog.md Normal file
View File

@@ -0,0 +1,147 @@
# Backlog Calendar Run
**Срез репозитория: 12 июля 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей.
## Текущая база
| Контур | Версия | Уже работает |
|---|---:|---|
| 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 в футере. |
## Правила версионирования и выпуска
1. BE и FE выпускаются независимо: задачам без серверного контракта не нужен искусственный bump BE.
2. Патч (`x.y.Z`) — исправление без изменения публичного контракта; минор (`x.Y.0`) — новая обратносуместимая возможность; мажор — только при несовместимом API или модели доступа.
3. Любой релиз с изменением API включает: обновлённый `docs/backend-api-for-frontend.md`, миграцию (если нужна), тесты контракта и проверку футера с фактическими версиями.
4. До миграций в production выполнить чек-лист из [auth-rollout.md](auth-rollout.md): backup, число стартов до/после миграции и проверка владельца legacy-данных.
## План релизов
| Очередь | BE | FE | Цель | Условие выпуска |
|---|---:|---:|---|---|
| P0 | `1.4.2` | `0.7.1` | Исправить риски текущего релиза и привести контракт в соответствие коду. | Нет невалидных записей, SSRF-защита, критические сценарии проверяются на PostgreSQL. |
| P1 | `1.6.0` | `0.9.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
| P2 | `1.7.0` | `1.0.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
| P3 | `1.8.0` | `1.1.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. |
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
## P0 — BE 1.4.2 / FE 0.7.1
### Backend 1.4.2
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-142-04 | P0 | Выполнить backend-часть Ponytail-аудита | Pipeline поднимает чистый PostgreSQL, применяет все миграции и выполняет минимум CRUD + auth, включая legacy-сценарий миграции `004`. Самодельный SQL-эмулятор `CALENDAR_RUN_MOCK_DB` удалён; тесты работают с этой БД. `cross-env` удалён, если Windows не входит в список поддерживаемых сред разработки. |
| BE-142-01 | P0 | Актуализировать контракт API | Документированы `id` (UUID сервера) и `slug` (ключ пользователя), обязательная авторизация для `/races`, CSRF для мутаций, реальный ответ `/health`, все коды ошибок и формат `Race`. Старое описание не обещает несуществующий неавторизованный CRUD. |
| BE-142-02 | P0 | Ввести строгую валидацию создания и изменения старта | Одна схема на `POST`/`PATCH`: корректные дата и дистанция, enum статуса, допустимые форматы `startTime` и `finishTime`, URL, пределы длины текстов; неизвестные поля не игнорируются. Ошибка возвращает `400 validation_error` с полем/причиной. |
| BE-142-03 | P0 | Защитить получение обложки от SSRF | Автозагрузка принимает только безопасные HTTP(S)-адреса, запрещает loopback/private/link-local адреса и опасные редиректы, ограничивает число редиректов, размер и время ответа. При невозможности получить обложку старт всё равно создаётся без неё. Есть тесты на блокировку. |
| BE-142-05 | P1 | Подтвердить достаточность индексов по фактическим запросам | Для списка владельца с фильтром года/месяца снять `EXPLAIN ANALYZE`; индекс `(owner_user_id, race_date)` добавлять только если он нужен по плану запроса или размеру данных. |
### Frontend 0.7.1
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-071-00 | P0 | Выполнить frontend-часть Ponytail-аудита | Удалены артефакты TypeScript/Vite из Git и добавлены в `.gitignore`, неиспользуемый `getHealth` и тип `HealthResponse`. Кастомные поля даты и времени старта заменены нативными `input[type=date]` и `input[type=time]`; каталог названий стартов с внешними картинками удалён, остаются `coverImageUrl` и локальные fallback-изображения. Создание и обновление старта используют один payload полей. |
| FE-071-01 | P0 | Исправить семантику личного рекорда | Карточка «Последний личный рекорд» показывается только для результата, который действительно улучшил прошлое лучшее время на сопоставимой дистанции; иначе карточка называется «Последний результат» либо скрывается. |
| FE-071-02 | P0 | Валидировать время до отправки | Форма не принимает `1:99`, отрицательные и произвольные значения; пользователю показана ошибка у поля. Правило идентично серверному, но сервер остаётся источником истины. |
| FE-071-03 | P0 | Довести ошибки auth-форм до состояния UI | На регистрации, повторной отправке письма, запросе и сбросе пароля есть loading-состояние, обработка ошибки и защита от повторной отправки. Никаких необработанных Promise в браузере. |
| FE-071-04 | P1 | Корректно обработать истёкшую сессию и CSRF | После `401` пользователь возвращается на вход с сохранением целевого маршрута; после `csrf_error` интерфейс предлагает однократно обновить сессию и повторить действие, не теряя заполненную форму. |
| FE-071-05 | P0 | Завершать подтверждение email понятным экраном успеха | После успешной ссылки `/verify-email?token=…` заголовок явно сообщает, что email подтверждён; ввод email, инструкция и кнопка повторной отправки не показываются. Форма повторной отправки остаётся только для недействительной/отсутствующей ссылки. |
| FE-071-06 | P0 | Упростить ввод результата забега | Время вводится отдельными цифровыми полями «часы», «минуты», «секунды» без ручного ввода `:`. Часы — целое число ≥ 0, минуты и секунды — от 0 до 59; пустые минуты/секунды автоматически нормализуются в `0`. Итоговое время не показывает незаполненные старшие части: без часов — `MM:SS`, без часов и минут — `SS`. Место вводится числами «место» и «всего участников» (второе необязательно), проверяется, что место ≥ 1 и не больше общего числа; в текущий `finishPlace` без изменения API сохраняется `место/всего` либо только место. Старые значения `finishPlace` не теряются до редактирования результата. |
## P1 — BE 1.6.0 / FE 0.9.0
### Backend 1.6.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
| BE-150-03 | Готово | Обратносуместимая фильтрация стартов | Добавлены документированные фильтры `status`, диапазон дат и диапазон дистанции без изменения ответа-массива `GET /races`, с валидацией параметров. |
| BE-150-04 | Готово | Восстановить обложки существующих стартов | Добавлен идемпотентный backfill и обновление обложки при изменении официальной страницы. SSRF-защищённая логика переиспользуется; ручные URL не перезаписываются, результат содержит агрегированную статистику. |
### Frontend 0.9.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. |
| FE-080-02 | Готово | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции, поиск по названию и сортировка синхронизированы с URL. Годы в селекторе строятся из данных. |
| FE-080-03 | Готово | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере; сервер не нужен. |
| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. |
| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. |
| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. |
| FE-080-07 | Готово | Вернуть управление официальной страницей старта | Поле «Официальная страница старта» доступно для любого старта, включая прошедший; для прошедших скрываются только неактуальные поля расписания. |
| FE-080-08 | Готово | Перевести график прогресса на AG Charts | Самописный SVG заменён на AG Charts; сохранены данные, оси, tooltip, клавиатурная доступность, переход по точке, адаптивность и пустое состояние. |
## P2 — BE 1.7.0 / FE 1.0.0
### Backend 1.7.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-160-01 | P1 | Чек-листы подготовки к старту | Новая сущность задач старта: текст, состояние, срок и порядок. Задачи принадлежат тому же пользователю, что и старт; каскадно удаляются вместе со стартом. |
| BE-160-02 | P1 | Надёжные email-напоминания | Настройки пользователя и очередь напоминаний хранятся в БД; отправка идемпотентна, ошибки ретраятся ограниченно и логируются без PII. Расписание запуска — внешний cron/worker, а не `setInterval` в каждом web-инстансе. |
| BE-160-03 | P2 | Подписка на персональный iCalendar | Выдать отдельный отзывной секретный URL только для read-only календаря пользователя. Нужны лимитирование, отсутствие email/заметок в ICS и отзыв/ротация токена. |
### Frontend 1.0.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
| 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
### Backend 1.8.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-170-01 | P0 | Эксплуатационный runbook | Описаны deploy, миграция, rollback, backup, restore, ротация секретов, SMTP/Turnstile и реакция на недоступную БД. Restore протестирован на копии production-данных. |
| BE-170-02 | P0 | Наблюдаемость без утечки данных | Структурированные логи с request ID, готовность БД, счётчики ошибок и alert на недоступность. Email, пароли, токены, cookie и текст заметок не логируются. |
| BE-170-03 | P1 | Машиночитаемый API-контракт | OpenAPI-спецификация синхронизирована с маршрутам и используется в CI для проверки ключевых ответов. Это предотвращает повторное расхождение документации и реализации. |
### Frontend 1.1.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-100-01 | P0 | Критический E2E-путь | Автоматически проверяются: регистрация/подтверждение (с тестовым адаптером), вход, создание старта, внесение результата, экспорт и выход. Тест запускается в CI против реального API и PostgreSQL. |
| FE-100-02 | P0 | Accessibility и мобильный аудит | Полный путь проходится клавиатурой; формы сообщают ошибки screen reader'у, focus не теряется после действий, контраст и touch-targets соответствуют WCAG 2.2 AA. Проверены малый экран и медленное соединение. |
| FE-100-03 | P1 | Полировка надёжности UI | Единые skeleton/empty/error состояния, retry для безопасных загрузок, защита от двойной отправки мутаций, проверка загрузки внешних обложек и сохранение версии FE/BE в футере. |
## Зависимости и решения до начала P2
| Решение | Нужен ответ | Почему блокирует |
|---|---|---|
| Канал напоминаний | Только email или ещё push/мессенджер? | Определяет модель согласия, worker и стоимость эксплуатации. |
| Планировщик | Какой внешний cron/worker доступен в production? | Нельзя надёжно рассылать напоминания таймером в web-процессе. |
| Удаление аккаунта | Немедленное удаление или 30-дневное восстановление? | Меняет схему данных и обязанность хранить архив. |
| Политика данных | Для каких стран и пользователей запускается сервис? | До публичного запуска нужны политика конфиденциальности, срок хранения и процесс обращения пользователя. |
## Явно не берём до подтверждения спроса
- общий доступ к календарям, клубы, тренеры и публичные профили;
- поиск и парсинг стартов из сайтов организаторов, кроме уже существующей необязательной обложки;
- нативные iOS/Android-приложения, платежи и социальная лента;
- серверную пагинацию: личный календарь пока мал, а изменение формы ответа `GET /races` без измеренной необходимости только усложнит FE и API.
## Рекомендуемый первый спринт
1. `BE-142-04` и `FE-071-00`: выполнить выводы Ponytail-аудита — удалить самодельную инфраструктуру и использовать возможности платформы.
2. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary.
3. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек.
4. `FE-071-01``FE-071-06`: устранить неверную аналитику и ошибки критических форм, включая понятное завершение подтверждения email и ввод результата.
## Выполненный спринт
**Цель:** пользователь быстро находит старты, восстанавливает данные истории, выгружает результат и видит прогресс в поддерживаемой chart-визуализации.
1. `BE-150-04` + `FE-080-07`: восстановление обложек существующих стартов и управление официальной страницей, включая прошедшие старты. Ручные обложки не перезаписываются; используется существующая SSRF-защищённая логика.
2. `BE-150-03` + `FE-080-02`: фильтры по статусу, периоду и дистанции, поиск по названию, сортировка и синхронизация с URL. Обновить API-документацию и контрактные тесты.
3. `FE-080-03`: экспорт текущей отфильтрованной выборки в CSV и одноразовый `.ics` без нового backend и зависимостей.
4. `FE-080-08`: заменить самописный SVG-график прогресса на AG Charts. Сохранить текущие данные и метрики, подписи осей, tooltip, клавиатурную доступность, переход по точке в старт, адаптивность и пустое состояние. Не менять API и не добавлять новую обёртку поверх `PaceTrendChart`.
`BE-150-02` в спринт не брать до решения: удаление аккаунта сразу или с 30-дневным восстановлением.

View File

@@ -1,2 +1,4 @@
# Для локального npm run dev дополнительных VITE-переменных не требуется.
# Для production-регистрации укажите публичный site key Cloudflare Turnstile.
# Без значения локально используется dev bypass token, если он разрешён бэкендом.
# VITE_TURNSTILE_SITE_KEY=
# Полный список переменных окружения — в корневом .env.example репозитория.

View File

@@ -2,6 +2,7 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Календарь стартов</title>
</head>

View File

@@ -1,13 +1,15 @@
{
"name": "calendar-run-frontend",
"version": "0.4.0",
"version": "0.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.4.0",
"version": "0.10.2",
"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",

View File

@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.4.0",
"version": "0.10.2",
"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"

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Календарь стартов">
<defs>
<linearGradient id="bg" x1="12" y1="4" x2="52" y2="60" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1168d8" />
<stop offset="1" stop-color="#071927" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#bg)" />
<path
d="M18 20h28a5 5 0 0 1 5 5v21a5 5 0 0 1-5 5H18a5 5 0 0 1-5-5V25a5 5 0 0 1 5-5Z"
fill="#ffffff"
/>
<path d="M13 29h38" stroke="#d6e1ea" stroke-width="4" />
<path d="M23 14v11M41 14v11" stroke="#b9f24a" stroke-width="5" stroke-linecap="round" />
<path
d="M22 41c5-8 13-8 18 0M22 41h18"
fill="none"
stroke="#1168d8"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="44" cy="44" r="5" fill="#ff6f5e" />
</svg>

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

100
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,100 @@
import { requestJson, setCsrfToken } from "./http";
import type { AuthSession, AuthUser } from "./types";
interface AuthResponse {
user: AuthUser;
csrfToken: string | null;
}
function applyAuthResponse(response: AuthResponse): AuthResponse {
setCsrfToken(response.csrfToken);
return response;
}
export async function getCurrentUser(): Promise<AuthResponse> {
return applyAuthResponse(await requestJson<AuthResponse>("/auth/me"));
}
export async function register(payload: {
email: string;
password: string;
turnstileToken: string;
}): Promise<void> {
await requestJson<void>("/auth/register", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function login(payload: { email: string; password: string }): Promise<AuthResponse> {
return applyAuthResponse(
await requestJson<AuthResponse>("/auth/login", {
method: "POST",
body: JSON.stringify(payload),
}),
);
}
export async function logout(): Promise<void> {
await requestJson<void>("/auth/logout", { method: "POST" });
setCsrfToken(null);
}
export async function verifyEmail(token: string): Promise<void> {
await requestJson<void>("/auth/verify-email", {
method: "POST",
body: JSON.stringify({ token }),
});
}
export async function resendVerification(email: string): Promise<void> {
await requestJson<void>("/auth/resend-verification", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function forgotPassword(email: string): Promise<void> {
await requestJson<void>("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(token: string, password: string): Promise<void> {
await requestJson<void>("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
}
export async function getSessions(): Promise<AuthSession[]> {
const response = await requestJson<{ sessions: AuthSession[] }>("/auth/sessions");
return response.sessions;
}
export async function changePassword(payload: { currentPassword: string; newPassword: string }): Promise<void> {
await requestJson<void>("/auth/password", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function revokeSession(sessionId: string): Promise<void> {
await requestJson<void>(`/auth/sessions/${sessionId}`, { method: "DELETE" });
}
export async function revokeOtherSessions(): Promise<void> {
await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" });
}
export async function updateProfile(payload: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
}): Promise<void> {
await requestJson<void>("/auth/profile", {
method: "PATCH",
body: JSON.stringify(payload),
});
}

View File

@@ -3,6 +3,13 @@ export type ApiErrorCode =
| "not_found"
| "database_unavailable"
| "conflict"
| "username_taken"
| "unauthorized"
| "email_not_verified"
| "csrf_error"
| "captcha_failed"
| "invalid_credentials"
| "invalid_token"
| "network_error"
| "unknown_error";
@@ -36,6 +43,13 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "not_found" ||
value === "database_unavailable" ||
value === "conflict" ||
value === "username_taken" ||
value === "unauthorized" ||
value === "email_not_verified" ||
value === "csrf_error" ||
value === "captcha_failed" ||
value === "invalid_credentials" ||
value === "invalid_token" ||
value === "unknown_error"
) {
return value;
@@ -98,6 +112,20 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Сервис временно недоступен. Попробуйте позже.";
case "conflict":
return "Запись с таким идентификатором уже существует.";
case "username_taken":
return "Это имя пользователя уже занято.";
case "unauthorized":
return "Нужно войти в аккаунт.";
case "email_not_verified":
return "Подтвердите email, чтобы продолжить.";
case "csrf_error":
return "Сессия устарела. Обновите страницу и попробуйте снова.";
case "captcha_failed":
return "Проверка капчи не пройдена.";
case "invalid_credentials":
return "Неверный email или пароль.";
case "invalid_token":
return "Ссылка недействительна или устарела.";
case "network_error":
return "Не удалось связаться с сервером.";
case "unknown_error":

View File

@@ -1,18 +1,9 @@
import { requestJson } from "./http";
export type HealthResponse = {
status: string;
version: string;
};
export type BackendMetaResponse = {
version: string;
};
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
return requestJson<HealthResponse>("/health", init);
}
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
return requestJson<BackendMetaResponse>("/meta", init);

View File

@@ -1,6 +1,11 @@
import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors";
const API_ROOT = "/api";
let csrfToken: string | null = null;
export function setCsrfToken(token: string | null): void {
csrfToken = token;
}
function buildUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
@@ -61,9 +66,13 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
if (method !== "GET" && method !== "HEAD") {
defaultHeaders["Content-Type"] = "application/json";
}
if (csrfToken && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
defaultHeaders["X-CSRF-Token"] = csrfToken;
}
const response = await fetch(buildUrl(path), {
...init,
credentials: "include",
headers: {
...defaultHeaders,
...(init?.headers ?? {}),

View File

@@ -1,5 +1,21 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export type { AuthSession, AuthUser, CreateRacePayload, PublicRace, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors";
export type { BackendMetaResponse, HealthResponse } from "./health";
export { getBackendMeta, getHealth } from "./health";
export type { BackendMetaResponse } from "./health";
export { getBackendMeta } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
export { getPublicRaces } from "./users";
export {
forgotPassword,
changePassword,
getCurrentUser,
getSessions,
login,
logout,
register,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSession,
updateProfile,
verifyEmail,
} from "./auth";

View File

@@ -10,11 +10,16 @@ function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function isOptionalNullableString(value: unknown): value is string | null | undefined {
return value === undefined || isNullableString(value);
}
function normalizeRace(input: unknown): Race {
const race = input as Partial<Race>;
const isValid =
isString(race?.id) &&
isString(race?.slug) &&
isString(race?.date) &&
isString(race?.title) &&
typeof race?.distanceKm === "number" &&
@@ -23,6 +28,7 @@ function normalizeRace(input: unknown): Race {
race?.status === "registered" ||
race?.status === "completed") &&
isNullableString(race?.officialUrl) &&
isOptionalNullableString(race?.coverImageUrl) &&
isNullableString(race?.startTime) &&
isNullableString(race?.clusterSchedule) &&
isNullableString(race?.bibPickup) &&
@@ -41,22 +47,25 @@ function normalizeRace(input: unknown): Race {
});
}
const validRace = race as Race;
return {
id: race.id,
date: race.date,
title: race.title,
distanceKm: race.distanceKm,
status: race.status,
officialUrl: race.officialUrl,
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,
};
}
@@ -72,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}` : "";

View File

@@ -2,11 +2,13 @@ export type RaceStatus = "planned" | "registered" | "completed";
export interface Race {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
status: RaceStatus | null;
officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
@@ -21,15 +23,21 @@ export interface Race {
export interface RacesQuery {
year?: number;
month?: number;
status?: RaceStatus;
dateFrom?: string;
dateTo?: string;
distanceMin?: number;
distanceMax?: number;
}
export interface CreateRacePayload {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
status?: RaceStatus | null;
officialUrl?: string | null;
coverImageUrl?: string | null;
startTime?: string | null;
clusterSchedule?: string | null;
bibPickup?: string | null;
@@ -40,3 +48,30 @@ export interface CreateRacePayload {
}
export type UpdateRacePayload = Partial<Omit<CreateRacePayload, "id">>;
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}
export interface PublicRace {
date: string;
title: string;
distanceKm: number;
status: RaceStatus | null;
coverImageUrl: string | null;
finishTime: string | null;
finishPlace: string | null;
}
export interface AuthSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}

24
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,24 @@
import { ApiError } from "./errors";
import { requestJson } from "./http";
import type { PublicRace } from "./types";
function isPublicRace(value: unknown): value is PublicRace {
const race = value as Partial<PublicRace>;
return (
typeof race?.date === "string" &&
typeof race?.title === "string" &&
typeof race?.distanceKm === "number" &&
(race?.status === null || race?.status === "planned" || race?.status === "registered" || race?.status === "completed") &&
(race?.coverImageUrl === null || typeof race?.coverImageUrl === "string") &&
(race?.finishTime === null || typeof race?.finishTime === "string") &&
(race?.finishPlace === null || typeof race?.finishPlace === "string")
);
}
export async function getPublicRaces(username: string, init?: RequestInit): Promise<PublicRace[]> {
const response = await requestJson<unknown[]>(`/users/${encodeURIComponent(username)}/races`, init);
if (!Array.isArray(response) || !response.every(isPublicRace)) {
throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." });
}
return response;
}

View File

@@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { AuthUser } from "../../api";
import { ApiError, getCurrentUser, login as loginRequest, logout as logoutRequest } from "../../api";
interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
login(email: string, password: string): Promise<void>;
logout(): Promise<void>;
refresh(): Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider(props: { children: React.ReactNode }): JSX.Element {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const response = await getCurrentUser();
setUser(response.user);
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
setUser(null);
return;
}
setUser(null);
}
}, []);
useEffect(() => {
let mounted = true;
void refresh().finally(() => {
if (mounted) {
setIsLoading(false);
}
});
return () => {
mounted = false;
};
}, [refresh]);
const login = useCallback(async (email: string, password: string) => {
const response = await loginRequest({ email, password });
setUser(response.user);
}, []);
const logout = useCallback(async () => {
await logoutRequest();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, isLoading, login, logout, refresh }),
[user, isLoading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,26 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
export function RequireAuth(): JSX.Element {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<section className="page page--auth" aria-busy="true">
<h1 className="page__title">Загрузка</h1>
<p className="page__subtitle">Проверяем сессию...</p>
</section>
);
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
if (!user.emailVerifiedAt) {
return <Navigate to="/verify-email" replace />;
}
return <Outlet />;
}

View File

@@ -1,11 +1,16 @@
import { NavLink, Outlet } from "react-router-dom";
import { Link, NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { AppShellFooter } from "./AppShellFooter";
export function AppLayout(): JSX.Element {
const { user, logout } = useAuth();
return (
<div className="app-shell">
<header className="app-shell__header">
<div className="app-shell__brand">Календарь стартов</div>
<Link className="app-shell__brand" to="/">
Календарь стартов
</Link>
<nav className="app-shell__nav" aria-label="Основная навигация">
<NavLink
to="/"
@@ -32,6 +37,30 @@ export function AppLayout(): JSX.Element {
>
+ Добавить
</NavLink>
{user ? (
<>
<NavLink
to="/account"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Аккаунт
</NavLink>
<button className="app-shell__link app-shell__link--button" type="button" onClick={() => void logout()}>
Выйти
</button>
</>
) : (
<NavLink
to="/login"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Войти
</NavLink>
)}
</nav>
</header>
<main className="app-shell__main">

View File

@@ -5,18 +5,34 @@ import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
import { RaceFormPage } from "../pages/RaceFormPage";
import { RaceDayPage } from "../pages/RaceDayPage";
import { AccountPage } from "../pages/AccountPage";
import { PublicProfilePage } from "../pages/PublicProfilePage";
import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages";
import { RequireAuth } from "./auth/RequireAuth";
export const appRouter = createBrowserRouter([
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "races", element: <RacesPage /> },
{ path: "races/new", element: <RaceFormPage /> },
{ path: "races/day/:ymd", element: <RaceDayPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "verify-email", element: <VerifyEmailPage /> },
{ path: "forgot-password", element: <ForgotPasswordPage /> },
{ path: "reset-password", element: <ResetPasswordPage /> },
{ path: "users/:username", element: <PublicProfilePage /> },
{
element: <RequireAuth />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "account", element: <AccountPage /> },
{ path: "races", element: <RacesPage /> },
{ path: "races/new", element: <RaceFormPage /> },
{ path: "races/day/:ymd", element: <RaceDayPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
],
},
],
},
]);

View File

@@ -1,70 +1,114 @@
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, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
type PaceTrendChartProps = {
races: Race[];
distanceKm: number;
};
/** Линейный график: время финиша (минуты) по завершённым стартам выбранной дистанции. */
export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
const { races, distanceKm } = props;
type PaceDatum = {
id: string;
date: string;
dateLabel: string;
paceSeconds: number;
race: Race;
};
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) => {
const seconds = parseFinishTimeToSeconds(race.finishTime)!;
return { race, minutes: seconds / 60 };
});
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 {
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 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),
paceSeconds: parseFinishTimeToSeconds(race.finishTime)! / race.distanceKm,
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",
reverse: true,
min: 0,
title: { text: "Темп, мин/км" },
label: { formatter: ({ value }) => formatAxisPace(Number(value)) },
},
},
series: [{
type: "line",
xKey: "dateLabel",
xName: "Дата старта",
yKey: "paceSeconds",
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>
);
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
}
const minutes = series.map((s) => s.minutes);
const minM = Math.min(...minutes);
const maxM = Math.max(...minutes);
const range = maxM - minM || 1;
const n = series.length;
const pad = 4;
const w = 100;
const h = 36;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const points = series
.map((s, i) => {
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const norm = (maxM - s.minutes) / range;
const y = pad + (1 - norm) * innerH;
return `${x},${y}`;
})
.join(" ");
const last = series[series.length - 1]!;
return (
<div className="pace-chart">
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
<polyline className="pace-chart__line" fill="none" points={points} />
</svg>
<p className="pace-chart__caption">
Последний пункт: {formatRaceDate(last.race.date)} {last.race.finishTime} (
{last.minutes.toFixed(1)} мин)
</p>
<div className="pace-chart" role="group" aria-label="Динамика темпа на дистанции. Выберите точку, чтобы открыть старт.">
<AgCharts options={options} />
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
import { buildMonthCells, groupRacesByYmd, isRaceDateInPast, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
const MONTH_NAMES_RU_SHORT = [
"янв.",
"февр.",
"мар.",
"апр.",
"мая",
"май",
"июн.",
"июл.",
"авг.",
@@ -20,6 +20,13 @@ const MONTH_NAMES_RU_SHORT = [
const POPOVER_LEAVE_MS = 140;
function toLocalYmd(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
interface RacesCalendarProps {
displayYear: number;
monthFilter: string;
@@ -77,6 +84,8 @@ function CalendarMonthBlock(props: {
setOpenYmd: (v: string | null) => void;
scheduleClose: () => void;
cancelClose: () => void;
onMonthSelect?: (monthIndex: number) => void;
todayYmd: string;
}): JSX.Element {
const {
year,
@@ -88,6 +97,8 @@ function CalendarMonthBlock(props: {
setOpenYmd,
scheduleClose,
cancelClose,
onMonthSelect,
todayYmd,
} = props;
const cells = useMemo(() => buildMonthCells(year, monthIndex), [year, monthIndex]);
const title = `${MONTH_NAMES_RU_SHORT[monthIndex]} ${year}`;
@@ -96,7 +107,21 @@ function CalendarMonthBlock(props: {
return (
<div className={blockClass}>
<h3 className="races-cal__month-title">{title}</h3>
<h3 className="races-cal__month-title">
{onMonthSelect ? (
<button
type="button"
className="races-cal__month-title-button"
onClick={() => {
onMonthSelect(monthIndex);
}}
>
{title}
</button>
) : (
title
)}
</h3>
<div className="races-cal__weekdays" aria-hidden>
{WEEKDAY_LABELS_SHORT_RU.map((d) => (
<span key={d} className="races-cal__weekday">
@@ -113,14 +138,25 @@ function CalendarMonthBlock(props: {
const dayRaces = racesByYmd.get(ymd) ?? [];
const hasRaces = dayRaces.length > 0;
const isOpen = openYmd === ymd;
const isPast = isRaceDateInPast(ymd);
const isToday = ymd === todayYmd;
const cellClassName = [
"races-cal__cell",
hasRaces ? "races-cal__cell--has-race" : "",
isOpen ? "races-cal__cell--open" : "",
isPast ? "races-cal__cell--past" : "",
isToday ? "races-cal__cell--today" : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={ymd}
className={`races-cal__cell${hasRaces ? " races-cal__cell--has-race" : ""}${isOpen ? " races-cal__cell--open" : ""}`}
className={cellClassName}
onMouseEnter={() => {
cancelClose();
setOpenYmd(ymd);
setOpenYmd(hasRaces ? ymd : null);
}}
onMouseLeave={scheduleClose}
>
@@ -132,7 +168,7 @@ function CalendarMonthBlock(props: {
}}
onFocus={() => {
cancelClose();
setOpenYmd(ymd);
setOpenYmd(hasRaces ? ymd : null);
}}
onBlur={(e) => {
const next = e.relatedTarget as Node | null;
@@ -144,7 +180,7 @@ function CalendarMonthBlock(props: {
>
{day}
</button>
{isOpen ? (
{isOpen && hasRaces ? (
<DayPopover
ymd={ymd}
races={dayRaces}
@@ -182,12 +218,13 @@ export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
}, [cancelClose]);
const racesByYmd = useMemo(() => groupRacesByYmd(races), [races]);
const todayYmd = useMemo(() => toLocalYmd(new Date()), []);
const focusedMonthIndex = monthFilter === "" ? null : parseInt(monthFilter, 10) - 1;
return (
<div className="races-cal">
<p className="races-cal__hint">Наведите на дату краткая информация. Клик страница дня.</p>
<p className="races-cal__hint">Наведите на дату с забегом краткая информация. Клик страница дня.</p>
{focusedMonthIndex === null || Number.isNaN(focusedMonthIndex) ? (
<div className="races-cal__year">
{MONTH_NAMES_RU_SHORT.map((_, mi) => (
@@ -202,6 +239,11 @@ export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
onMonthSelect={(mi) => {
onMonthFilterChange(String(mi + 1));
setOpenYmd(null);
}}
todayYmd={todayYmd}
/>
))}
</div>
@@ -240,6 +282,7 @@ export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
todayYmd={todayYmd}
/>
</div>
)}

View File

@@ -1,157 +0,0 @@
import { useCallback, useMemo } from "react";
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function parseToParts(value: string): { h: number | null; m: number | null; s: number | null } {
const t = value.trim();
if (!t) {
return { h: null, m: null, s: null };
}
const parts = t.split(":").map((p) => p.trim());
if (parts.length === 2) {
const h = Number(parts[0]);
const m = Number(parts[1]);
if (Number.isInteger(h) && Number.isInteger(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return { h, m, s: 0 };
}
}
if (parts.length >= 3) {
const h = Number(parts[0]);
const m = Number(parts[1]);
const s = Number(parts[2]);
if (
Number.isInteger(h) &&
Number.isInteger(m) &&
Number.isInteger(s) &&
h >= 0 &&
h <= 23 &&
m >= 0 &&
m <= 59 &&
s >= 0 &&
s <= 59
) {
return { h, m, s };
}
}
return { h: null, m: null, s: null };
}
function partsToString(h: number | null, m: number | null, s: number | null): string {
if (h === null || m === null || s === null) {
return "";
}
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
}
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MIN_SEC = Array.from({ length: 60 }, (_, i) => i);
interface StartTimeSelectsProps {
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
export function StartTimeSelects(props: StartTimeSelectsProps): JSX.Element {
const { value, onChange, disabled } = props;
const { h, m, s } = useMemo(() => parseToParts(value), [value]);
const emit = useCallback(
(nextH: number | null, nextM: number | null, nextS: number | null) => {
onChange(partsToString(nextH, nextM, nextS));
},
[onChange],
);
const hourVal = h === null ? "" : String(h);
const minVal = m === null ? "" : String(m);
const secVal = s === null ? "" : String(s);
return (
<div className="race-form__time-picker">
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Часы</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Часы"
disabled={disabled}
value={hourVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nh = Number(v);
emit(nh, m ?? 0, s ?? 0);
}}
>
<option value=""></option>
{HOURS.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Минуты</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Минуты"
disabled={disabled}
value={minVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nm = Number(v);
emit(h ?? 0, nm, s ?? 0);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Секунды</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Секунды"
disabled={disabled}
value={secVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const ns = Number(v);
emit(h ?? 0, m ?? 0, ns);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
</div>
);
}

View File

@@ -1,3 +1,2 @@
export { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";
export { StartTimeSelects } from "./StartTimeSelects";

View File

@@ -2,16 +2,20 @@ import type { Race } from "../api";
export const WEEKDAY_LABELS_SHORT_RU: string[] = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
/** Monday-based week: Mon=0 Sun=6 */
/** Monday-based week: Mon=0 ... Sun=6 */
function mondayIndexFromJsDay(jsDay: number): number {
return (jsDay + 6) % 7;
}
/** Monday-based week: Mon=0 ... Sun=6 */
export function mondayIndexFromDate(d: Date): number {
return (d.getDay() + 6) % 7;
return mondayIndexFromJsDay(d.getDay());
}
/** Grid cells for one month: `null` = empty, `1..31` = day of month. Padded to full weeks, at least 6 rows. */
export function buildMonthCells(year: number, monthIndex: number): (number | null)[] {
const first = new Date(year, monthIndex, 1);
const lead = mondayIndexFromDate(first);
const dim = new Date(year, monthIndex + 1, 0).getDate();
const lead = mondayIndexFromJsDay(new Date(Date.UTC(year, monthIndex, 1)).getUTCDay());
const dim = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
const cells: (number | null)[] = [];
for (let i = 0; i < lead; i += 1) {
cells.push(null);

View File

@@ -1,6 +1,7 @@
export {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getPaceLabel,
getRaceCountdownLabel,
getRaceStatusClassName,
@@ -16,3 +17,6 @@ export {
} from "./raceMetrics";
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";

View 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);
}

View File

@@ -24,18 +24,29 @@ export function parseFinishTimeToSeconds(value: string | null): number | null {
return null;
}
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
const parts = value.split(":");
if (parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
const values = parts.map(Number);
if (parts.length === 2) {
const [minutes, seconds] = parts;
if (values.length === 1) {
return values[0]! <= 59 ? values[0]! : null;
}
if (values.length === 2) {
const [minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
if (values.length === 3) {
const [hours, minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
@@ -62,6 +73,37 @@ export function sortByDateDesc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}
/** Последний результат, который улучшил уже существовавший личный рекорд на сопоставимой дистанции. */
export function findLatestPersonalRecord(races: Race[]): Race | null {
const bestTimes: Array<{ distanceKm: number; seconds: number }> = [];
let latestRecord: Race | null = null;
for (const race of sortByDateAsc(races)) {
if (race.status !== "completed") {
continue;
}
const seconds = parseFinishTimeToSeconds(race.finishTime);
if (seconds === null) {
continue;
}
// ponytail: personal calendars are small; introduce a distance index only if profiling requires it.
const comparable = bestTimes.filter((best) => isCloseDistance(best.distanceKm, race.distanceKm));
if (comparable.length > 0 && seconds < Math.min(...comparable.map((best) => best.seconds))) {
latestRecord = race;
}
if (comparable.length === 0) {
bestTimes.push({ distanceKm: race.distanceKm, seconds });
} else {
comparable.forEach((best) => {
best.seconds = Math.min(best.seconds, seconds);
});
}
}
return latestRecord;
}
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
const today = new Date(now);
today.setHours(0, 0, 0, 0);

View File

@@ -0,0 +1,92 @@
import type { Race } from "../api";
export type RaceVisualVariant = "short" | "half" | "marathon" | "trail" | "night";
export type RaceVisualFit = "cover" | "contain";
interface RaceVisual {
variant: RaceVisualVariant;
imageSrc: string;
fallbackSrc: string;
imageFit: RaceVisualFit;
label: string;
}
const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
short: {
variant: "short",
imageSrc: "/images/race-short.jpg",
fallbackSrc: "/images/race-short.jpg",
imageFit: "cover",
label: "Городской темп",
},
half: {
variant: "half",
imageSrc: "/images/race-half.jpg",
fallbackSrc: "/images/race-half.jpg",
imageFit: "cover",
label: "Полумарафон",
},
marathon: {
variant: "marathon",
imageSrc: "/images/race-marathon.jpg",
fallbackSrc: "/images/race-marathon.jpg",
imageFit: "cover",
label: "Марафон",
},
trail: {
variant: "trail",
imageSrc: "/images/race-trail.jpg",
fallbackSrc: "/images/race-trail.jpg",
imageFit: "cover",
label: "Трейл",
},
night: {
variant: "night",
imageSrc: "/images/race-night.jpg",
fallbackSrc: "/images/race-night.jpg",
imageFit: "cover",
label: "Ночной старт",
},
};
function getFallbackRaceVisual(race: Race): RaceVisual {
const title = race.title.toLowerCase();
if (title.includes("ночной")) {
return FALLBACK_VISUALS.night;
}
if (
title.includes("trail") ||
title.includes("extreme") ||
title.includes("suvorov") ||
title.includes("трейл") ||
title.includes("экстрим")
) {
return FALLBACK_VISUALS.trail;
}
if (Math.abs(race.distanceKm - 42.2) < 0.8) {
return FALLBACK_VISUALS.marathon;
}
if (Math.abs(race.distanceKm - 21.1) < 0.4) {
return FALLBACK_VISUALS.half;
}
return FALLBACK_VISUALS.short;
}
export function getRaceVisual(race: Race): RaceVisual {
const fallback = getFallbackRaceVisual(race);
if (race.coverImageUrl) {
return {
...fallback,
imageSrc: race.coverImageUrl,
fallbackSrc: fallback.imageSrc,
};
}
return fallback;
}

View File

@@ -1,12 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { AllCommunityModule, ModuleRegistry } from "ag-charts-community";
import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./app/auth/AuthContext";
import { appRouter } from "./app/router";
import "./styles/tokens.css";
import "./styles/global.css";
ModuleRegistry.registerModules([AllCommunityModule]);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RouterProvider router={appRouter} />
<AuthProvider>
<RouterProvider router={appRouter} />
</AuthProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,227 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfile } from "../api";
import type { AuthSession } from "../api";
import { useAuth } from "../app/auth/AuthContext";
function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
export function AccountPage(): JSX.Element {
const { user, logout, refresh } = useAuth();
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsError, setSessionsError] = useState("");
const [sessionsLoading, setSessionsLoading] = useState(true);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [passwordError, setPasswordError] = useState("");
const [passwordSuccess, setPasswordSuccess] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
const [profileError, setProfileError] = useState("");
const [profileSuccess, setProfileSuccess] = useState("");
const [profileSaving, setProfileSaving] = useState(false);
const [profileUsername, setProfileUsername] = useState(user?.profileUsername ?? "");
const [futureRacesPublic, setFutureRacesPublic] = useState(user?.isFutureRacesPublic ?? false);
const [completedRacesPublic, setCompletedRacesPublic] = useState(user?.isCompletedRacesPublic ?? false);
const loadSessions = useCallback(async () => {
setSessionsLoading(true);
setSessionsError("");
try {
setSessions(await getSessions());
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось загрузить активные сессии.");
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
useEffect(() => {
setProfileUsername(user?.profileUsername ?? "");
setFutureRacesPublic(user?.isFutureRacesPublic ?? false);
setCompletedRacesPublic(user?.isCompletedRacesPublic ?? false);
}, [user?.profileUsername, user?.isFutureRacesPublic, user?.isCompletedRacesPublic]);
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setPasswordError("");
setPasswordSuccess("");
if (newPassword !== passwordConfirmation) {
setPasswordError("Новый пароль и его повтор не совпадают.");
return;
}
setPasswordSaving(true);
try {
await changePassword({ currentPassword, newPassword });
setCurrentPassword("");
setNewPassword("");
setPasswordConfirmation("");
setPasswordSuccess("Пароль изменён. Остальные активные сессии завершены.");
await loadSessions();
} catch (error) {
setPasswordError(error instanceof ApiError ? error.message : "Не удалось изменить пароль.");
} finally {
setPasswordSaving(false);
}
}
async function handleSessionRevoke(session: AuthSession): Promise<void> {
try {
await revokeSession(session.id);
if (session.current) {
await logout();
return;
}
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессию.");
}
}
async function handleOtherSessionsRevoke(): Promise<void> {
try {
await revokeOtherSessions();
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессии.");
}
}
async function handleProfileSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setProfileSaving(true);
setProfileError("");
setProfileSuccess("");
try {
await updateProfile({
profileUsername: profileUsername.trim(),
isFutureRacesPublic: futureRacesPublic,
isCompletedRacesPublic: completedRacesPublic,
});
await refresh();
setProfileSuccess("Настройки личной страницы сохранены.");
} catch (error) {
setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы.");
} finally {
setProfileSaving(false);
}
}
async function copyProfileUrl(): Promise<void> {
if (!user?.profileUsername) {
return;
}
setProfileError("");
try {
await navigator.clipboard.writeText(`${window.location.origin}/users/${user.profileUsername}`);
setProfileSuccess("Адрес скопирован.");
} catch {
setProfileError("Не удалось скопировать адрес.");
}
}
const profileUrl = user?.profileUsername ? `${window.location.origin}/users/${user.profileUsername}` : "";
return (
<section className="page page--auth">
<h1 className="page__title">Аккаунт</h1>
<p className="page__subtitle">{user?.email} email подтверждён.</p>
<form className="auth-form" aria-labelledby="profile-title" onSubmit={(event) => void handleProfileSubmit(event)}>
<h2 className="auth-form__title" id="profile-title">Публичная страница</h2>
<label className="auth-form__field">
<span className="auth-form__label">Имя пользователя</span>
<input
className="auth-form__input"
required
minLength={3}
maxLength={30}
pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
value={profileUsername}
onChange={(event) => setProfileUsername(event.target.value.toLowerCase())}
/>
</label>
<label className="auth-form__field">
<span>
<input
checked={futureRacesPublic}
disabled={profileSaving}
type="checkbox"
onChange={(event) => setFutureRacesPublic(event.target.checked)}
/>{" "}
Видимость будущих стартов
</span>
</label>
<label className="auth-form__field">
<span>
<input
checked={completedRacesPublic}
disabled={profileSaving}
type="checkbox"
onChange={(event) => setCompletedRacesPublic(event.target.checked)}
/>{" "}
Видимость завершённых стартов и результатов
</span>
</label>
<div className="auth-form__field">
<span className="auth-form__label">Адрес личной страницы</span>
<div className="account__profile-link">
<input className="auth-form__input" readOnly value={profileUrl} placeholder="Сначала задайте имя пользователя" />
<button className="btn btn--secondary" disabled={!profileUrl} type="button" onClick={() => void copyProfileUrl()}>Скопировать</button>
</div>
</div>
<p className="page__subtitle">Открытая страница не показывает личные заметки и номера участников.</p>
{profileError ? <p className="page__subtitle page__subtitle--error" role="alert">{profileError}</p> : null}
{profileSuccess ? <p className="account__success" role="status">{profileSuccess}</p> : null}
<button className="btn" disabled={profileSaving} type="submit">{profileSaving ? "Сохраняем…" : "Сохранить настройки"}</button>
</form>
<form className="auth-form" onSubmit={(event) => void handlePasswordSubmit(event)}>
<h2 className="auth-form__title">Сменить пароль</h2>
<label className="auth-form__field">
Текущий пароль
<input className="auth-form__input" required type="password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} />
</label>
<label className="auth-form__field">
Новый пароль
<input className="auth-form__input" required minLength={15} type="password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} />
</label>
<label className="auth-form__field">
Повторите новый пароль
<input className="auth-form__input" required minLength={15} type="password" value={passwordConfirmation} onChange={(event) => setPasswordConfirmation(event.target.value)} />
</label>
{passwordError ? <p className="page__subtitle page__subtitle--error" role="alert">{passwordError}</p> : null}
{passwordSuccess ? <p className="account__success" role="status">{passwordSuccess}</p> : null}
<button className="btn" disabled={passwordSaving} type="submit">{passwordSaving ? "Сохраняем…" : "Сменить пароль"}</button>
</form>
<section className="auth-form" aria-labelledby="sessions-title">
<h2 className="auth-form__title" id="sessions-title">Активные сессии</h2>
<p className="page__subtitle">После смены пароля все остальные сессии завершаются автоматически.</p>
{sessionsError ? <p className="page__subtitle page__subtitle--error" role="alert">{sessionsError}</p> : null}
{sessionsLoading ? <p>Загружаем сессии</p> : null}
{!sessionsLoading ? (
<ul className="account__sessions">
{sessions.map((session) => (
<li className="account__session" key={session.id}>
<div>
<strong>{session.current ? "Текущая сессия" : "Активная сессия"}</strong>
<p>Последняя активность: {formatDate(session.lastSeenAt)}</p>
<p>Создана: {formatDate(session.createdAt)}</p>
</div>
{!session.current ? <button className="btn btn--secondary" type="button" onClick={() => void handleSessionRevoke(session)}>Завершить</button> : null}
</li>
))}
</ul>
) : null}
{sessions.some((session) => !session.current) ? <button className="btn btn--secondary" type="button" onClick={() => void handleOtherSessionsRevoke()}>Завершить все остальные</button> : null}
</section>
</section>
);
}

View File

@@ -0,0 +1,328 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import {
ApiError,
forgotPassword,
register,
resendVerification,
resetPassword,
verifyEmail,
} from "../api";
import { useAuth } from "../app/auth/AuthContext";
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
return error.details.length > 0 ? error.details.join("; ") : error.message;
}
return fallback;
}
function TurnstileField(props: { onToken(token: string): void }): JSX.Element {
const ref = useRef<HTMLDivElement | null>(null);
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
const { onToken } = props;
useEffect(() => {
if (!siteKey || !ref.current) {
onToken("mock-turnstile-token");
return;
}
const scriptId = "turnstile-script";
if (!document.getElementById(scriptId)) {
const script = document.createElement("script");
script.id = scriptId;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
let widgetId: string | null = null;
const timer = window.setInterval(() => {
if (window.turnstile && ref.current && !widgetId) {
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
});
window.clearInterval(timer);
}
}, 100);
return () => {
window.clearInterval(timer);
if (widgetId && window.turnstile) {
window.turnstile.remove(widgetId);
}
};
}, [onToken, siteKey]);
return <div className="auth-form__captcha" ref={ref} />;
}
export function LoginPage(): JSX.Element {
const { user, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (user?.emailVerifiedAt) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await login(email, password);
const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/";
navigate(from, { replace: true });
} catch (err) {
setError(errorMessage(err, "Не удалось войти."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Вход</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>
Войти
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/register">Регистрация</Link>
<Link className="page-link" to="/forgot-password">Забыли пароль?</Link>
</p>
</section>
);
}
export function RegisterPage(): JSX.Element {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleToken = useCallback((token: string) => setTurnstileToken(token), []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Регистрация</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<TurnstileField onToken={handleToken} />
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={!turnstileToken || isSubmitting}>
{isSubmitting ? "Создаём аккаунт…" : "Создать аккаунт"}
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/login">Уже есть аккаунт</Link>
</p>
</section>
);
}
export function VerifyEmailPage(): JSX.Element {
const { user, refresh } = useAuth();
const [params] = useSearchParams();
const [email, setEmail] = useState(user?.email ?? "");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [isResending, setIsResending] = useState(false);
useEffect(() => {
const token = params.get("token");
if (!token) {
return;
}
let isMounted = true;
setIsVerifying(true);
setError(null);
void (async () => {
try {
await verifyEmail(token);
try {
await refresh();
} catch {
// Подтверждение уже выполнено; обновление сессии не должно скрывать успех.
}
if (isMounted) {
setIsVerified(true);
}
} catch (err) {
if (isMounted) {
setError(errorMessage(err, "Ссылка недействительна."));
}
} finally {
if (isMounted) {
setIsVerifying(false);
}
}
})();
return () => {
isMounted = false;
};
}, [params, refresh]);
const resend = async () => {
setError(null);
setMessage(null);
setIsResending(true);
try {
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить письмо повторно."));
} finally {
setIsResending(false);
}
};
if (isVerified) {
return (
<section className="page page--auth">
<h1 className="page__title">Email подтверждён</h1>
<p className="page__subtitle">Теперь можно пользоваться календарём.</p>
<p className="auth-form__links"><Link className="page-link" to="/">Перейти к календарю</Link></p>
</section>
);
}
return (
<section className="page page--auth">
<h1 className="page__title">Подтверждение email</h1>
<p className="page__subtitle">{isVerifying ? "Проверяем ссылку…" : "Для доступа к календарю подтвердите email по ссылке из письма."}</p>
<form className="auth-form" onSubmit={(e) => { e.preventDefault(); void resend(); }}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isVerifying || isResending}>
{isResending ? "Отправляем…" : "Отправить письмо ещё раз"}
</button>
</form>
</section>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await forgotPassword(email);
setMessage("Если email зарегистрирован, ссылка отправлена.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить ссылку."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Сброс пароля</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Отправляем…" : "Отправить ссылку"}</button>
</form>
</section>
);
}
export function ResetPasswordPage(): JSX.Element {
const [params] = useSearchParams();
const [password, setPassword] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await resetPassword(params.get("token") ?? "", password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Новый пароль</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(event) => setPassword(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Сохраняем…" : "Сохранить пароль"}</button>
</form>
</section>
);
}

View File

@@ -1,11 +1,15 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components";
import {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getRaceCountdownLabel,
getRaceVisual,
getPaceLabel,
isCloseDistance,
parseFinishTimeToSeconds,
@@ -15,6 +19,10 @@ import {
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
type DashboardHeroStyle = CSSProperties & {
"--dashboard-hero-image"?: string;
};
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
@@ -22,11 +30,19 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить данные обзора.";
}
function toCssUrl(value: string): string {
return `url(${JSON.stringify(value)})`;
}
export function DashboardPage(): JSX.Element {
const navigate = useNavigate();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
const [latestDistance, setLatestDistance] = useState<string>("");
const [latestYear, setLatestYear] = useState<string>("");
const [latestPage, setLatestPage] = useState(0);
useEffect(() => {
const ac = new AbortController();
@@ -61,26 +77,11 @@ export function DashboardPage(): JSX.Element {
const dashboardMetrics = useMemo(() => {
const { upcoming, past } = splitRacesByDate(races);
const completed = races.filter((race) => race.status === "completed");
const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null;
let personalRecord: Race | null = null;
let personalRecordSeconds = Number.POSITIVE_INFINITY;
for (const race of completed) {
const finishSeconds = parseFinishTimeToSeconds(race.finishTime);
if (!finishSeconds) {
continue;
}
const candidate = finishSeconds / race.distanceKm;
if (candidate < personalRecordSeconds) {
personalRecordSeconds = candidate;
personalRecord = race;
}
}
const lastPersonalRecord = findLatestPersonalRecord(races);
const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
@@ -89,7 +90,7 @@ export function DashboardPage(): JSX.Element {
return {
nextRace,
lastResult,
personalRecord,
lastPersonalRecord,
seasonTotal: seasonRaces.length,
seasonCompletedCount: seasonCompleted.length,
};
@@ -133,6 +134,7 @@ export function DashboardPage(): JSX.Element {
.filter((race) => race.status === "completed")
.map((race) => ({
id: race.id,
date: race.date,
year: parseRaceDate(race.date).getFullYear(),
title: race.title,
distance: formatDistance(race.distanceKm),
@@ -140,9 +142,29 @@ export function DashboardPage(): JSX.Element {
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
place: race.finishPlace?.trim() ? race.finishPlace : "нет данных",
}))
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
.sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}, [races]);
const latestDistances = useMemo(
() => [...new Set(comparisonRows.map((row) => row.distance))].sort((left, right) => Number(left.split(" ")[0]) - Number(right.split(" ")[0])),
[comparisonRows],
);
const latestYears = useMemo(() => [...new Set(comparisonRows.map((row) => row.year))].sort((a, b) => b - a), [comparisonRows]);
const filteredLatestRows = comparisonRows.filter(
(row) => (!latestDistance || row.distance === latestDistance) && (!latestYear || row.year === Number(latestYear)),
);
const latestPageCount = Math.ceil(filteredLatestRows.length / 5);
const latestRows = filteredLatestRows.slice(latestPage * 5, latestPage * 5 + 5);
const seasonProgress =
dashboardMetrics.seasonTotal > 0
? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100)
: 0;
const dashboardHeroVisual = dashboardMetrics.nextRace ? getRaceVisual(dashboardMetrics.nextRace) : null;
const dashboardHeroStyle: DashboardHeroStyle | undefined = dashboardHeroVisual
? { "--dashboard-hero-image": toCssUrl(dashboardHeroVisual.imageSrc) }
: undefined;
if (isLoading) {
return (
<section className="page page--dashboard" aria-busy="true">
@@ -163,67 +185,128 @@ export function DashboardPage(): JSX.Element {
return (
<section className="page page--dashboard">
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<section
className={`dashboard-hero${dashboardHeroVisual ? " dashboard-hero--with-image" : ""}`}
style={dashboardHeroStyle}
aria-label="Обзор сезона"
>
<div className="dashboard-hero__content">
<p className="dashboard-hero__eyebrow">Календарь сезона</p>
<p className="dashboard-hero__text">
Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям.
</p>
<div className="dashboard-hero__actions">
<Link className="btn btn--primary" to="/races">
Смотреть старты
</Link>
<Link className="btn btn--secondary dashboard-hero__secondary" to="/races/new">
Добавить старт
</Link>
</div>
</div>
<div className="dashboard-hero__panel">
<p className="dashboard-hero__panel-label">Ближайший старт</p>
{dashboardMetrics.nextRace ? (
<Link className="dashboard-hero__race-link" to={`/races/${dashboardMetrics.nextRace.id}`}>
<span className="dashboard-hero__race-title">{dashboardMetrics.nextRace.title}</span>
<span className="dashboard-hero__race-meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
</span>
<span className="dashboard-hero__race-countdown">
{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}
</span>
</Link>
) : (
<p className="dashboard-hero__empty">Запланируйте первый старт сезона.</p>
)}
</div>
</section>
<div className="dashboard-grid" aria-label="Ключевые метрики">
<article className="dashboard-card">
<h2 className="dashboard-card__title">Ближайший старт</h2>
<article
className={`dashboard-card dashboard-card--accent-blue${dashboardMetrics.nextRace ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.nextRace ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.nextRace.id}`}
aria-label={`Ближайший старт: ${dashboardMetrics.nextRace.title}`}
>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
<p className="dashboard-card__meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
{formatRaceDate(dashboardMetrics.nextRace.date)} ·{" "}
{formatDistance(dashboardMetrics.nextRace.distanceKm)}
</p>
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
<>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
</>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Последний результат</h2>
<article
className={`dashboard-card dashboard-card--accent-coral${dashboardMetrics.lastResult ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastResult ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastResult.id}`}
aria-label={`Последний результат: ${dashboardMetrics.lastResult.title}`}
>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Личный рекорд</h2>
{dashboardMetrics.personalRecord ? (
<>
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
</>
) : (
<p className="dashboard-card__empty">Недостаточно данных для личного рекорда.</p>
)}
</article>
<article className="dashboard-card">
{dashboardMetrics.lastPersonalRecord ? (
<article className="dashboard-card dashboard-card--accent-lime dashboard-card--linked">
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
aria-label={`Последний личный рекорд: ${dashboardMetrics.lastPersonalRecord.title}`}
>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__value">
{dashboardMetrics.lastPersonalRecord.finishTime ?? "время не указано"}
</p>
<p className="dashboard-card__meta">
{dashboardMetrics.lastPersonalRecord.title} ·{" "}
{formatDistance(dashboardMetrics.lastPersonalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
</article>
) : null}
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
<h2 className="dashboard-card__title">Сезон</h2>
<p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p>
<p className="dashboard-card__meta">стартов в этом году</p>
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
<div className="dashboard-card__progress" aria-label={`Сезон завершен на ${seasonProgress}%`}>
<span style={{ width: `${seasonProgress}%` }} />
</div>
</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>
@@ -248,26 +331,50 @@ export function DashboardPage(): JSX.Element {
<h2 className="dashboard-section__title">Рекорды по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card">
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<article key={item.distanceKm} className={`dashboard-card${item.bestRace ? " dashboard-card--linked" : ""}`}>
{item.bestRace ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${item.bestRace.id}`}
aria-label={`Рекорд на ${formatDistance(item.distanceKm)}: ${item.bestRace.title}`}
>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__value">{item.bestRace.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">{item.bestRace.title}</p>
<p className="dashboard-card__hint">{formatRaceDate(item.bestRace.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
<>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
</>
)}
</article>
))}
</div>
</section>
<section className="dashboard-section" aria-label="Сравнение завершённых стартов">
<h2 className="dashboard-section__title">Сравнение стартов</h2>
<section className="dashboard-section" aria-label="Последние завершённые старты">
<h2 className="dashboard-section__title">Последние старты</h2>
{comparisonRows.length > 0 ? (
<div className="comparison-table-wrapper">
<>
<div className="dashboard-latest__filters" aria-label="Фильтры последних стартов">
<label className="races-filter__field">
<span className="races-filter__label">Дистанция</span>
<select className="races-filter__select" value={latestDistance} onChange={(event) => { setLatestDistance(event.target.value); setLatestPage(0); }}>
<option value="">Все дистанции</option>
{latestDistances.map((distance) => <option key={distance} value={distance}>{distance}</option>)}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Год участия</span>
<select className="races-filter__select" value={latestYear} onChange={(event) => { setLatestYear(event.target.value); setLatestPage(0); }}>
<option value="">Все годы</option>
{latestYears.map((year) => <option key={year} value={year}>{year}</option>)}
</select>
</label>
</div>
<div className="comparison-table-wrapper">
<table className="comparison-table">
<thead>
<tr>
@@ -280,8 +387,20 @@ export function DashboardPage(): JSX.Element {
</tr>
</thead>
<tbody>
{comparisonRows.map((row) => (
<tr key={row.id}>
{latestRows.map((row) => (
<tr
key={row.id}
className="comparison-table__row"
tabIndex={0}
onClick={() => navigate(`/races/${row.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigate(`/races/${row.id}`);
}
}}
aria-label={`Открыть старт: ${row.title}`}
>
<td>{row.year}</td>
<td>{row.title}</td>
<td>{row.distance}</td>
@@ -292,7 +411,16 @@ export function DashboardPage(): JSX.Element {
))}
</tbody>
</table>
</div>
</div>
{filteredLatestRows.length === 0 ? <p className="dashboard-card__empty">По выбранным фильтрам стартов нет.</p> : null}
{latestPageCount > 1 ? (
<nav className="dashboard-latest__pagination" aria-label="Страницы последних стартов">
<button className="btn btn--secondary" type="button" disabled={latestPage === 0} onClick={() => setLatestPage((page) => page - 1)}>Назад</button>
<span aria-live="polite">Страница {latestPage + 1} из {latestPageCount}</span>
<button className="btn btn--secondary" type="button" disabled={latestPage === latestPageCount - 1} onClick={() => setLatestPage((page) => page + 1)}>Вперёд</button>
</nav>
) : null}
</>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для сравнения.</p>
)}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { ApiError, getPublicRaces } from "../api";
import type { PublicRace } from "../api";
import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel } from "../lib";
export function PublicProfilePage(): JSX.Element {
const { username } = useParams<{ username: string }>();
const [races, setRaces] = useState<PublicRace[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const ac = new AbortController();
if (!username) {
setError("Страница пользователя не найдена.");
setLoading(false);
return () => ac.abort();
}
void getPublicRaces(username, { signal: ac.signal })
.then((items) => {
if (!ac.signal.aborted) {
setRaces(items);
}
})
.catch((cause) => {
if (!ac.signal.aborted) {
setError(cause instanceof ApiError ? cause.message : "Не удалось загрузить страницу пользователя.");
}
})
.finally(() => {
if (!ac.signal.aborted) {
setLoading(false);
}
});
return () => ac.abort();
}, [username]);
return (
<section className="page page--race-day">
<section className="race-day-hero">
<p className="race-day-hero__eyebrow">Публичная страница</p>
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Опубликованные старты пользователя.</p>
</section>
{loading ? <p className="page__subtitle" aria-busy="true">Загружаем</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
{!loading && !error && races.length === 0 ? <p className="page__subtitle">Пока нет опубликованных стартов.</p> : null}
{!loading && !error && races.length > 0 ? (
<ul className="race-day__list">
{races.map((race, index) => (
<li className="race-day__item" key={`${race.date}-${race.title}-${index}`}>
<div className="race-day__body">
<span className="race-day__title">{race.title}</span>
<span className="race-day__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
{race.finishTime ? ` · ${race.finishTime}` : ""}
{race.finishPlace ? ` · ${race.finishPlace}` : ""}
</span>
</div>
</li>
))}
</ul>
) : null}
</section>
);
}

View File

@@ -2,7 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel, sortByDateAsc } from "../lib";
import {
formatDistance,
formatRaceDate,
getRaceStatusClassName,
getRaceStatusLabel,
getRaceVisual,
sortByDateAsc,
} from "../lib";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
@@ -70,24 +77,33 @@ export function RaceDayPage(): JSX.Element {
if (!validYmd) {
return (
<section className="page page--race-day">
<h1 className="page__title">Некорректная дата</h1>
<p className="page__subtitle">
<div className="race-day-hero">
<p className="race-day-hero__eyebrow">Страница дня</p>
<h1 className="page__title">Некорректная дата</h1>
<Link className="page-link" to="/races">
Вернуться к календарю стартов
</Link>
</p>
</div>
</section>
);
}
return (
<section className="page page--race-day">
<p className="page__subtitle">
<section className="race-day-hero" aria-label="Старты дня">
<Link className="page-link" to="/races">
Календарь стартов
</Link>
</p>
<h1 className="page__title">{heading}</h1>
<p className="race-day-hero__eyebrow">Старты дня</p>
<h1 className="page__title">{heading}</h1>
<p className="page__subtitle">
{isLoading
? "Загружаем расписание..."
: races.length > 0
? `Запланировано стартов: ${races.length}`
: "Проверьте расписание или добавьте старт на эту дату."}
</p>
</section>
{errorMessage ? (
<p className="page__subtitle page__subtitle--error" role="alert">
@@ -107,19 +123,40 @@ export function RaceDayPage(): JSX.Element {
{!isLoading && races.length > 0 ? (
<ul className="race-day__list">
{races.map((race) => (
<li key={race.id} className="race-day__item">
<Link className="race-day__link" to={`/races/${race.id}`}>
{race.title}
</Link>
<span className="race-day__meta">
{formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</span>
</li>
))}
{races.map((race) => {
const visual = getRaceVisual(race);
return (
<li key={race.id} className="race-day__item">
<Link className="race-day__link" to={`/races/${race.id}`}>
<img
className={`race-day__image${
visual.imageFit === "contain" ? " race-day__image--contain" : ""
}`}
src={visual.imageSrc}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.classList.remove("race-day__image--contain");
event.currentTarget.src = visual.fallbackSrc;
}}
/>
<span className="race-day__body">
<span className="race-day__kicker">{visual.label}</span>
<span className="race-day__title">{race.title}</span>
<span className="race-day__meta">
{formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</span>
</span>
</Link>
</li>
);
})}
</ul>
) : null}

View File

@@ -7,6 +7,7 @@ import {
getPaceLabel,
getRaceStatusClassName,
getRaceStatusLabel,
getRaceVisual,
raceNeedsResultEntry,
} from "../lib";
import type { Race } from "../api";
@@ -139,18 +140,40 @@ export function RaceDetailsPage(): JSX.Element {
}
const isCompleted = race.status === "completed";
const visual = getRaceVisual(race);
return (
<section className="page page--race-details">
<div className="race-details-header">
<div className="race-details-header__main">
<section className={`race-details-hero race-details-hero--${visual.variant}`} aria-label="Карточка старта">
<img
className={`race-details-hero__image${
visual.imageFit === "contain" ? " race-details-hero__image--contain" : ""
}`}
src={visual.imageSrc}
alt=""
loading="eager"
referrerPolicy="no-referrer"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.classList.remove("race-details-hero__image--contain");
event.currentTarget.src = visual.fallbackSrc;
}}
/>
<div className="race-details-hero__shade" aria-hidden="true" />
<div className="race-details-hero__content">
<Link className="race-details-hero__back" to="/races">
Календарь стартов
</Link>
<p className="race-details-hero__eyebrow">{visual.label}</p>
<h1 className="page__title">{race.title}</h1>
<p className="page__subtitle">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</div>
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
</div>
</section>
{raceNeedsResultEntry(race) ? (
<p className="race-details-past-hint" role="status">

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
import { StartTimeSelects } from "../components/StartTimeSelects";
import type { CreateRacePayload, Race, RaceStatus } from "../api";
import { isRaceDateInPast } from "../lib";
function slugify(text: string): string {
@@ -31,12 +30,18 @@ interface FormData {
distanceKm: string;
status: string;
officialUrl: string;
coverImageUrl: string;
startTime: string;
clusterSchedule: string;
bibPickup: string;
bibNumber: string;
finishTime: string;
finishPlace: string;
finishPlaceTotal: string;
originalFinishPlace: string;
isFinishPlaceEdited: boolean;
finishHours: string;
finishMinutes: string;
finishSeconds: string;
notes: string;
}
@@ -46,12 +51,18 @@ const EMPTY_FORM: FormData = {
distanceKm: "",
status: "planned",
officialUrl: "",
coverImageUrl: "",
startTime: "",
clusterSchedule: "",
bibPickup: "",
bibNumber: "",
finishTime: "",
finishPlace: "",
finishPlaceTotal: "",
originalFinishPlace: "",
isFinishPlaceEdited: false,
finishHours: "",
finishMinutes: "",
finishSeconds: "",
notes: "",
};
@@ -63,21 +74,93 @@ function raceToFormData(race: Race): FormData {
distanceKm: String(race.distanceKm),
status: race.status ?? "",
officialUrl: race.officialUrl ?? "",
coverImageUrl: race.coverImageUrl ?? "",
startTime: race.startTime ?? "",
clusterSchedule: race.clusterSchedule ?? "",
bibPickup: race.bibPickup ?? "",
bibNumber: race.bibNumber ?? "",
finishTime: race.finishTime ?? "",
finishPlace: race.finishPlace ?? "",
...finishTimeToFormData(race.finishTime),
...finishPlaceToFormData(race.finishPlace),
notes: race.notes ?? "",
};
}
function finishTimeToFormData(
value: string | null,
): Pick<FormData, "finishHours" | "finishMinutes" | "finishSeconds"> {
const parts = value?.trim().split(":") ?? [];
if (parts.length === 3) {
return { finishHours: parts[0], finishMinutes: parts[1], finishSeconds: parts[2] };
}
if (parts.length === 2) {
return { finishHours: "", finishMinutes: parts[0], finishSeconds: parts[1] };
}
return { finishHours: "", finishMinutes: "", finishSeconds: parts[0] ?? "" };
}
function finishPlaceToFormData(
value: string | null,
): Pick<FormData, "finishPlace" | "finishPlaceTotal" | "originalFinishPlace" | "isFinishPlaceEdited"> {
const originalFinishPlace = value ?? "";
const [finishPlace = "", finishPlaceTotal = ""] = originalFinishPlace.split("/", 2);
return { finishPlace, finishPlaceTotal, originalFinishPlace, isFinishPlaceEdited: false };
}
function emptyToNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
function parseNonNegativeInteger(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
return Number(trimmed);
}
function getFinishTime(form: FormData): { value: string | null; error: string | null } {
const hasValue = [form.finishHours, form.finishMinutes, form.finishSeconds].some((value) => value.trim() !== "");
if (!hasValue) {
return { value: null, error: null };
}
const hours = form.finishHours.trim() === "" ? null : parseNonNegativeInteger(form.finishHours);
const minutes = form.finishMinutes.trim() === "" ? 0 : parseNonNegativeInteger(form.finishMinutes);
const seconds = form.finishSeconds.trim() === "" ? 0 : parseNonNegativeInteger(form.finishSeconds);
if (hours === null && form.finishHours.trim() !== "") {
return { value: null, error: "Часы должны быть целым числом не меньше 0." };
}
if (minutes === null || seconds === null || minutes > 59 || seconds > 59) {
return { value: null, error: "Минуты и секунды должны быть целыми числами от 0 до 59." };
}
const pad = (value: number) => String(value).padStart(2, "0");
if (hours !== null) {
return { value: `${hours}:${pad(minutes)}:${pad(seconds)}`, error: null };
}
if (form.finishMinutes.trim() !== "") {
return { value: `${pad(minutes)}:${pad(seconds)}`, error: null };
}
return { value: pad(seconds), error: null };
}
function getFinishPlace(form: FormData): { value: string | null; error: string | null } {
if (!form.isFinishPlaceEdited) {
return { value: emptyToNull(form.originalFinishPlace), error: null };
}
if (form.finishPlace.trim() === "" && form.finishPlaceTotal.trim() === "") {
return { value: null, error: null };
}
const place = parseNonNegativeInteger(form.finishPlace);
const total = form.finishPlaceTotal.trim() === "" ? null : parseNonNegativeInteger(form.finishPlaceTotal);
if (place === null || place < 1 || (total !== null && (total < 1 || place > total))) {
return { value: null, error: "Место должно быть не меньше 1 и не больше числа участников." };
}
return { value: total === null ? String(place) : `${place}/${total}`, error: null };
}
function validateForm(form: FormData): string[] {
const errors: string[] = [];
if (!form.date.trim()) {
@@ -90,9 +173,28 @@ function validateForm(form: FormData): string[] {
if (Number.isNaN(km) || km <= 0) {
errors.push("Дистанция должна быть положительным числом.");
}
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
if (finishTime.error) {
errors.push(finishTime.error);
}
if (finishPlace.error) {
errors.push(finishPlace.error);
}
return errors;
}
function isRaceDateTodayOrPast(date: string): boolean {
if (!date.trim()) {
return false;
}
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, "0");
const d = String(today.getDate()).padStart(2, "0");
return isRaceDateInPast(date) || date.slice(0, 10) === `${y}-${m}-${d}`;
}
export function RaceFormPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate();
@@ -151,7 +253,10 @@ export function RaceFormPage(): JSX.Element {
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = event.target;
setForm((prev) => ({ ...prev, [name]: value }));
setForm((prev) => {
const next = { ...prev, [name]: value };
return next;
});
},
[],
);
@@ -170,48 +275,38 @@ export function RaceFormPage(): JSX.Element {
setIsSaving(true);
try {
const statusValue: RaceStatus | null =
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
let statusValue: RaceStatus | null =
form.status === "planned" || form.status === "registered" || form.status === "completed"
? form.status
: null;
if (finishTime.value) {
statusValue = "completed";
}
const payload: Omit<CreateRacePayload, "slug"> = {
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: finishTime.value,
finishPlace: finishPlace.value,
notes: emptyToNull(form.notes),
};
if (isEditMode && raceId) {
const payload: UpdateRacePayload = {
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
await updateRace(raceId, payload);
navigate(`/races/${raceId}`);
} else {
const id = generateId(form.date.trim(), form.title.trim());
const payload: CreateRacePayload = {
id,
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
const created = await createRace(payload);
const slug = generateId(form.date.trim(), form.title.trim());
const created = await createRace({ ...payload, slug });
navigate(`/races/${created.id}`);
}
} catch (error) {
@@ -228,6 +323,7 @@ export function RaceFormPage(): JSX.Element {
);
const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date);
const showResultFields = isRaceDateTodayOrPast(form.date);
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
if (isLoading) {
@@ -285,7 +381,6 @@ export function RaceFormPage(): JSX.Element {
</label>
<label className="race-form__field">
<span className="race-form__label">Дистанция, км *</span>
<input
className="race-form__input"
type="number"
@@ -317,30 +412,42 @@ 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>
<input
className="race-form__input"
type="url"
name="coverImageUrl"
value={form.coverImageUrl}
onChange={handleChange}
placeholder="https://…"
/>
</label>
{hideOrgScheduleFields ? null : (
<div className="race-form__field">
<label className="race-form__field">
<span className="race-form__label">Время старта</span>
<StartTimeSelects
<input
className="race-form__input"
type="time"
step="1"
name="startTime"
value={form.startTime}
onChange={(next) => {
setForm((prev) => ({ ...prev, startTime: next }));
}}
onChange={handleChange}
/>
</div>
</label>
)}
{hideOrgScheduleFields ? null : (
@@ -382,33 +489,43 @@ export function RaceFormPage(): JSX.Element {
</label>
</fieldset>
<fieldset className="race-form__group">
<legend className="race-form__legend">Результаты</legend>
{showResultFields ? (
<fieldset className="race-form__group">
<legend className="race-form__legend">Результаты</legend>
<label className="race-form__field">
<span className="race-form__label">Финишное время</span>
<input
className="race-form__input"
type="text"
name="finishTime"
value={form.finishTime}
onChange={handleChange}
placeholder="1:45:30"
/>
</label>
<div className="race-form__field">
<span className="race-form__label">Финишное время</span>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Часы</span>
<input className="race-form__input" type="number" name="finishHours" value={form.finishHours} onChange={handleChange} min="0" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Минуты</span>
<input className="race-form__input" type="number" name="finishMinutes" value={form.finishMinutes} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Секунды</span>
<input className="race-form__input" type="number" name="finishSeconds" value={form.finishSeconds} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
</div>
</div>
<label className="race-form__field">
<span className="race-form__label">Место на финише</span>
<input
className="race-form__input"
type="text"
name="finishPlace"
value={form.finishPlace}
onChange={handleChange}
placeholder="12/340"
/>
</label>
</fieldset>
<div className="race-form__field">
<span className="race-form__label">Место на финише</span>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Место</span>
<input className="race-form__input" type="number" name="finishPlace" value={form.finishPlace} onChange={(event) => setForm((prev) => ({ ...prev, finishPlace: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Всего участников</span>
<input className="race-form__input" type="number" name="finishPlaceTotal" value={form.finishPlaceTotal} onChange={(event) => setForm((prev) => ({ ...prev, finishPlaceTotal: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
</div>
</div>
</fieldset>
) : null}
<fieldset className="race-form__group">
<legend className="race-form__legend">Дополнительно</legend>

View File

@@ -1,15 +1,19 @@
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 {
formatDistance,
formatRaceDate,
getRaceVisual,
getRaceStatusClassName,
getRaceStatusLabel,
raceNeedsResultEntry,
splitRacesByDate,
parseRaceDate,
sortByDateDesc,
downloadTextFile,
racesToCsv,
racesToIcs,
} from "../lib";
const MONTH_OPTIONS: { value: string; label: string }[] = [
@@ -31,16 +35,14 @@ const MONTH_OPTIONS: { value: string; label: string }[] = [
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(): 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 {
@@ -59,25 +61,53 @@ function readInitialViewMode(): ViewMode {
}
}
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
const { title, races } = props;
function RaceList(props: { title: string; races: Race[]; variant: RaceListTab }): JSX.Element {
const { title, races, variant } = props;
return (
<section className="race-list" aria-label={title}>
<h2 className="race-list__title">{title}</h2>
<section className={`race-list race-list--${variant}`} aria-label={title}>
<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) => {
const needsResult = raceNeedsResultEntry(race);
if (needsResult) {
return (
<li key={race.id} className="race-card race-card--action">
<Link
className="race-card__link-surface"
to={`/races/${race.id}/edit`}
aria-label={`${race.title}, внести результат`}
>
const visual = getRaceVisual(race);
const parsedDate = parseRaceDate(race.date);
const day = parsedDate.toLocaleDateString("ru-RU", { day: "2-digit" });
const month = parsedDate.toLocaleDateString("ru-RU", { month: "short" });
return (
<li key={race.id} className={`race-card race-card--action race-card--poster race-card--${visual.variant}`}>
<Link
className="race-card__link-surface"
to={`/races/${race.id}`}
aria-label={`Старт: ${race.title}`}
>
<div className="race-card__image-wrap">
<img
className={`race-card__image${
visual.imageFit === "contain" ? " race-card__image--contain" : ""
}`}
src={visual.imageSrc}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.classList.remove("race-card__image--contain");
event.currentTarget.src = visual.fallbackSrc;
}}
/>
<span className="race-card__date-badge">
<span>{day}</span>
<span>{month}</span>
</span>
</div>
<div className="race-card__content">
<div className="race-card__main">
<p className="race-card__kicker">{visual.label}</p>
<p className="race-card__title">
<span className="race-card__title-text">{race.title}</span>
</p>
@@ -85,28 +115,14 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</Link>
</li>
);
}
return (
<li key={race.id} className="race-card">
<div className="race-card__main">
<p className="race-card__title">
<Link className="race-card__link" to={`/races/${race.id}`}>
{race.title}
</Link>
</p>
<p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
<div className="race-card__footer">
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
<span className="race-card__cta">Открыть</span>
</div>
</div>
</Link>
</li>
);
})}
@@ -119,12 +135,24 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
}
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<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 [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState<boolean>(() =>
["dateFrom", "dateTo", "distanceMin", "distanceMax", "sort"].some((key) => searchParams.has(key)),
);
const setViewModePersist = useCallback((mode: ViewMode) => {
setViewMode(mode);
@@ -145,28 +173,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 !== "") {
@@ -208,7 +253,67 @@ export function RacesPage(): JSX.Element {
};
}, [listQuery]);
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
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) 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: filteredRaces.filter((race) => race.status !== "completed"),
completed: sortOrder
? filteredRaces.filter((race) => race.status === "completed")
: sortByDateDesc(filteredRaces.filter((race) => race.status === "completed")),
}),
[filteredRaces, sortOrder],
);
const statusMessage = useMemo(() => {
if (errorMessage && !isLoading) {
return errorMessage;
}
if (isLoading) {
return "Загружаем данные...";
}
if (viewMode === "calendar" && monthFilter === "") {
return "Выберите месяц, чтобы увидеть его крупным планом.";
}
return "";
}, [errorMessage, isLoading, monthFilter, viewMode]);
const statusClassName = [
"races-status__message",
!statusMessage ? "races-status__message--empty" : "",
errorMessage && !isLoading ? "races-status__message--error" : "",
]
.filter(Boolean)
.join(" ");
const activeAdvancedFilterCount = [
dateFrom,
dateTo,
distanceMin,
distanceMax,
sortOrder,
].filter(Boolean).length;
if (errorMessage && races.length === 0 && !isLoading) {
return (
@@ -221,89 +326,224 @@ export function RacesPage(): JSX.Element {
return (
<section className="page page--races">
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
<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>
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
<div className="races-view-toggle" role="group" aria-label="Вид отображения">
<button
type="button"
className={`races-view-toggle__btn${viewMode === "list" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewList}
>
Список
</button>
<button
type="button"
className={`races-view-toggle__btn${viewMode === "calendar" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewCalendar}
>
Календарь
</button>
</div>
</div>
{!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)}
>
<span>Дополнительные фильтры{activeAdvancedFilterCount > 0 ? ` · ${activeAdvancedFilterCount} активн.` : ""}</span>
</button>
</div>
</div>
) : null}
</section>
<div className="races-view-toggle" role="group" aria-label="Вид отображения">
<button
type="button"
className={`races-view-toggle__btn${viewMode === "list" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewList}
{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}
role={errorMessage && !isLoading ? "alert" : undefined}
aria-busy={isLoading || undefined}
aria-hidden={!statusMessage || undefined}
>
Список
</button>
<button
type="button"
className={`races-view-toggle__btn${viewMode === "calendar" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewCalendar}
>
Календарь
</button>
</div>
{errorMessage && !isLoading ? (
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}>
{errorMessage}
{statusMessage || "\u00a0"}
</p>
) : null}
<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);
}}
>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions().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>
</div>
{viewMode === "calendar" && monthFilter === "" ? (
<p className="page__subtitle races-cal__filter-hint">Выберите месяц, чтобы увидеть его крупным планом.</p>
) : null}
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем данные...
</p>
) : null}
{viewMode === "list" ? (
<div className="race-lists">
<RaceList title="Будущие" races={upcoming} />
<RaceList title="Прошедшие" races={past} />
</div>
<>
<div className="race-list-tabs" role="tablist" aria-label="Раздел стартов">
<button
type="button"
role="tab"
aria-selected={activeListTab === "upcoming"}
className={`race-list-tabs__btn${activeListTab === "upcoming" ? " race-list-tabs__btn--active" : ""}`}
onClick={() => setActiveListTab("upcoming")}
>
Будущие
<span className="race-list-tabs__count">{upcoming.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeListTab === "completed"}
className={`race-list-tabs__btn${activeListTab === "completed" ? " race-list-tabs__btn--active" : ""}`}
onClick={() => setActiveListTab("completed")}
>
Прошедшие
<span className="race-list-tabs__count">{completed.length}</span>
</button>
</div>
<div className={`race-lists race-lists--mobile-${activeListTab}`}>
<RaceList title="Будущие" races={upcoming} variant="upcoming" />
<RaceList title="Завершенные" races={completed} variant="completed" />
</div>
</>
) : (
<div className="races-cal-wrap">
<RacesCalendar
displayYear={displayYear}
monthFilter={monthFilter}
races={races}
races={filteredRaces}
onMonthFilterChange={setMonthFilter}
/>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,26 @@
:root {
--color-bg: #f3f5f7;
--color-bg: #edf3f6;
--color-bg-deep: #071927;
--color-surface: #ffffff;
--color-text: #13202b;
--color-text-muted: #5d6b77;
--color-accent: #1f7ae0;
--color-border: #dce2e8;
--color-success: #2f9e63;
--color-warning: #c0821f;
--color-error: #cc3a3a;
--color-surface-soft: #f7fafc;
--color-text: #0e1f2d;
--color-text-muted: #647483;
--color-accent: #1168d8;
--color-accent-strong: #0c48a0;
--color-lime: #b9f24a;
--color-coral: #ff6f5e;
--color-violet: #6d5dfc;
--color-border: #d6e1ea;
--color-success: #168657;
--color-warning: #b77716;
--color-error: #c43333;
--font-family-base: "Inter", "Segoe UI", Arial, sans-serif;
--font-size-h1: 2rem;
--font-size-h2: 1.5rem;
--font-size-h1: 2.35rem;
--font-size-h2: 1.35rem;
--font-size-body: 1rem;
--font-size-caption: 0.875rem;
--line-height-base: 1.5;
--line-height-base: 1.45;
--space-1: 0.25rem;
--space-2: 0.5rem;
@@ -25,8 +31,10 @@
--space-8: 2rem;
--radius-sm: 0.375rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-md: 0.625rem;
--radius-lg: 0.75rem;
--shadow-card-lift: 0 6px 20px rgba(19, 32, 43, 0.12);
--shadow-card: 0 10px 30px rgba(14, 31, 45, 0.08);
--shadow-card-lift: 0 16px 34px rgba(14, 31, 45, 0.16);
--shadow-hero: 0 22px 60px rgba(7, 25, 39, 0.24);
}

View File

@@ -1 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_TURNSTILE_SITE_KEY?: string;
}
interface Window {
turnstile?: {
render(container: HTMLElement, options: { sitekey: string; callback(token: string): void; "expired-callback"(): void }): string;
remove(widgetId: string): void;
};
}

View File

@@ -1 +0,0 @@
{"root":["./src/main.tsx","./src/api/index.ts","./src/app/router.tsx","./src/app/layouts/applayout.tsx","./src/components/index.ts","./src/features/index.ts","./src/lib/index.ts","./src/pages/dashboardpage.tsx","./src/pages/racespage.tsx"],"version":"5.9.3"}

View File

@@ -2,12 +2,14 @@
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020"],
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"strict": true
"strict": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -1,5 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});