Compare commits

...

8 Commits

50 changed files with 2178 additions and 1701 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

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

188
CHANGELOG.md Normal file
View File

@@ -0,0 +1,188 @@
# Changelog
Все заметки о выпусках Calendar Run. Backend и frontend имеют независимые версии.
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
## В работе
### Backend 1.5.0
#### Added
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
### Frontend 0.8.0
#### Added
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
## Backend
### [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.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.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

@@ -6,7 +6,7 @@
Один шаблон для локальной разработки и для 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 — локально
@@ -17,8 +17,6 @@
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
@@ -46,4 +44,3 @@ docker compose -f docker-compose.stack.yml exec runners-calendar-backend node di
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
- [Эксплуатация backend](docs/backend.md)

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.4.1",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.4.1",
"version": "1.5.1",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
@@ -28,7 +28,6 @@
"@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,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.4.1",
"version": "1.5.1",
"private": true,
"scripts": {
"build": "tsc",
@@ -8,7 +8,7 @@
"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"
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
},
"dependencies": {
"argon2": "^0.44.0",
@@ -31,7 +31,6 @@
"@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

@@ -7,6 +7,7 @@ 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";
@@ -54,6 +55,7 @@ export function createApp(): express.Express {
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

@@ -10,6 +10,7 @@ declare global {
id: string;
email: string;
emailVerifiedAt: string | null;
isProfilePublic: boolean;
};
csrfTokenHash: string;
sessionToken: string;

View File

@@ -18,6 +18,7 @@ export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
isProfilePublic: boolean;
}
interface UserRow {
@@ -25,6 +26,7 @@ interface UserRow {
email: string;
password_hash: string;
email_verified_at: Date | string | null;
is_profile_public: boolean;
}
interface SessionRow {
@@ -35,6 +37,23 @@ interface SessionRow {
expires_at: Date | string;
email: string;
email_verified_at: Date | string | null;
is_profile_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 {
@@ -49,6 +68,7 @@ function userFromRow(row: UserRow): AuthUser {
id: row.id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public,
};
}
@@ -65,7 +85,7 @@ function isUniqueViolation(error: unknown): boolean {
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 FROM users WHERE LOWER(BTRIM(email)) = $1",
"SELECT id, email, password_hash, email_verified_at, is_profile_public FROM users WHERE LOWER(BTRIM(email)) = $1",
[normalized],
);
return rows[0] ?? null;
@@ -126,7 +146,7 @@ export async function registerUser(email: string, password: string): Promise<voi
({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at`,
RETURNING id, email, password_hash, email_verified_at, is_profile_public`,
[normalized, passwordHash],
));
} catch (error) {
@@ -164,7 +184,8 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
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 email_verified_at FROM users WHERE id = $1) AS email_verified_at,
(SELECT is_profile_public FROM users WHERE id = $1) AS is_profile_public`,
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
);
const row = rows[0];
@@ -176,6 +197,7 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public,
},
};
}
@@ -198,6 +220,84 @@ export async function revokeSession(sessionToken: string): Promise<void> {
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(
@@ -212,7 +312,7 @@ export async function rotateCsrf(sessionToken: string): Promise<string | 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
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at, u.is_profile_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()`,
@@ -231,11 +331,19 @@ export async function getSession(sessionToken: string): Promise<{ user: AuthUser
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public,
},
csrfTokenHash: row.csrf_token_hash,
};
}
export async function setProfileVisibility(userId: string, isProfilePublic: boolean): Promise<void> {
await pool.query(
"UPDATE users SET is_profile_public = $2, updated_at = NOW() WHERE id = $1",
[userId, isProfilePublic],
);
}
export function csrfMatches(hash: string, token: string): boolean {
return timingSafeEqualHex(hash, sha256Hex(token));
}

View File

@@ -16,13 +16,6 @@ function optionalEnv(name: string): string | null {
return value ? value : null;
}
function requireEnvUnlessMock(name: string, fallback: string): string {
if (useMockDb) {
return process.env[name]?.trim() || fallback;
}
return requireEnv(name);
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value == null || value.trim() === "") {
return fallback;
@@ -30,50 +23,33 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean {
return value === "1" || value.toLowerCase() === "true";
}
const useMockDb =
process.env.CALENDAR_RUN_MOCK_DB === "1" ||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
export function resolveTurnstileBypassToken(params: {
rawBypassToken?: string;
securityProfile: string;
useMockDb: boolean;
}): string {
const raw = params.rawBypassToken?.trim() ?? "";
if (raw && params.securityProfile === "production" && !params.useMockDb) {
if (raw && params.securityProfile === "production") {
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
}
if (raw) {
return raw;
}
return params.useMockDb ? "mock-turnstile-token" : "";
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: requireEnvUnlessMock("SESSION_SECRET", "mock-session-secret-change-me"),
secret: requireEnv("SESSION_SECRET"),
cookieName:
process.env.SESSION_COOKIE_NAME?.trim() ||
(process.env.NODE_ENV === "production" ? "__Host-sid" : "sid"),
@@ -89,11 +65,10 @@ export const config = {
from: process.env.SMTP_FROM?.trim() || "Calendar Run <no-reply@example.com>",
},
turnstile: {
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || (useMockDb ? "mock-turnstile-secret" : ""),
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || "",
bypassToken: resolveTurnstileBypassToken({
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
securityProfile,
useMockDb,
}),
},
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),

View File

@@ -1,7 +1,5 @@
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
import crypto from "crypto";
import { Pool, PoolConfig } from "pg";
import { config } from "./config";
import type { RaceRow } from "./mappers/race";
const poolConfig: PoolConfig = {
host: config.db.host,
@@ -14,509 +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] ?? ""),
slug: String(params[0] ?? ""),
owner_user_id: null,
race_date: "",
title: "",
distance_km: "0",
status: null,
official_url: null,
cover_image_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 ?? crypto.randomUUID()),
slug: String(row.slug ?? row.id ?? ""),
owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null,
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,
cover_image_url: row.cover_image_url != null ? String(row.cover_image_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 users = new Map<string, any>();
const sessions = new Map<string, any>();
const verificationTokens = new Map<string, any>();
const resetTokens = new Map<string, any>();
const appSettings = new Map<string, string>();
const result = <T extends QueryResultRow>(rows: T[], command = "SELECT"): QueryResult<T> =>
({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult<T>;
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 === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) {
return result<T>([]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) {
return result([{ count: String(users.size) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
const email = String(p[0] ?? "");
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
return user ? result([user as T]) : emptyResult();
}
if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) {
const email = String(p[0] ?? "");
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
return user ? result([user as T]) : emptyResult();
}
if (sql.includes("INSERT INTO users")) {
const email = String(p[0] ?? "").trim().toLowerCase();
const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
if (existing) {
const err = new Error("duplicate key") as Error & { code?: string };
err.code = "23505";
throw err;
}
const id = crypto.randomUUID();
const row = {
id,
email: String(p[0] ?? ""),
password_hash: String(p[1] ?? ""),
email_verified_at: null,
created_at: new Date(),
updated_at: null,
};
users.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("INSERT INTO email_verification_tokens")) {
const id = crypto.randomUUID();
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
expires_at: p[2] ?? new Date(),
used_at: null,
created_at: new Date(),
};
verificationTokens.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = verificationTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of verificationTokens.values()) {
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(verificationTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now();
return result(
Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
);
}
if (sql.includes("INSERT INTO password_reset_tokens")) {
const id = crypto.randomUUID();
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
expires_at: p[2] ?? new Date(),
used_at: null,
created_at: new Date(),
};
resetTokens.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = resetTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of resetTokens.values()) {
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(resetTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now();
return result(
Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
);
}
if (sql.includes("UPDATE users SET email_verified_at")) {
const user = users.get(String(p[0] ?? ""));
if (user) {
user.email_verified_at = user.email_verified_at ?? new Date();
user.updated_at = new Date();
}
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE users SET password_hash")) {
const user = users.get(String(p[0] ?? ""));
if (user) {
user.password_hash = String(p[1] ?? "");
user.updated_at = new Date();
}
return result<T>([], "UPDATE");
}
if (sql.includes("INSERT INTO sessions")) {
const id = crypto.randomUUID();
const user = users.get(String(p[0] ?? ""));
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
csrf_token_hash: String(p[2] ?? ""),
expires_at: p[3] ?? new Date(),
email: user?.email ?? "",
email_verified_at: user?.email_verified_at ?? null,
revoked_at: null,
created_at: new Date(),
last_seen_at: new Date(),
};
sessions.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("FROM sessions s JOIN users u")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
const row = Array.from(sessions.values()).find(
(item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now,
);
if (!row) {
return emptyResult();
}
const user = users.get(row.user_id);
return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]);
}
if (sql.includes("UPDATE sessions SET csrf_token_hash")) {
const tokenHash = String(p[0] ?? "");
const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at);
if (row) {
row.csrf_token_hash = String(p[1] ?? "");
row.last_seen_at = new Date();
}
return result<T>(row ? ([{} as unknown as T]) : [], "UPDATE");
}
if (sql.includes("UPDATE sessions SET last_seen_at")) {
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) {
const tokenHash = String(p[0] ?? "");
for (const row of sessions.values()) {
if (row.token_hash === tokenHash) {
row.revoked_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
for (const row of sessions.values()) {
if (row.user_id === userId && !row.revoked_at) {
row.revoked_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of sessions.entries()) {
const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null;
const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000;
if (new Date(row.expires_at).getTime() <= now || staleRevoked) {
sessions.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of verificationTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
verificationTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of resetTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
resetTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("SELECT value FROM app_settings")) {
const value = appSettings.get("orphan_races_claimed_by_user_id");
return value ? result([{ value } as unknown as T]) : emptyResult();
}
if (sql.includes("INSERT INTO app_settings")) {
appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? ""));
return result<T>([], "INSERT");
}
if (sql.includes("UPDATE races SET owner_user_id")) {
const userId = String(p[0] ?? "");
for (const race of store.values()) {
if (!race.owner_user_id) {
race.owner_user_id = userId;
race.updated_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
const row = mockRowFromInsert(text, p);
const conflict = Array.from(store.values()).find(
(item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug,
);
if (conflict) {
const err = new Error("duplicate key") as Error & { code?: string };
err.code = "23505";
throw err;
}
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 ownerId = p[1] != null ? String(p[1]) : null;
const existing = store.get(id);
const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId));
if (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 - 2] ?? p[p.length - 1] ?? "");
const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null;
const existing = store.get(id);
if (!existing || (ownerId && existing.owner_user_id !== ownerId)) {
return emptyResult();
}
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
const updated = { ...existing, updated_at: new Date() };
const setColumns =
setMatch?.[1]
.split(",")
.map((part) => part.trim())
.filter((part) => !part.startsWith("updated_at"))
.map((part) => part.split("=")[0]?.trim())
.filter((col): col is string => Boolean(col)) ?? [];
setColumns.forEach((col, index) => {
(updated as unknown as Record<string, unknown>)[col] = p[index] ?? null;
});
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 ownerId = p[1] != null ? String(p[1]) : null;
const row = store.get(id);
return row && (!ownerId || row.owner_user_id === ownerId)
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
: emptyResult();
}
if (sql.includes("SELECT * FROM races")) {
const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value));
const rows = ownerParam
? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam)
: 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 () => {
return {
query: mockQuery,
release() {},
};
},
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

@@ -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

@@ -1,3 +1,6 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const IMAGE_META_KEYS = new Set([
"og:image",
"og:image:url",
@@ -6,6 +9,8 @@ const IMAGE_META_KEYS = new Set([
]);
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");
@@ -21,6 +26,75 @@ function toHttpUrl(value: string, baseUrl: string): string | 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();
@@ -84,17 +158,30 @@ export async function extractRaceCoverImage(officialUrl: string): Promise<string
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(normalizedUrl, {
redirect: "follow",
signal: controller.signal,
});
let pageUrl = normalizedUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!(await isSafePublicHttpUrl(pageUrl))) {
return null;
}
if (!response.ok) {
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);
}
const html = await response.text();
return extractRaceCoverImageFromHtml(html, response.url || normalizedUrl);
return null;
} catch {
return null;
} finally {

View File

@@ -2,16 +2,21 @@ 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,
setProfileVisibility,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, setSessionCookie } from "../authMiddleware";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
import { isValidPassword, normalizeEmail } from "../security";
import { verifyTurnstileToken } from "../turnstile";
@@ -37,6 +42,13 @@ const emailAddressLimiter = rateLimit({
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(),
@@ -62,6 +74,14 @@ const resetSchema = z.object({
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 profileSchema = z.object({ isProfilePublic: z.boolean() }).strict();
function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
}
@@ -129,6 +149,76 @@ router.get("/auth/me", async (req: Request, res: Response, next) => {
}
});
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 setProfileVisibility(req.auth!.user.id, parsed.data.isProfilePublic);
res.json({ isProfilePublic: parsed.data.isProfilePublic });
} catch (error) {
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) {

View File

@@ -1,4 +1,5 @@
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";
@@ -21,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,
@@ -97,10 +179,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 AND owner_user_id = $2",
[req.params.id, req.auth!.user.id],
[id, req.auth!.user.id],
);
if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
@@ -116,31 +202,24 @@ router.get("/races/:id", async (req: Request, res: Response) => {
/* ─── POST /races ─────────────────────────────────────────── */
router.post("/races", async (req: Request, res: Response) => {
const body = req.body;
const slug = typeof body.slug === "string" && body.slug.trim() ? body.slug.trim() : body.id;
if (!slug || !body.date || !body.title || body.distanceKm == null) {
validationError(res, ["Fields slug, date, title, distanceKm are required"]);
const parsed = createRaceSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res, schemaDetails(parsed.error));
return;
}
const payload = { ...body, slug };
const payload = { ...parsed.data };
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== "";
const officialUrl = payload.officialUrl;
const hasOfficialUrl = typeof officialUrl === "string" && officialUrl.trim() !== "";
if (!hasManualCover && hasOfficialUrl) {
payload.coverImageUrl = await extractRaceCoverImage(payload.officialUrl);
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
}
const { columns, values } = bodyToColumns(payload);
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
columns.unshift("owner_user_id");
values.unshift(req.auth!.user.id);
if (!columns.includes("slug")) {
columns.push("slug");
values.push(slug);
}
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -163,16 +242,20 @@ 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 { columns, values } = bodyToColumns(parsed.data as Record<string, unknown>);
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(req.params.id);
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 *`;
@@ -192,10 +275,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 AND owner_user_id = $2",
[req.params.id, req.auth!.user.id],
[id, req.auth!.user.id],
);
if (rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });

View File

@@ -0,0 +1,53 @@
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;
}
function raceDate(value: string | Date): string {
return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10);
}
router.get("/users/:id/races", async (req: Request, res: Response) => {
const parsed = z.string().uuid().safeParse(req.params.id);
if (!parsed.success) {
res.status(400).json({ error: "validation_error", details: ["id: Must be a UUID"] });
return;
}
try {
const profile = await pool.query(
"SELECT id FROM users WHERE id = $1 AND is_profile_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
FROM races WHERE owner_user_id = $1 ORDER BY race_date ASC`,
[parsed.data],
);
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,
})));
} catch (error) {
console.error("[GET /users/:id/races]", error);
res.status(503).json({ error: "database_unavailable" });
}
});
export default router;

View File

@@ -1,5 +1,7 @@
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 { buildHelmetOptions, createApp } from "../src/app";
import {
@@ -11,12 +13,14 @@ import {
} from "../src/authService";
import { resolveTurnstileBypassToken } from "../src/config";
import { pool } from "../src/db";
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
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`);
@@ -60,6 +64,12 @@ async function createUser(email: string, password: string, verified: boolean) {
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`,
@@ -81,7 +91,7 @@ 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");
@@ -93,7 +103,6 @@ test("production config rejects Turnstile bypass token", () => {
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
useMockDb: false,
}),
/TURNSTILE_BYPASS_TOKEN/,
);
@@ -101,18 +110,9 @@ test("production config rejects Turnstile bypass token", () => {
resolveTurnstileBypassToken({
rawBypassToken: "dev-bypass",
securityProfile: "development",
useMockDb: false,
}),
"dev-bypass",
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "mock-bypass",
securityProfile: "production",
useMockDb: true,
}),
"mock-bypass",
);
});
test("production CSP allows Turnstile without unsafe script directives", () => {
@@ -161,10 +161,10 @@ test("GET /api/races accepts year and month", async () => {
assert.ok(Array.isArray(res.body));
});
test("GET /api/races/:id returns not_found", async () => {
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(404);
assert.equal(res.body.error, "not_found");
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));
});
@@ -173,6 +173,48 @@ test("GET /api/races requires authentication", async () => {
assert.equal(res.body.error, "unauthorized");
});
test("public profile exposes only its owner's calendar after they enable it", async () => {
const { agent } = await authAgent();
const user = await agent.get("/api/auth/me").expect(200);
const userId = user.body.user.id as string;
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-08-01-public-race",
date: "2026-08-01",
title: "Public Race",
distanceKm: 10,
notes: "Private note",
bibNumber: "123",
finishTime: "00:40:00",
})
.expect(201);
await request(app).get(`/api/users/${userId}/races`).expect(404);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isProfilePublic: true })
.expect(200);
const publicRaces = await request(app).get(`/api/users/${userId}/races`).expect(200);
assert.deepEqual(publicRaces.body, [{
date: "2026-08-01",
title: "Public Race",
distanceKm: 10,
status: null,
coverImageUrl: null,
}]);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isProfilePublic: false })
.expect(200);
await request(app).get(`/api/users/${userId}/races`).expect(404);
});
test("login uses generic response for missing user and wrong password", async () => {
const password = "correct horse battery staple";
await createVerifiedUser("generic@example.com", password);
@@ -189,6 +231,76 @@ test("login uses generic response for missing user and wrong password", async ()
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
@@ -327,6 +439,44 @@ test("POST /api/races stores manual coverImageUrl", async () => {
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
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 () =>
@@ -381,6 +531,38 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
}
});
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
@@ -403,3 +585,49 @@ test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
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");

View File

@@ -1,240 +1,167 @@
# 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,
"coverImageUrl": 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); без результатов возвращается
`[]`.
### `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",
"coverImageUrl": "https://example.com/cover.jpg",
"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`, `coverImageUrl`, `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:** пустое тело.
## Модель `Race`
**Ответ 404:**
| Поле | Тип | 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 |
```json
{ "error": "not_found", "details": ["Race not found"] }
```
## Ошибки
## 4. Модель `Race` (camelCase)
| Статус | Тело | Когда |
|---:|---|---|
| 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` | База недоступна |
| Поле | Тип | 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 организатора |
| `coverImageUrl` | string \| null | нет | да | URL обложки забега. При `POST` может быть найден автоматически по `officialUrl`, если не передан вручную |
| `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) |
Некорректный JSON также отвечает `400 validation_error`.
## 5. Фильтрация списка (`GET /api/races`)
## Seed
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
- **`month`** — целое число 112, фильтрует по `EXTRACT(MONTH FROM race_date)`.
- Параметры можно комбинировать (`?year=2026&month=5`) или указывать по одному.
- Без параметров — все забеги без ограничения (пагинация пока не реализована).
- Если по фильтру нет результатов — пустой массив `[]`, статус `200`.
## 6. Идемпотентность 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

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

@@ -0,0 +1,132 @@
# Backlog Calendar Run
**Срез репозитория: 12 июля 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей.
## Текущая база
| Контур | Версия | Уже работает |
|---|---:|---|
| Backend | `1.5.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
| Frontend | `0.8.0` | Защищённые маршруты и auth-экраны, экран аккаунта, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и интерактивным графиком, версия 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.5.0` | `0.8.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
| P2 | `1.6.0` | `0.9.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
| P3 | `1.7.0` | `1.0.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.5.0 / FE 0.8.0
### Backend 1.5.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
| BE-150-03 | P1 | Обратносуместимая фильтрация стартов | Добавить документированные фильтры `status`, диапазон дат и диапазон дистанции, не меняя текущий ответ-массив `GET /races`. Валидация параметров и покрытие индексами — по фактическому query plan. |
### Frontend 0.8.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. |
| FE-080-02 | P1 | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции синхронизированы с URL; поиск по названию работает без новой зависимости. Годы в селекторе строятся из данных, а не ограничены окном «-2/+4». |
| FE-080-03 | P1 | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере. Сервер не нужен: данные уже принадлежат пользователю и загружены в UI. |
| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. |
| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. |
| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. |
## P2 — BE 1.6.0 / FE 0.9.0
### Backend 1.6.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 0.9.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
## P3 — BE 1.7.0 / FE 1.0.0
### Backend 1.7.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.0.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 и ввод результата.

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
"version": "0.7.0",
"version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.7.0",
"version": "0.8.1",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

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

View File

@@ -1,5 +1,5 @@
import { requestJson, setCsrfToken } from "./http";
import type { AuthUser } from "./types";
import type { AuthSession, AuthUser } from "./types";
interface AuthResponse {
user: AuthUser;
@@ -67,3 +67,30 @@ export async function resetPassword(token: string, password: string): Promise<vo
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 updateProfileVisibility(isProfilePublic: boolean): Promise<void> {
await requestJson<void>("/auth/profile", {
method: "PATCH",
body: JSON.stringify({ isProfilePublic }),
});
}

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,15 +1,21 @@
export type { AuthUser, 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,
updateProfileVisibility,
verifyEmail,
} from "./auth";

View File

@@ -48,4 +48,21 @@ export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
isProfilePublic: boolean;
}
export interface PublicRace {
date: string;
title: string;
distanceKm: number;
status: RaceStatus | null;
coverImageUrl: string | null;
}
export interface AuthSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}

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

@@ -0,0 +1,22 @@
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")
);
}
export async function getPublicRaces(userId: string, init?: RequestInit): Promise<PublicRace[]> {
const response = await requestJson<unknown[]>(`/users/${userId}/races`, init);
if (!Array.isArray(response) || !response.every(isPublicRace)) {
throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." });
}
return response;
}

View File

@@ -38,9 +38,19 @@ export function AppLayout(): JSX.Element {
+ Добавить
</NavLink>
{user ? (
<button className="app-shell__link app-shell__link--button" type="button" onClick={() => void logout()}>
Выйти
</button>
<>
<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"

View File

@@ -5,6 +5,8 @@ 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";
@@ -18,10 +20,12 @@ export const appRouter = createBrowserRouter([
{ path: "verify-email", element: <VerifyEmailPage /> },
{ path: "forgot-password", element: <ForgotPasswordPage /> },
{ path: "reset-password", element: <ResetPasswordPage /> },
{ path: "users/:userId", 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 /> },

View File

@@ -1,197 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { buildMonthCells, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
const MONTH_NAMES_RU = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
interface DatePickerFieldProps {
value: string;
name: string;
required?: boolean;
onChange: (value: string) => void;
}
function parseYmd(value: string): { year: number; monthIndex: number; day: number } | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const year = Number(value.slice(0, 4));
const monthIndex = Number(value.slice(5, 7)) - 1;
const day = Number(value.slice(8, 10));
if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) {
return null;
}
if (monthIndex < 0 || monthIndex > 11) {
return null;
}
return { year, monthIndex, day };
}
function getInitialVisibleMonth(value: string): { year: number; monthIndex: number } {
const parsed = parseYmd(value);
if (parsed) {
return { year: parsed.year, monthIndex: parsed.monthIndex };
}
const now = new Date();
return { year: now.getFullYear(), monthIndex: now.getMonth() };
}
export function DatePickerField(props: DatePickerFieldProps): JSX.Element {
const { value, name, required, onChange } = props;
const [isOpen, setIsOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState(() => getInitialVisibleMonth(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const parsed = parseYmd(value);
if (!parsed) {
return;
}
setVisibleMonth({ year: parsed.year, monthIndex: parsed.monthIndex });
}, [value]);
useEffect(() => {
if (!isOpen) {
return;
}
function handlePointerDown(event: MouseEvent): void {
if (rootRef.current?.contains(event.target as Node)) {
return;
}
setIsOpen(false);
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const selected = parseYmd(value);
const todayYmd = toYmd(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
const cells = useMemo(
() => buildMonthCells(visibleMonth.year, visibleMonth.monthIndex),
[visibleMonth],
);
const monthTitle = `${MONTH_NAMES_RU[visibleMonth.monthIndex]} ${visibleMonth.year}`;
function shiftMonth(delta: number): void {
setVisibleMonth((prev) => {
const next = new Date(Date.UTC(prev.year, prev.monthIndex + delta, 1));
return { year: next.getUTCFullYear(), monthIndex: next.getUTCMonth() };
});
}
return (
<div className="date-picker" ref={rootRef}>
<div className="date-picker__control">
<input
className="race-form__input date-picker__input"
type="text"
inputMode="numeric"
name={name}
value={value}
onChange={(event) => {
onChange(event.target.value);
}}
onFocus={() => setIsOpen(true)}
placeholder="2026-05-03"
autoComplete="off"
required={required}
/>
<button
className="date-picker__toggle"
type="button"
aria-label="Открыть календарь"
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
>
</button>
</div>
{isOpen ? (
<div className="date-picker__popover" role="dialog" aria-label="Выбор даты">
<div className="date-picker__header">
<button
className="date-picker__nav"
type="button"
aria-label="Предыдущий месяц"
onClick={() => shiftMonth(-1)}
>
</button>
<p className="date-picker__title">{monthTitle}</p>
<button
className="date-picker__nav"
type="button"
aria-label="Следующий месяц"
onClick={() => shiftMonth(1)}
>
</button>
</div>
<div className="date-picker__weekdays" aria-hidden>
{WEEKDAY_LABELS_SHORT_RU.map((weekday) => (
<span key={weekday} className="date-picker__weekday">
{weekday}
</span>
))}
</div>
<div className="date-picker__cells">
{cells.map((day, idx) => {
if (day === null) {
return <span key={`empty-${idx}`} className="date-picker__cell date-picker__cell--empty" />;
}
const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day);
const isSelected =
selected?.year === visibleMonth.year &&
selected.monthIndex === visibleMonth.monthIndex &&
selected.day === day;
return (
<button
key={ymd}
className={`date-picker__day${isSelected ? " date-picker__day--selected" : ""}${ymd === todayYmd ? " date-picker__day--today" : ""}`}
type="button"
onClick={() => {
onChange(ymd);
setIsOpen(false);
}}
>
{day}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -1,92 +1,107 @@
import { useState } 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;
function formatAxisTime(minutes: number): string {
const totalSeconds = Math.round(minutes * 60);
const hours = Math.floor(totalSeconds / 3600);
const rest = totalSeconds % 3600;
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
}
function formatShortDate(date: string): string {
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
const navigate = useNavigate();
const [activeId, setActiveId] = useState<string | null>(null);
const series = races
.filter(
(race) =>
race.status === "completed" &&
isCloseDistance(race.distanceKm, distanceKm) &&
parseFinishTimeToSeconds(race.finishTime) != null,
)
.sort(
(a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime(),
)
.map((race) => {
const seconds = parseFinishTimeToSeconds(race.finishTime)!;
return { race, minutes: seconds / 60 };
});
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
.map((race) => ({ race, minutes: parseFinishTimeToSeconds(race.finishTime)! / 60 }));
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 values = series.map((item) => item.minutes);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const w = 100;
const h = 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]!;
const best = series.reduce((currentBest, item) => (item.minutes < currentBest.minutes ? item : currentBest), series[0]!);
const dotPoints = series.map((s, i) => {
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const norm = (maxM - s.minutes) / range;
const y = pad + (1 - norm) * innerH;
return { x, y, id: s.race.id };
const h = 58;
const margin = { top: 6, right: 4, bottom: 14, left: 17 };
const innerW = w - margin.left - margin.right;
const innerH = h - margin.top - margin.bottom;
const pointAt = (item: (typeof series)[number], index: number) => ({
x: margin.left + (index / (series.length - 1)) * innerW,
y: margin.top + ((max - item.minutes) / range) * innerH,
});
const points = series.map(pointAt);
const line = points.map(({ x, y }) => `${x},${y}`).join(" ");
const yTicks = [max, (max + min) / 2, min];
const labelStep = Math.max(1, Math.ceil(series.length / 6));
const active = series.find((item) => item.race.id === activeId) ?? null;
return (
<div className="pace-chart">
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
<line className="pace-chart__grid-line" x1={pad} y1={pad} x2={w - pad} y2={pad} />
<line className="pace-chart__grid-line" x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} />
<polyline className="pace-chart__line" fill="none" points={points} />
{dotPoints.map((point, index) => (
<circle
key={point.id}
className={index === dotPoints.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"}
cx={point.x}
cy={point.y}
r="1.6"
/>
))}
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="group" aria-label="Динамика времени на дистанции. Нажмите точку, чтобы открыть старт.">
<text className="pace-chart__axis-title" x="2" y={h / 2} transform={`rotate(-90 2 ${h / 2})`}>Время</text>
{yTicks.map((tick) => {
const y = margin.top + ((max - tick) / range) * innerH;
return (
<g key={tick}>
<line className="pace-chart__grid-line" x1={margin.left} y1={y} x2={w - margin.right} y2={y} />
<text className="pace-chart__axis-label" x={margin.left - 2} y={y + 1} textAnchor="end">{formatAxisTime(tick)}</text>
</g>
);
})}
<line className="pace-chart__axis" x1={margin.left} y1={margin.top} x2={margin.left} y2={h - margin.bottom} />
<line className="pace-chart__axis" x1={margin.left} y1={h - margin.bottom} x2={w - margin.right} y2={h - margin.bottom} />
<polyline className="pace-chart__line" fill="none" points={line} />
{series.map((item, index) => {
const point = points[index]!;
const isLabeled = index % labelStep === 0 || index === series.length - 1;
return (
<g
key={item.race.id}
className="pace-chart__point"
tabIndex={0}
role="button"
aria-label={`Открыть ${item.race.title}: ${item.race.finishTime}, ${getPaceLabel(item.race.finishTime, item.race.distanceKm)}`}
onMouseEnter={() => setActiveId(item.race.id)}
onMouseLeave={() => setActiveId(null)}
onFocus={() => setActiveId(item.race.id)}
onBlur={() => setActiveId(null)}
onClick={() => navigate(`/races/${item.race.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigate(`/races/${item.race.id}`);
}
}}
>
<circle className={index === series.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"} cx={point.x} cy={point.y} r="1.05" />
{isLabeled ? <text className="pace-chart__point-label" x={point.x} y={h - margin.bottom + 5} textAnchor="middle">{formatShortDate(item.race.date)}</text> : null}
</g>
);
})}
<text className="pace-chart__axis-title" x={w / 2} y={h - 1} textAnchor="middle">Дата старта</text>
</svg>
<div className="pace-chart__stats">
<p className="pace-chart__caption">
Последний: {formatRaceDate(last.race.date)} · {last.race.finishTime} · {last.minutes.toFixed(1)} мин
</p>
<p className="pace-chart__caption pace-chart__caption--best">
Лучший: {formatRaceDate(best.race.date)} · {best.race.finishTime} · {best.minutes.toFixed(1)} мин
</p>
</div>
{active ? (
<div className="pace-chart__tooltip" role="status">
<strong>{active.race.title}</strong>
<span>{formatRaceDate(active.race.date)} · {active.race.finishTime} · {getPaceLabel(active.race.finishTime, active.race.distanceKm)}</span>
</div>
) : <p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>}
</div>
);
}

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,4 +1,2 @@
export { DatePickerField } from "./DatePickerField";
export { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";
export { StartTimeSelects } from "./StartTimeSelects";

View File

@@ -1,6 +1,7 @@
export {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getPaceLabel,
getRaceCountdownLabel,
getRaceStatusClassName,

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

@@ -11,13 +11,6 @@ interface RaceVisual {
label: string;
}
interface OfficialRaceVisual {
keywords: string[];
imageSrc: string;
imageFit?: RaceVisualFit;
label: string;
}
const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
short: {
variant: "short",
@@ -56,109 +49,8 @@ const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
},
};
const OFFICIAL_VISUALS: OfficialRaceVisual[] = [
{
keywords: ["забег апрель"],
imageSrc: "https://aprilrun5km.runc.run/uploads/page_card_photos/AprilRun_photo_1.jpg",
label: "Забег Апрель",
},
{
keywords: ["быстрый пес"],
imageSrc: "https://fastdogxc.runc.run/uploads/page_card_photos/Dog_spring_2026-5.jpg",
label: "Кросс",
},
{
keywords: ["лисья гора"],
imageSrc: "https://foxhillxc.runc.run/uploads/page_card_photos/Fox_Spring_2026-0.jpg",
label: "Кросс",
},
{
keywords: ["казанский марафон"],
imageSrc: "https://static.tildacdn.com/tild3961-6436-4462-b738-356665613039/Frame_2131327895.png",
imageFit: "contain",
label: "Казанский марафон",
},
{
keywords: ["мышкинский полумарафон", "по шести холмам"],
imageSrc: "https://static.tildacdn.com/tild6133-6137-4865-b166-623532313531/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["забег.рф", "забег рф"],
imageSrc: "https://xn--80acghh.xn--p1ai/zabeg.jpg",
label: "ЗаБег.РФ",
},
{
keywords: ["переславский марафон", "александровские версты"],
imageSrc: "https://static.tildacdn.com/tild6432-3338-4533-b262-633339353335/photo_1.jpg",
label: "Золотое кольцо",
},
{
keywords: ["красочный забег"],
imageSrc: "https://colorrun5km.runc.run/uploads/page_card_photos/ColorRun2026-1.jpg",
label: "Красочный забег",
},
{
keywords: ["здорово кострома", "здорово, кострома"],
imageSrc: "https://static.tildacdn.com/tild6139-3539-4661-b232-386230336431/kostroma.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег москва"],
imageSrc: "https://nightrun10km.runc.run/uploads/page_card_photos/NightRun_2026-9.jpg",
label: "Ночной забег",
},
{
keywords: ["белые ночи"],
imageSrc: "https://wnmarathon.runc.run/uploads/page_card_photos/WN_photo_01.jpg",
label: "Белые ночи",
},
{
keywords: ["сергиевым путем", "сергиевым путём"],
imageSrc: "https://static.tildacdn.com/tild6236-3466-4239-b666-393061326338/serg.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег нижний новгород"],
imageSrc: "https://rrweb.russiarunning.com/-x740/generalimages/0531a1b8-3876-4620-8961-2fa374e474e5.png",
imageFit: "contain",
label: "Ночной забег",
},
{
keywords: ["suvorov extreme"],
imageSrc: "https://goldenultra.ru/grut/files/photos/100.jpg",
label: "Трейл",
},
{
keywords: ["рыбинский полумарафон", "великий хлебный путь"],
imageSrc: "https://static.tildacdn.com/tild6130-3230-4332-b932-366166366633/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ярославский полумарафон", "золотое кольцо"],
imageSrc: "https://static.tildacdn.com/tild6331-6333-4635-b635-376262373361/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["моя столица"],
imageSrc: "https://static.tildacdn.com/tild3263-3036-4639-b830-653365663832/-min.jpg",
imageFit: "contain",
label: "Моя столица",
},
];
function normalizeTitle(value: string): string {
return value
.toLowerCase()
.replaceAll("ё", "е")
.replace(/[«»|]/g, " ")
.replace(/[^\p{L}\p{N}.&]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function getFallbackRaceVisual(race: Race): RaceVisual {
const title = normalizeTitle(race.title);
const title = race.title.toLowerCase();
if (title.includes("ночной")) {
return FALLBACK_VISUALS.night;
@@ -196,20 +88,5 @@ export function getRaceVisual(race: Race): RaceVisual {
};
}
const title = normalizeTitle(race.title);
const official = OFFICIAL_VISUALS.find((visual) =>
visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))),
);
if (!official) {
return fallback;
}
return {
...fallback,
imageSrc: official.imageSrc,
fallbackSrc: fallback.imageSrc,
imageFit: official.imageFit ?? fallback.imageFit,
label: official.label,
};
return fallback;
}

View File

@@ -0,0 +1,167 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfileVisibility } 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 [profileSaving, setProfileSaving] = useState(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]);
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 handleProfileVisibilityChange(isProfilePublic: boolean): Promise<void> {
setProfileSaving(true);
setProfileError("");
try {
await updateProfileVisibility(isProfilePublic);
await refresh();
} catch (error) {
setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы.");
} finally {
setProfileSaving(false);
}
}
return (
<section className="page page--auth">
<h1 className="page__title">Аккаунт</h1>
<p className="page__subtitle">{user?.email} email подтверждён.</p>
<section className="auth-form" aria-labelledby="profile-title">
<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" readOnly value={`${window.location.origin}/users/${user?.id ?? ""}`} />
</label>
<label className="auth-form__field">
<span>
<input
checked={user?.isProfilePublic ?? false}
disabled={profileSaving}
type="checkbox"
onChange={(event) => void handleProfileVisibilityChange(event.target.checked)}
/>{" "}
Открыть страницу всем
</span>
<span className="auth-form__label">Открытая страница показывает только календарь стартов, без личных заметок и результатов.</span>
</label>
{profileError ? <p className="page__subtitle page__subtitle--error" role="alert">{profileError}</p> : null}
</section>
<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

@@ -120,6 +120,7 @@ export function RegisterPage(): JSX.Element {
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), []);
@@ -127,11 +128,14 @@ export function RegisterPage(): JSX.Element {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
} finally {
setIsSubmitting(false);
}
};
@@ -150,7 +154,9 @@ export function RegisterPage(): JSX.Element {
<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}>Создать аккаунт</button>
<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>
@@ -165,30 +171,72 @@ export function VerifyEmailPage(): JSX.Element {
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;
}
void verifyEmail(token)
.then(async () => {
await refresh();
setMessage("Email подтверждён. Теперь можно пользоваться календарём.");
})
.catch((err) => setError(errorMessage(err, "Ссылка недействительна.")));
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);
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
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">Для доступа к календарю подтвердите email по ссылке из письма.</p>
<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>
@@ -196,7 +244,9 @@ export function VerifyEmailPage(): JSX.Element {
</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">Отправить письмо ещё раз</button>
<button className="btn btn--primary" type="submit" disabled={isVerifying || isResending}>
{isResending ? "Отправляем…" : "Отправить письмо ещё раз"}
</button>
</form>
</section>
);
@@ -205,17 +255,35 @@ export function VerifyEmailPage(): JSX.Element {
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={async (e) => { e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}>
<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}
<button className="btn btn--primary" type="submit">Отправить ссылку</button>
{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>
);
@@ -226,27 +294,34 @@ export function ResetPasswordPage(): JSX.Element {
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={async (e) => {
e.preventDefault();
const token = params.get("token") ?? "";
try {
await resetPassword(token, password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
}
}}>
<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">{error}</p> : null}
<button className="btn btn--primary" type="submit">Сохранить пароль</button>
{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,12 +1,13 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
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,
@@ -34,10 +35,14 @@ function toCssUrl(value: string): string {
}
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();
@@ -76,10 +81,7 @@ export function DashboardPage(): JSX.Element {
const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null;
const lastPersonalRecord =
past.find(
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
) ?? null;
const lastPersonalRecord = findLatestPersonalRecord(races);
const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
@@ -132,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),
@@ -139,9 +142,20 @@ 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)
@@ -259,10 +273,8 @@ export function DashboardPage(): JSX.Element {
)}
</article>
<article
className={`dashboard-card dashboard-card--accent-lime${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastPersonalRecord ? (
{dashboardMetrics.lastPersonalRecord ? (
<article className="dashboard-card dashboard-card--accent-lime dashboard-card--linked">
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
@@ -278,13 +290,8 @@ export function DashboardPage(): JSX.Element {
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
) : (
<>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
</>
)}
</article>
</article>
) : null}
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
<h2 className="dashboard-card__title">Сезон</h2>
@@ -325,26 +332,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>
@@ -357,8 +388,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>
@@ -369,7 +412,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,66 @@
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 { userId } = useParams<{ userId: string }>();
const [races, setRaces] = useState<PublicRace[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const ac = new AbortController();
if (!userId) {
setError("Страница пользователя не найдена.");
setLoading(false);
return () => ac.abort();
}
void getPublicRaces(userId, { 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();
}, [userId]);
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>
</span>
</div>
</li>
))}
</ul>
) : null}
</section>
);
}

View File

@@ -1,9 +1,8 @@
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 { DatePickerField, StartTimeSelects } from "../components";
import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib";
import type { CreateRacePayload, Race, RaceStatus } from "../api";
import { isRaceDateInPast } from "../lib";
function slugify(text: string): string {
return text
@@ -36,8 +35,13 @@ interface FormData {
clusterSchedule: string;
bibPickup: string;
bibNumber: string;
finishTime: string;
finishPlace: string;
finishPlaceTotal: string;
originalFinishPlace: string;
isFinishPlaceEdited: boolean;
finishHours: string;
finishMinutes: string;
finishSeconds: string;
notes: string;
}
@@ -52,8 +56,13 @@ const EMPTY_FORM: FormData = {
clusterSchedule: "",
bibPickup: "",
bibNumber: "",
finishTime: "",
finishPlace: "",
finishPlaceTotal: "",
originalFinishPlace: "",
isFinishPlaceEdited: false,
finishHours: "",
finishMinutes: "",
finishSeconds: "",
notes: "",
};
@@ -70,17 +79,88 @@ function raceToFormData(race: Race): FormData {
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()) {
@@ -93,6 +173,14 @@ 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;
}
@@ -167,12 +255,6 @@ export function RaceFormPage(): JSX.Element {
const { name, value } = event.target;
setForm((prev) => {
const next = { ...prev, [name]: value };
if (name === "finishTime") {
const trimmed = value.trim();
if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) {
return { ...next, status: "completed" };
}
}
return next;
});
},
@@ -193,56 +275,38 @@ export function RaceFormPage(): JSX.Element {
setIsSaving(true);
try {
const finishTrimmed = form.finishTime.trim();
const hasParsedFinish =
finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== 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 (hasParsedFinish) {
if (finishTime.value) {
statusValue = "completed";
}
if (isEditMode && raceId) {
const payload: UpdateRacePayload = {
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: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
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) {
await updateRace(raceId, payload);
navigate(`/races/${raceId}`);
} else {
const slug = generateId(form.date.trim(), form.title.trim());
const payload: 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: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
const created = await createRace(payload);
const created = await createRace({ ...payload, slug });
navigate(`/races/${created.id}`);
}
} catch (error) {
@@ -291,17 +355,17 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group">
<legend className="race-form__legend">Основная информация</legend>
<div className="race-form__field">
<label className="race-form__field">
<span className="race-form__label">Дата *</span>
<DatePickerField
<input
className="race-form__input"
type="date"
name="date"
value={form.date}
onChange={(next) => {
setForm((prev) => ({ ...prev, date: next }));
}}
onChange={handleChange}
required
/>
</div>
</label>
<label className="race-form__field">
<span className="race-form__label">Название *</span>
@@ -317,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"
@@ -376,15 +439,17 @@ export function RaceFormPage(): JSX.Element {
</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 : (
@@ -430,29 +495,37 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group">
<legend className="race-form__legend">Результаты</legend>
<label className="race-form__field">
<div 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__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">
<div 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>
<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}

View File

@@ -177,6 +177,35 @@ a {
gap: var(--space-4);
}
.account__success {
margin: 0;
color: var(--color-success);
}
.account__sessions {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.account__session {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.account__session p {
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.dashboard-grid {
margin-top: var(--space-6);
display: grid;
@@ -472,6 +501,32 @@ a {
min-width: 10rem;
}
.comparison-table__row {
cursor: pointer;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
outline: none;
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
transform: translateX(0.2rem);
}
.dashboard-latest__filters,
.dashboard-latest__pagination {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
margin: 0 0 var(--space-4);
}
.dashboard-latest__pagination {
align-items: center;
margin-top: var(--space-4);
}
.races-filter__label {
font-size: var(--font-size-caption);
font-weight: 600;
@@ -508,6 +563,57 @@ a {
vector-effect: non-scaling-stroke;
}
.pace-chart__axis {
stroke: rgba(14, 31, 45, 0.42);
stroke-width: 0.75;
vector-effect: non-scaling-stroke;
}
.pace-chart__axis-label,
.pace-chart__axis-title,
.pace-chart__point-label {
fill: var(--color-text-muted);
font-size: 2.5px;
}
.pace-chart__axis-title {
font-size: 2.8px;
font-weight: 700;
}
.pace-chart__point-label {
font-size: 2.25px;
}
.pace-chart__point {
cursor: pointer;
outline: none;
}
.pace-chart__point:focus-visible .pace-chart__dot,
.pace-chart__point:hover .pace-chart__dot {
stroke-width: 2;
}
.pace-chart__tooltip,
.pace-chart__hint {
margin: var(--space-3) 0 0;
font-size: var(--font-size-caption);
}
.pace-chart__tooltip {
display: grid;
gap: var(--space-1);
padding: var(--space-3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.78);
color: var(--color-text);
}
.pace-chart__hint {
color: var(--color-text-muted);
}
.pace-chart__caption {
margin: var(--space-3) 0 0;
font-size: var(--font-size-caption);
@@ -825,167 +931,24 @@ a {
min-height: 5rem;
}
.race-form__time-picker {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
.race-form__result-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: var(--space-2);
}
.race-form__time-picker__unit {
.race-form__result-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.race-form__time-picker__label {
.race-form__result-field-label {
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--color-text-muted);
}
.race-form__time-picker__select {
min-width: 4.25rem;
}
.race-form__time-picker__sep {
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-2);
}
.date-picker {
position: relative;
width: min(100%, 22rem);
}
.date-picker__control {
display: flex;
align-items: stretch;
}
.date-picker__input {
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.date-picker__toggle {
width: 3rem;
border: 1px solid var(--color-border);
border-left: none;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--color-bg-deep);
color: #ffffff;
font: inherit;
font-weight: 900;
cursor: pointer;
}
.date-picker__toggle:hover,
.date-picker__toggle:focus-visible {
background: var(--color-accent-strong);
outline: none;
}
.date-picker__popover {
position: absolute;
top: calc(100% + var(--space-2));
left: 0;
z-index: 12;
width: min(20rem, calc(100vw - var(--space-8)));
padding: var(--space-4);
border: 1px solid rgba(17, 104, 216, 0.2);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-card-lift);
}
.date-picker__header {
display: grid;
grid-template-columns: 2.25rem minmax(0, 1fr) 2.25rem;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.date-picker__title {
margin: 0;
color: var(--color-bg-deep);
font-weight: 900;
text-align: center;
}
.date-picker__nav {
width: 2.25rem;
height: 2.25rem;
border: none;
border-radius: var(--radius-sm);
background: rgba(17, 104, 216, 0.08);
color: var(--color-accent);
font: inherit;
font-size: 1.25rem;
font-weight: 900;
cursor: pointer;
}
.date-picker__nav:hover,
.date-picker__nav:focus-visible {
background: var(--color-lime);
color: #071927;
outline: none;
}
.date-picker__weekdays,
.date-picker__cells {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.date-picker__weekdays {
margin-bottom: var(--space-1);
}
.date-picker__weekday {
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 800;
text-align: center;
}
.date-picker__cell,
.date-picker__day {
min-height: 2.25rem;
}
.date-picker__day {
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
font-size: var(--font-size-caption);
font-weight: 700;
cursor: pointer;
}
.date-picker__day:hover,
.date-picker__day:focus-visible {
background: #e8f1ff;
border-color: rgba(17, 104, 216, 0.2);
outline: none;
}
.date-picker__day--today {
border-color: var(--color-accent);
}
.date-picker__day--selected {
background: var(--color-lime);
color: #071927;
}
.race-form__actions {
display: flex;
gap: var(--space-3);
@@ -2182,3 +2145,14 @@ body {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.comparison-table__row {
transition: none;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
transform: none;
}
}

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

@@ -7,7 +7,9 @@
"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()]
});