From a317c9b7609cf658454b8a74d583f2a49d6a3cd6 Mon Sep 17 00:00:00 2001 From: Vakanaut Date: Sun, 12 Jul 2026 15:08:00 +0300 Subject: [PATCH] feat: complete p0 hardening sprint --- .env.example | 3 - .github/workflows/ci.yml | 24 +- .gitignore | 5 +- CHANGELOG.md | 17 +- README.md | 5 +- backend/package-lock.json | 5 +- backend/package.json | 5 +- backend/src/config.ts | 47 +- backend/src/db.ts | 508 +------------------ backend/src/migrate.ts | 15 +- backend/src/raceCoverImage.ts | 105 +++- backend/src/routes/races.ts | 131 ++++- backend/test/app.test.ts | 142 +++++- backend/test/setup.ts | 21 + docs/backend-api-for-frontend.md | 267 +++------- docs/backend.md | 19 +- docs/product-backlog.md | 30 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/health.ts | 9 - frontend/src/api/index.ts | 4 +- frontend/src/components/DatePickerField.tsx | 197 ------- frontend/src/components/StartTimeSelects.tsx | 157 ------ frontend/src/components/index.ts | 2 - frontend/src/lib/index.ts | 1 + frontend/src/lib/raceMetrics.ts | 54 +- frontend/src/lib/raceVisuals.ts | 127 +---- frontend/src/pages/AuthPages.tsx | 125 ++++- frontend/src/pages/DashboardPage.tsx | 21 +- frontend/src/pages/RaceFormPage.tsx | 241 ++++++--- frontend/src/styles/global.css | 153 +----- frontend/tsconfig.app.tsbuildinfo | 1 - frontend/tsconfig.node.json | 4 +- frontend/tsconfig.node.tsbuildinfo | 1 - frontend/vite.config.d.ts | 2 - frontend/vite.config.js | 5 - 36 files changed, 851 insertions(+), 1608 deletions(-) create mode 100644 backend/test/setup.ts delete mode 100644 frontend/src/components/DatePickerField.tsx delete mode 100644 frontend/src/components/StartTimeSelects.tsx delete mode 100644 frontend/tsconfig.app.tsbuildinfo delete mode 100644 frontend/tsconfig.node.tsbuildinfo delete mode 100644 frontend/vite.config.d.ts delete mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example index 5f8b4d5..1b78408 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c6e8ec..552a369 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 37b954c..56e7c44 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ node_modules/ dist/ .env *.log -.DS_Store \ No newline at end of file +*.tsbuildinfo +vite.config.js +vite.config.d.ts +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a354ac..4f63d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,22 @@ ## В работе -Версии приложения пока не изменены. В этой ветке добавлены документация backlog и этот changelog. +### Backend 1.4.2 + +#### Changed + +- CI и тесты используют настоящий PostgreSQL с миграциями; самодельный SQL mock удалён. +- Контракт API стартов актуализирован: UUID `id`, пользовательский `slug`, авторизация, CSRF и строгая валидация запросов. +- Получение обложки по сайту организатора ограничено безопасными публичными HTTP(S)-адресами, редиректами, размером и временем ответа. + +### Frontend 0.7.1 + +#### Changed + +- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места. +- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки. +- Карточка личного рекорда отображается только при реальном улучшении результата. +- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек. ## Backend diff --git a/README.md b/README.md index 73eda49..11775e0 100644 --- a/README.md +++ b/README.md @@ -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) - diff --git a/backend/package-lock.json b/backend/package-lock.json index 828ad65..4362722 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.4.1", + "version": "1.4.2", "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", diff --git a/backend/package.json b/backend/package.json index a1b2e48..65c2ec0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.4.1", + "version": "1.4.2", "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", diff --git a/backend/src/config.ts b/backend/src/config.ts index 690572b..2665032 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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 ", }, 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), diff --git a/backend/src/db.ts b/backend/src/db.ts index 3e934e0..496f546 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -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 = {}; - 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 = (): QueryResult => - ({ - rows: [], - rowCount: 0, - command: "", - oid: 0, - fields: [], - }) as QueryResult; - - const store = new Map(); - const users = new Map(); - const sessions = new Map(); - const verificationTokens = new Map(); - const resetTokens = new Map(); - const appSettings = new Map(); - - const result = (rows: T[], command = "SELECT"): QueryResult => - ({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult; - - const mockQuery = async ( - text: string, - params?: unknown[], - ): Promise> => { - 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([]); - } - - 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([], "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([], "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([], "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([], "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([], "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([], "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(row ? ([{} as unknown as T]) : [], "UPDATE"); - } - - if (sql.includes("UPDATE sessions SET last_seen_at")) { - return result([], "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([], "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([], "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(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(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(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([], "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([], "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; - } - - 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; - } - - 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)[col] = p[index] ?? null; - }); - store.set(id, updated); - return { - rows: [updated as unknown as T], - rowCount: 1, - command: "UPDATE", - oid: 0, - fields: [], - } as QueryResult; - } - - 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 - : 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; - } - - 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 { - if (config.useMockDb) { - return true; - } try { const client = await pool.connect(); client.release(); diff --git a/backend/src/migrate.ts b/backend/src/migrate.ts index dcbafba..c78c939 100644 --- a/backend/src/migrate.ts +++ b/backend/src/migrate.ts @@ -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()); +} diff --git a/backend/src/raceCoverImage.ts b/backend/src/raceCoverImage.ts index 43019ee..d645673 100644 --- a/backend/src/raceCoverImage.ts +++ b/backend/src/raceCoverImage.ts @@ -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 { + 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 { + 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 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 { diff --git a/backend/src/routes/races.ts b/backend/src/routes/races.ts index 931d0c4..3c2bae1 100644 --- a/backend/src/routes/races.ts +++ b/backend/src/routes/races.ts @@ -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( "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); 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); 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"] }); diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 5075992..a5433de 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -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`); @@ -81,7 +85,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 +97,6 @@ test("production config rejects Turnstile bypass token", () => { resolveTurnstileBypassToken({ rawBypassToken: "unsafe-bypass", securityProfile: "production", - useMockDb: false, }), /TURNSTILE_BYPASS_TOKEN/, ); @@ -101,18 +104,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 +155,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)); }); @@ -327,6 +321,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 +413,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 +467,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(); + } +}); diff --git a/backend/test/setup.ts b/backend/test/setup.ts new file mode 100644 index 0000000..04e5029 --- /dev/null +++ b/backend/test/setup.ts @@ -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"); diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index 7e177e2..b78c989 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -1,240 +1,125 @@ -# 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 -``` - -Разрешены методы `GET`, `POST`, `PATCH`, `DELETE` и заголовок `Content-Type: application/json`. - -## 3. Эндпоинты +## Служебные маршруты ### `GET /api/health` -Liveness-проверка (без обращения к БД). - -**Ответ 200:** +Liveness без проверки БД: ```json -{ "status": "ok" } +{ "status": "ok", "version": "" } ``` ---- +### `GET /api/meta` + +Версия для footer: + +```json +{ "version": "" } +``` ### `GET /api/ready` -Readiness-проверка (проверяет подключение к БД). +Проверяет БД. Успех: `{ "status": "ready", "db": "connected" }`. +Недоступная БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. -**Ответ 200:** - -```json -{ "status": "ready", "db": "connected" } -``` - -**Ответ 503:** - -```json -{ "error": "database_unavailable", "db": "disconnected" } -``` - ---- +## Забеги ### `GET /api/races` -Список забегов, отсортированных по дате. - -**Query-параметры (опциональные):** - -| Параметр | Тип | Описание | -|---|---|---| -| `year` | number | Фильтр по году (напр. `2026`) | -| `month` | number | Фильтр по месяцу (1–12) | - -- Без параметров — возвращает все забеги. -- Можно указать только `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 - } -] -``` - ---- +Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные +query-параметры: `year` (целое число) и `month` (1–12); без результатов возвращается +`[]`. ### `GET /api/races/:id` -Одна запись по `id`. - -**Ответ 200:** объект `Race` (см. модель ниже). - -**Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать). - -```json -{ "error": "not_found", "details": ["Race not found"] } -``` - ---- +Возвращает один забег. `id` — UUID, который создаёт сервер. Не путайте его с `slug`: +`slug` создаёт клиент как читаемый ключ и он уникален в рамках одного пользователя. ### `POST /api/races` -Создание забега. - -**Тело запроса (JSON):** +Создаёт забег. Обязательные поля: `slug`, `date`, `title`, `distanceKm`. +Неизвестные поля отклоняются. ```json { - "id": "2026-06-01-my-race", + "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 | да | да | 1–120 символов: буквы/цифры, разделённые `-` | +| `date` | string | да | да | Реальная дата `YYYY-MM-DD` | +| `title` | string | да | да | 1–200 символов | +| `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`** — целое число 1–12, фильтрует по `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`. diff --git a/docs/backend.md b/docs/backend.md index d9914fd..21597db 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -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: `. +При отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: `. -**С `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 diff --git a/docs/product-backlog.md b/docs/product-backlog.md index 95dfd7b..c39902a 100644 --- a/docs/product-backlog.md +++ b/docs/product-backlog.md @@ -1,21 +1,13 @@ # Backlog Calendar Run -**Срез репозитория: 11 июля 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей. +**Срез репозитория: 12 июля 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей. ## Текущая база | Контур | Версия | Уже работает | |---|---:|---| -| Backend | `1.4.1` | Express + PostgreSQL, миграции и seed, CRUD личных стартов, фильтр по году/месяцу, cookie-сессии, CSRF, подтверждение email, сброс пароля, Turnstile, SMTP, `/health`, `/ready`, `/meta`. | -| Frontend | `0.7.0` | Защищённые маршруты и auth-экраны, список и календарь стартов, карточка и форма старта, результаты, дашборд, график по дистанции, версия FE/BE в футере. | - -### Что не является готовым продуктом - -- API-справочник описывает старый публичный CRUD: в коде маршруты стартов требуют авторизацию, сервер генерирует UUID `id`, а клиент передаёт `slug`. -- Поля старта принимаются без строгой серверной схемы; в частности, форматы времени, календарная дата, URL, длины строк и лишние поля не валидируются в одной точке. -- Загрузка обложки ходит по URL организатора на сервере; перед следующим production-релизом необходима защита от SSRF. -- В дашборде карточка «Последний личный рекорд» показывает последний завершённый старт с временем, а не факт нового PR. -- CI собирает FE и тестирует BE только на mock-БД; миграции и запросы не проходят проверку на настоящем PostgreSQL. +| Backend | `1.4.2` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. | +| Frontend | `0.7.1` | Защищённые маршруты и auth-экраны, нативные поля даты/старта, удобный ввод результатов, дашборд с корректным личным рекордом, версия FE/BE в футере. | ## Правила версионирования и выпуска @@ -41,20 +33,23 @@ | 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-04 | P0 | Проверять миграции на реальном PostgreSQL в CI | Pipeline поднимает чистый PostgreSQL, применяет все миграции, выполняет минимум CRUD + auth. Отдельно проверяется сценарий legacy-таблицы, который обрабатывает миграция `004`. | | 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 @@ -73,6 +68,9 @@ | FE-080-01 | P0 | Экран «Аккаунт» | Пользователь видит подтверждённый email, меняет пароль, видит/отзывает сессии и инициирует удаление аккаунта с явным предупреждением. | | FE-080-02 | P1 | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции синхронизированы с URL; поиск по названию работает без новой зависимости. Годы в селекторе строятся из данных, а не ограничены окном «-2/+4». | | FE-080-03 | P1 | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере. Сервер не нужен: данные уже принадлежат пользователю и загружены в UI. | +| FE-080-04 | P1 | Сделать на дашборде список последних стартов | Блок «Сравнение стартов» переименован в «Последние старты» и показывает завершённые старты в обратном хронологическом порядке. На странице не более 5 строк, есть клиентская пагинация и фильтры по дистанции и году участия; значения фильтров строятся из данных пользователя, смена фильтра возвращает на первую страницу. Строка ведёт на страницу старта по клику и клавиатуре; состояние выбора анимируется CSS с учётом `prefers-reduced-motion`. API и серверная пагинация не нужны. | +| FE-080-05 | P1 | Связать рекорды по дистанциям со стартами | Карточка рекорда с найденным стартом ведёт на `/races/:id` мышью и с клавиатуры; она использует тот же `dashboard-card--linked`/`dashboard-card__link-surface` и анимацию, что «Ближайший старт», «Последний результат» и «Последний личный рекорд». Пустая карточка ссылкой не становится. | +| FE-080-06 | P1 | Сделать график прогресса информативным и интерактивным | SVG-график показывает подписанные оси «Дата» и «Время финиша» с читаемыми делениями; точки компактнее, имеют краткие адаптивные подписи без наложений. Наведение и фокус на точке показывают tooltip с датой, названием, временем и темпом; клик или Enter/Space открывает `/races/:id`. На узких экранах график и подписи не выходят за контейнер, а анимации учитывают `prefers-reduced-motion`. Используются текущие данные без новой библиотеки и API. | ## P2 — BE 1.6.0 / FE 0.9.0 @@ -128,7 +126,7 @@ ## Рекомендуемый первый спринт -1. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary. -2. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек. -3. `FE-071-01`–`FE-071-03`: устранить неверную аналитику и ошибки критических форм. -4. `BE-142-04`: перенести эту базу в CI, затем выпускать `1.4.2` / `0.7.1`. +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 и ввод результата. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f33f133..7917a66 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 2a726cd..1197fe2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.7.0", + "version": "0.7.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/health.ts b/frontend/src/api/health.ts index 978f5d6..8ada9a5 100644 --- a/frontend/src/api/health.ts +++ b/frontend/src/api/health.ts @@ -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 { - return requestJson("/health", init); -} - /** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */ export async function getBackendMeta(init?: RequestInit): Promise { return requestJson("/meta", init); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d1dd26b..d5e6bab 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,7 +1,7 @@ export type { AuthUser, CreateRacePayload, 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 { forgotPassword, diff --git a/frontend/src/components/DatePickerField.tsx b/frontend/src/components/DatePickerField.tsx deleted file mode 100644 index 7457c25..0000000 --- a/frontend/src/components/DatePickerField.tsx +++ /dev/null @@ -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(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 ( -
-
- { - onChange(event.target.value); - }} - onFocus={() => setIsOpen(true)} - placeholder="2026-05-03" - autoComplete="off" - required={required} - /> - -
- - {isOpen ? ( -
-
- -

{monthTitle}

- -
-
- {WEEKDAY_LABELS_SHORT_RU.map((weekday) => ( - - {weekday} - - ))} -
-
- {cells.map((day, idx) => { - if (day === null) { - return ; - } - - const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day); - const isSelected = - selected?.year === visibleMonth.year && - selected.monthIndex === visibleMonth.monthIndex && - selected.day === day; - - return ( - - ); - })} -
-
- ) : null} -
- ); -} diff --git a/frontend/src/components/StartTimeSelects.tsx b/frontend/src/components/StartTimeSelects.tsx deleted file mode 100644 index 89996fb..0000000 --- a/frontend/src/components/StartTimeSelects.tsx +++ /dev/null @@ -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 ( -
- - - : - - - - : - - -
- ); -} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index f24a219..f9aa081 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,4 +1,2 @@ -export { DatePickerField } from "./DatePickerField"; export { PaceTrendChart } from "./PaceTrendChart"; export { RacesCalendar } from "./RacesCalendar"; -export { StartTimeSelects } from "./StartTimeSelects"; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index e048b75..babc74c 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -1,6 +1,7 @@ export { formatDistance, formatRaceDate, + findLatestPersonalRecord, getPaceLabel, getRaceCountdownLabel, getRaceStatusClassName, diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts index 0e988c6..7072b1c 100644 --- a/frontend/src/lib/raceMetrics.ts +++ b/frontend/src/lib/raceMetrics.ts @@ -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); diff --git a/frontend/src/lib/raceVisuals.ts b/frontend/src/lib/raceVisuals.ts index 032e14f..bfefc8e 100644 --- a/frontend/src/lib/raceVisuals.ts +++ b/frontend/src/lib/raceVisuals.ts @@ -11,13 +11,6 @@ interface RaceVisual { label: string; } -interface OfficialRaceVisual { - keywords: string[]; - imageSrc: string; - imageFit?: RaceVisualFit; - label: string; -} - const FALLBACK_VISUALS: Record = { short: { variant: "short", @@ -56,109 +49,8 @@ const FALLBACK_VISUALS: Record = { }, }; -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; } diff --git a/frontend/src/pages/AuthPages.tsx b/frontend/src/pages/AuthPages.tsx index fba900c..336df47 100644 --- a/frontend/src/pages/AuthPages.tsx +++ b/frontend/src/pages/AuthPages.tsx @@ -120,6 +120,7 @@ export function RegisterPage(): JSX.Element { const [turnstileToken, setTurnstileToken] = useState(""); const [message, setMessage] = useState(null); const [error, setError] = useState(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 { {message ?

{message}

: null} {error ?

{error}

: null} - +

Уже есть аккаунт @@ -165,30 +171,72 @@ export function VerifyEmailPage(): JSX.Element { const [email, setEmail] = useState(user?.email ?? ""); const [message, setMessage] = useState(null); const [error, setError] = useState(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 ( +

+

Email подтверждён

+

Теперь можно пользоваться календарём.

+

Перейти к календарю

+
+ ); + } + return (

Подтверждение email

-

Для доступа к календарю подтвердите email по ссылке из письма.

+

{isVerifying ? "Проверяем ссылку…" : "Для доступа к календарю подтвердите email по ссылке из письма."}

{ e.preventDefault(); void resend(); }}> {message ?

{message}

: null} {error ?

{error}

: null} - +
); @@ -205,17 +255,35 @@ export function VerifyEmailPage(): JSX.Element { export function ForgotPasswordPage(): JSX.Element { const [email, setEmail] = useState(""); const [message, setMessage] = useState(null); + const [error, setError] = useState(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 (

Сброс пароля

-
{ e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}> + {message ?

{message}

: null} - + {error ?

{error}

: null} +
); @@ -226,27 +294,34 @@ export function ResetPasswordPage(): JSX.Element { const [password, setPassword] = useState(""); const [message, setMessage] = useState(null); const [error, setError] = useState(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 (

Новый пароль

-
{ - e.preventDefault(); - const token = params.get("token") ?? ""; - try { - await resetPassword(token, password); - setMessage("Пароль обновлён. Теперь войдите заново."); - } catch (err) { - setError(errorMessage(err, "Не удалось обновить пароль.")); - } - }}> + {message ?

{message}

: null} - {error ?

{error}

: null} - + {error ?

{error}

: null} +
); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 3780331..13fdec7 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -7,6 +7,7 @@ import { PaceTrendChart } from "../components"; import { formatDistance, formatRaceDate, + findLatestPersonalRecord, getRaceCountdownLabel, getRaceVisual, getPaceLabel, @@ -76,10 +77,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); @@ -259,10 +257,8 @@ export function DashboardPage(): JSX.Element { )} -
- {dashboardMetrics.lastPersonalRecord ? ( + {dashboardMetrics.lastPersonalRecord ? ( +

{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}

- ) : ( - <> -

Последний личный рекорд

-

Нет завершённых стартов с финишным временем.

- - )} -
+
+ ) : null}

Сезон

diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index d30002d..7414c79 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -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 { + 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 { + 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 = { + 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 {
Основная информация -
+
+ )} {hideOrgScheduleFields ? null : ( @@ -430,29 +495,37 @@ export function RaceFormPage(): JSX.Element {
Результаты -
) : null} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f1a714e..007ae7d 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -825,167 +825,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); diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo deleted file mode 100644 index 6285837..0000000 --- a/frontend/tsconfig.app.tsbuildinfo +++ /dev/null @@ -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"} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 84d71af..4251d92 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -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"] } diff --git a/frontend/tsconfig.node.tsbuildinfo b/frontend/tsconfig.node.tsbuildinfo deleted file mode 100644 index 37849f0..0000000 --- a/frontend/tsconfig.node.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.webworker.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/rollup/dist/rollup.d.ts","./node_modules/rollup/dist/parseast.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/types.d-agj9qkwt.d.ts","./node_modules/esbuild/lib/main.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/dist/node/runtime.d.ts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@babel/types/lib/index.d.ts","./node_modules/@types/babel__generator/index.d.ts","./node_modules/@babel/parser/typings/babel-parser.d.ts","./node_modules/@types/babel__template/index.d.ts","./node_modules/@types/babel__traverse/index.d.ts","./node_modules/@types/babel__core/index.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[53,101,118,119,184],[53,101,118,119],[53,101,118,119,184,185,186,187,188],[53,101,118,119,184,186],[53,98,99,101,118,119],[53,100,101,118,119],[101,118,119],[53,101,106,118,119,136],[53,101,102,107,112,118,119,121,133,144],[53,101,102,103,112,118,119,121],[48,49,50,53,101,118,119],[53,101,104,118,119,145],[53,101,105,106,113,118,119,122],[53,101,106,118,119,133,141],[53,101,107,109,112,118,119,121],[53,100,101,108,118,119],[53,101,109,110,118,119],[53,101,111,112,118,119],[53,100,101,112,118,119],[53,101,112,113,114,118,119,133,144],[53,101,112,113,114,118,119,128,133,136],[53,94,101,109,112,115,118,119,121,133,144],[53,101,112,113,115,116,118,119,121,133,141,144],[53,101,115,117,118,119,133,141,144],[51,52,53,54,55,56,57,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150],[53,101,112,118,119],[53,101,118,119,120,144],[53,101,109,112,118,119,121,133],[53,101,118,119,122],[53,101,118,119,123],[53,100,101,118,119,124],[53,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150],[53,101,118,119,126],[53,101,118,119,127],[53,101,112,118,119,128,129],[53,101,118,119,128,130,145,147],[53,101,113,118,119],[53,101,112,118,119,133,134,136],[53,101,118,119,135,136],[53,101,118,119,133,134],[53,101,118,119,136],[53,101,118,119,137],[53,98,101,118,119,133,138,144],[53,101,112,118,119,139,140],[53,101,118,119,139,140],[53,101,106,118,119,121,133,141],[53,101,118,119,142],[53,101,118,119,121,143],[53,101,115,118,119,127,144],[53,101,106,118,119,145],[53,101,118,119,133,146],[53,101,118,119,120,147],[53,101,118,119,148],[53,94,101,118,119],[53,94,101,112,114,118,119,124,133,136,144,146,147,149],[53,101,118,119,133,150],[53,101,118,119,183,189],[53,101,118,119,175],[53,101,118,119,173,175],[53,101,118,119,164,172,173,174,176,178],[53,101,118,119,162],[53,101,118,119,165,170,175,178],[53,101,118,119,161,178],[53,101,118,119,165,166,169,170,171,178],[53,101,118,119,165,166,167,169,170,178],[53,101,118,119,162,163,164,165,166,170,171,172,174,175,176,178],[53,101,118,119,178],[53,101,118,119,160,162,163,164,165,166,167,169,170,171,172,173,174,175,176,177],[53,101,118,119,160,178],[53,101,118,119,165,167,168,170,171,178],[53,101,118,119,169,178],[53,101,118,119,170,171,175,178],[53,101,118,119,163,173],[53,101,118,119,153,182],[53,101,118,119,152,153],[53,66,70,101,118,119,144],[53,66,101,118,119,133,144],[53,61,101,118,119],[53,63,66,101,118,119,141,144],[53,101,118,119,121,141],[53,101,118,119,151],[53,61,101,118,119,151],[53,63,66,101,118,119,121,144],[53,58,59,62,65,101,112,118,119,133,144],[53,66,73,101,118,119],[53,58,64,101,118,119],[53,66,87,88,101,118,119],[53,62,66,101,118,119,136,144,151],[53,87,101,118,119,151],[53,60,61,101,118,119,151],[53,66,101,118,119],[53,60,61,62,63,64,65,66,67,68,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,88,89,90,91,92,93,101,118,119],[53,66,81,101,118,119],[53,66,73,74,101,118,119],[53,64,66,74,75,101,118,119],[53,65,101,118,119],[53,58,61,66,101,118,119],[53,66,70,74,75,101,118,119],[53,70,101,118,119],[53,64,66,69,101,118,119,144],[53,58,63,66,73,101,118,119],[53,101,118,119,133],[53,61,66,87,101,118,119,149,151],[53,101,112,113,115,116,117,118,119,121,133,141,144,150,151,153,154,155,156,157,158,159,179,180,181,182],[53,101,118,119,155,156,157,158],[53,101,118,119,155,156,157],[53,101,118,119,155],[53,101,118,119,156],[53,101,118,119,153],[53,101,118,119,183,190]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d08cde2bfb6ff909efbf7cb7d6d55be412cf4b9f48fd0430185986973fa91f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"9855e02d837744303391e5623a531734443a5f8e6e8755e018c41d63ad797db2","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"c906fb15bd2aabc9ed1e3f44eb6a8661199d6c320b3aa196b826121552cb3695","impliedFormat":1},{"version":"22295e8103f1d6d8ea4b5d6211e43421fe4564e34d0dd8e09e520e452d89e659","impliedFormat":1},{"version":"58647d85d0f722a1ce9de50955df60a7489f0593bf1a7015521efe901c06d770","impliedFormat":1},{"version":"6b4e081d55ac24fc8a4631d5dd77fe249fa25900abd7d046abb87d90e3b45645","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"a3fc63c0d7b031693f665f5494412ba4b551fe644ededccc0ab5922401079c95","impliedFormat":1},{"version":"80523c00b8544a2000ae0143e4a90a00b47f99823eb7926c1e03c494216fc363","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"746911b62b329587939560deb5c036aca48aece03147b021fa680223255d5183","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"c8d3e5a18ba35629954e48c4cc8f11dc88224650067a172685c736b27a34a4dc","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2b55d426ff2b9087485e52ac4bc7cfafe1dc420fc76dad926cd46526567c501a","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"47613031a5a31510831304405af561b0ffaedb734437c595256bb61a90f9311b","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"8a1a0d0a4a06a8d278947fcb66bf684f117bf147f89b06e50662d79a53be3e9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"9f55299850d4f0921e79b6bf344b47c420ce0f507b9dcf593e532b09ea7eeea1","impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"ee70b8037ecdf0de6c04f35277f253663a536d7e38f1539d270e4e916d225a3f","affectsGlobalScope":true,"impliedFormat":1},{"version":"a660aa95476042d3fdcc1343cf6bb8fdf24772d31712b1db321c5a4dcc325434","impliedFormat":1},{"version":"282f98006ed7fa9bb2cd9bdbe2524595cfc4bcd58a0bb3232e4519f2138df811","impliedFormat":1},{"version":"6222e987b58abfe92597e1273ad7233626285bc2d78409d4a7b113d81a83496b","impliedFormat":1},{"version":"cbe726263ae9a7bf32352380f7e8ab66ee25b3457137e316929269c19e18a2be","impliedFormat":1},{"version":"8b96046bf5fb0a815cba6b0880d9f97b7f3a93cf187e8dcfe8e2792e97f38f87","impliedFormat":99},{"version":"bacf2c84cf448b2cd02c717ad46c3d7fd530e0c91282888c923ad64810a4d511","affectsGlobalScope":true,"impliedFormat":1},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"7965dc3c7648e2a7a586d11781cabb43d4859920716bc2fdc523da912b06570d","impliedFormat":1},{"version":"90c2bd9a3e72fe08b8fa5982e78cb8dc855a1157b26e11e37a793283c52bf64b","impliedFormat":1},{"version":"a8122fe390a2a987079e06c573b1471296114677923c1c094c24a53ddd7344a2","impliedFormat":1},{"version":"70c2cb19c0c42061a39351156653aa0cf5ba1ecdc8a07424dd38e3a1f1e3c7f4","impliedFormat":1},{"version":"a8fb10fd8c7bc7d9b8f546d4d186d1027f8a9002a639bec689b5000dab68e35c","impliedFormat":1},{"version":"c9b467ea59b86bd27714a879b9ad43c16f186012a26d0f7110b1322025ceaa83","impliedFormat":1},{"version":"57ea19c2e6ba094d8087c721bac30ff1c681081dbd8b167ac068590ef633e7a5","impliedFormat":1},{"version":"cba81ec9ae7bc31a4dc56f33c054131e037649d6b9a2cfa245124c67e23e4721","impliedFormat":1},{"version":"ad193f61ba708e01218496f093c23626aa3808c296844a99189be7108a9c8343","impliedFormat":1},{"version":"a0544b3c8b70b2f319a99ea380b55ab5394ede9188cdee452a5d0ce264f258b2","impliedFormat":1},{"version":"8c654c17c334c7c168c1c36e5336896dc2c892de940886c1639bebd9fc7b9be4","impliedFormat":1},{"version":"6a4da742485d5c2eb6bcb322ae96993999ffecbd5660b0219a5f5678d8225bb0","impliedFormat":1},{"version":"c65ca21d7002bdb431f9ab3c7a6e765a489aa5196e7e0ef00aed55b1294df599","impliedFormat":1},{"version":"c8fc655c2c4bafc155ceee01c84ab3d6c03192ced5d3f2de82e20f3d1bd7f9fa","impliedFormat":1},{"version":"be5a7ff3b47f7e553565e9483bdcadb0ca2040ac9e5ec7b81c7e115a81059882","impliedFormat":1},{"version":"1a93f36ecdb60a95e3a3621b561763e2952da81962fae217ab5441ac1d77ffc5","impliedFormat":1},{"version":"2a771d907aebf9391ac1f50e4ad37952943515eeea0dcc7e78aa08f508294668","impliedFormat":1},{"version":"0146fd6262c3fd3da51cb0254bb6b9a4e42931eb2f56329edd4c199cb9aaf804","impliedFormat":1},{"version":"183f480885db5caa5a8acb833c2be04f98056bdcc5fb29e969ff86e07efe57ab","impliedFormat":99},{"version":"82e687ebd99518bc63ea04b0c3810fb6e50aa6942decd0ca6f7a56d9b9a212a6","impliedFormat":99},{"version":"7f698624bbbb060ece7c0e51b7236520ebada74b747d7523c7df376453ed6fea","impliedFormat":1},{"version":"8f07f2b6514744ac96e51d7cb8518c0f4de319471237ea10cf688b8d0e9d0225","impliedFormat":1},{"version":"257b83faa134d971c738a6b9e4c47e59bb7b23274719d92197580dd662bfafc3","impliedFormat":99},{"version":"556ccd493ec36c7d7cb130d51be66e147b91cc1415be383d71da0f1e49f742a9","impliedFormat":1},{"version":"b6d03c9cfe2cf0ba4c673c209fcd7c46c815b2619fd2aad59fc4229aaef2ed43","impliedFormat":1},{"version":"95aba78013d782537cc5e23868e736bec5d377b918990e28ed56110e3ae8b958","impliedFormat":1},{"version":"670a76db379b27c8ff42f1ba927828a22862e2ab0b0908e38b671f0e912cc5ed","impliedFormat":1},{"version":"13b77ab19ef7aadd86a1e54f2f08ea23a6d74e102909e3c00d31f231ed040f62","impliedFormat":1},{"version":"069bebfee29864e3955378107e243508b163e77ab10de6a5ee03ae06939f0bb9","impliedFormat":1},{"version":"26e0ffceb2198feb1ef460d5d14111c69ad07d44c5a67fd4bfeb74c969aa9afb","impliedFormat":99},{"version":"c9f2b1d8adb74ccdf5b3279875524ab2f6c1dccf88474c85e96d02aec6964910","signature":"4b96dd19fd2949d28ce80e913412b0026dc421e5bf6c31d87c7b5eb11b5753b4"}],"root":[191],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99,"strict":true,"target":7},"referencedMap":[[186,1],[184,2],[189,3],[185,1],[187,4],[188,1],[152,2],[98,5],[99,5],[100,6],[53,7],[101,8],[102,9],[103,10],[48,2],[51,11],[49,2],[50,2],[104,12],[105,13],[106,14],[107,15],[108,16],[109,17],[110,17],[111,18],[112,19],[113,20],[114,21],[54,2],[52,2],[115,22],[116,23],[117,24],[151,25],[118,26],[119,2],[120,27],[121,28],[122,29],[123,30],[124,31],[125,32],[126,33],[127,34],[128,35],[129,35],[130,36],[131,2],[132,37],[133,38],[135,39],[134,40],[136,41],[137,42],[138,43],[139,44],[140,45],[141,46],[142,47],[143,48],[144,49],[145,50],[146,51],[147,52],[148,53],[55,2],[56,2],[57,2],[95,54],[96,2],[97,2],[149,55],[150,56],[190,57],[159,2],[176,58],[174,59],[175,60],[163,61],[164,59],[171,62],[162,63],[167,64],[177,2],[168,65],[173,66],[179,67],[178,68],[161,69],[169,70],[170,71],[165,72],[172,58],[166,73],[154,74],[153,75],[160,2],[46,2],[47,2],[8,2],[11,2],[10,2],[2,2],[12,2],[13,2],[14,2],[15,2],[16,2],[17,2],[18,2],[19,2],[3,2],[20,2],[21,2],[4,2],[22,2],[26,2],[23,2],[24,2],[25,2],[27,2],[28,2],[29,2],[5,2],[30,2],[31,2],[32,2],[33,2],[6,2],[37,2],[34,2],[35,2],[36,2],[38,2],[7,2],[39,2],[44,2],[45,2],[40,2],[41,2],[42,2],[43,2],[1,2],[9,2],[73,76],[83,77],[72,76],[93,78],[64,79],[63,80],[92,81],[86,82],[91,83],[66,84],[80,85],[65,86],[89,87],[61,88],[60,81],[90,89],[62,90],[67,91],[68,2],[71,91],[58,2],[94,92],[84,93],[75,94],[76,95],[78,96],[74,97],[77,98],[87,81],[69,99],[70,100],[79,101],[59,102],[82,93],[81,91],[85,2],[88,103],[183,104],[180,105],[158,106],[156,107],[155,2],[157,108],[181,2],[182,109],[191,110]],"semanticDiagnosticsPerFile":[[8,[{"start":930,"length":9,"messageText":"Definitions of the following identifiers conflict with those in another file: ImportExportKind, TableKind, ExportValue, Exports, ImportValue, Imports, ModuleImports, ValueType, name, AlgorithmIdentifier, AllowSharedBufferSource, BigInteger, BlobPart, BodyInit, BufferSource, CSSKeywordish, CSSNumberish, CSSPerspectiveValue, CSSUnparsedSegment, CanvasImageSource, CookieList, DOMHighResTimeStamp, EpochTimeStamp, EventListenerOrEventListenerObject, FileSystemWriteChunkType, Float32List, FormDataEntryValue, GLbitfield, GLboolean, GLclampf, GLenum, GLfloat, GLint, GLint64, GLintptr, GLsizei, GLsizeiptr, GLuint, GLuint64, HashAlgorithmIdentifier, HeadersInit, IDBValidKey, ImageBitmapSource, ImageBufferSource, ImageDataArray, Int32List, MessageEventSource, NamedCurve, OffscreenRenderingContext, OnErrorEventHandler, PerformanceEntryList, ReadableStreamController, ReadableStreamReadResult, ReadableStreamReader, ReportList, RequestInfo, TexImageSource, TimerHandler, Transferable, Uint32List, XMLHttpRequestBodyInit, AlphaOption, AudioSampleFormat, AvcBitstreamFormat, BinaryType, BitrateMode, CSSMathOperator, CSSNumericBaseType, CanvasDirection, CanvasFillRule, CanvasFontKerning, CanvasFontStretch, CanvasFontVariantCaps, CanvasLineCap, CanvasLineJoin, CanvasTextAlign, CanvasTextBaseline, CanvasTextRendering, ClientTypes, CodecState, ColorGamut, ColorSpaceConversion, CompressionFormat, CookieSameSite, DocumentVisibilityState, EncodedAudioChunkType, EncodedVideoChunkType, EndingType, FileSystemHandleKind, FontDisplay, FontFaceLoadStatus, FontFaceSetLoadStatus, GlobalCompositeOperation, HardwareAcceleration, HdrMetadataType, IDBCursorDirection, IDBRequestReadyState, IDBTransactionDurability, IDBTransactionMode, ImageOrientation, ImageSmoothingQuality, KeyFormat, KeyType, KeyUsage, LatencyMode, LockMode, MediaDecodingType, MediaEncodingType, MediaKeysRequirement, NotificationDirection, NotificationPermission, OffscreenRenderingContextId, OpusBitstreamFormat, PermissionName, PermissionState, PredefinedColorSpace, PremultiplyAlpha, PushEncryptionKeyName, RTCDataChannelState, RTCEncodedVideoFrameType, ReadableStreamReaderMode, ReadableStreamType, ReferrerPolicy, RequestCache, RequestCredentials, RequestDestination, RequestMode, RequestPriority, RequestRedirect, ResizeQuality, ResponseType, SecurityPolicyViolationEventDisposition, ServiceWorkerState, ServiceWorkerUpdateViaCache, TransferFunction, VideoColorPrimaries, VideoEncoderBitrateMode, VideoMatrixCoefficients, VideoPixelFormat, VideoTransferCharacteristics, WebGLPowerPreference, WebTransportCongestionControl, WebTransportErrorSource, WorkerType, WriteCommandType, XMLHttpRequestResponseType","category":1,"code":6200,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.webworker.d.ts","start":930,"length":9,"messageText":"Conflicts are in this file.","category":3,"code":6201}]},{"start":194996,"length":33,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":283291,"length":39,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":287103,"length":36,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":378009,"length":24,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":487425,"length":22,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":851009,"length":28,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374}]],[9,[{"start":930,"length":9,"messageText":"Definitions of the following identifiers conflict with those in another file: ImportExportKind, TableKind, ExportValue, Exports, ImportValue, Imports, ModuleImports, ValueType, name, AlgorithmIdentifier, AllowSharedBufferSource, BigInteger, BlobPart, BodyInit, BufferSource, CSSKeywordish, CSSNumberish, CSSPerspectiveValue, CSSUnparsedSegment, CanvasImageSource, CookieList, DOMHighResTimeStamp, EpochTimeStamp, EventListenerOrEventListenerObject, FileSystemWriteChunkType, Float32List, FormDataEntryValue, GLbitfield, GLboolean, GLclampf, GLenum, GLfloat, GLint, GLint64, GLintptr, GLsizei, GLsizeiptr, GLuint, GLuint64, HashAlgorithmIdentifier, HeadersInit, IDBValidKey, ImageBitmapSource, ImageBufferSource, ImageDataArray, Int32List, MessageEventSource, NamedCurve, OffscreenRenderingContext, OnErrorEventHandler, PerformanceEntryList, ReadableStreamController, ReadableStreamReadResult, ReadableStreamReader, ReportList, RequestInfo, TexImageSource, TimerHandler, Transferable, Uint32List, XMLHttpRequestBodyInit, AlphaOption, AudioSampleFormat, AvcBitstreamFormat, BinaryType, BitrateMode, CSSMathOperator, CSSNumericBaseType, CanvasDirection, CanvasFillRule, CanvasFontKerning, CanvasFontStretch, CanvasFontVariantCaps, CanvasLineCap, CanvasLineJoin, CanvasTextAlign, CanvasTextBaseline, CanvasTextRendering, ClientTypes, CodecState, ColorGamut, ColorSpaceConversion, CompressionFormat, CookieSameSite, DocumentVisibilityState, EncodedAudioChunkType, EncodedVideoChunkType, EndingType, FileSystemHandleKind, FontDisplay, FontFaceLoadStatus, FontFaceSetLoadStatus, GlobalCompositeOperation, HardwareAcceleration, HdrMetadataType, IDBCursorDirection, IDBRequestReadyState, IDBTransactionDurability, IDBTransactionMode, ImageOrientation, ImageSmoothingQuality, KeyFormat, KeyType, KeyUsage, LatencyMode, LockMode, MediaDecodingType, MediaEncodingType, MediaKeysRequirement, NotificationDirection, NotificationPermission, OffscreenRenderingContextId, OpusBitstreamFormat, PermissionName, PermissionState, PredefinedColorSpace, PremultiplyAlpha, PushEncryptionKeyName, RTCDataChannelState, RTCEncodedVideoFrameType, ReadableStreamReaderMode, ReadableStreamType, ReferrerPolicy, RequestCache, RequestCredentials, RequestDestination, RequestMode, RequestPriority, RequestRedirect, ResizeQuality, ResponseType, SecurityPolicyViolationEventDisposition, ServiceWorkerState, ServiceWorkerUpdateViaCache, TransferFunction, VideoColorPrimaries, VideoEncoderBitrateMode, VideoMatrixCoefficients, VideoPixelFormat, VideoTransferCharacteristics, WebGLPowerPreference, WebTransportCongestionControl, WebTransportErrorSource, WorkerType, WriteCommandType, XMLHttpRequestResponseType","category":1,"code":6200,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":930,"length":9,"messageText":"Conflicts are in this file.","category":3,"code":6201}]},{"start":58232,"length":33,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":60921,"length":15,"messageText":"Subsequent variable declarations must have the same type. Variable 'CSSNumericValue' must be of type '{ new (): CSSNumericValue; prototype: CSSNumericValue; parse(cssText: string): CSSNumericValue; }', but here has type '{ new (): CSSNumericValue; prototype: CSSNumericValue; }'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":197685,"length":15,"messageText":"'CSSNumericValue' was also declared here.","category":3,"code":6203}]},{"start":66034,"length":13,"messageText":"Subsequent variable declarations must have the same type. Variable 'CSSStyleValue' must be of type '{ new (): CSSStyleValue; prototype: CSSStyleValue; parse(property: string, cssText: string): CSSStyleValue; parseAll(property: string, cssText: string): CSSStyleValue[]; }', but here has type '{ new (): CSSStyleValue; prototype: CSSStyleValue; }'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":280066,"length":13,"messageText":"'CSSStyleValue' was also declared here.","category":3,"code":6203}]},{"start":68159,"length":39,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":70752,"length":36,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":134441,"length":24,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":166292,"length":22,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":191710,"length":8,"messageText":"Subsequent variable declarations must have the same type. Variable 'FormData' must be of type '{ new (form?: HTMLFormElement | undefined, submitter?: HTMLElement | null | undefined): FormData; prototype: FormData; }', but here has type '{ new (): FormData; prototype: FormData; }'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":516496,"length":8,"messageText":"'FormData' was also declared here.","category":3,"code":6203}]},{"start":235944,"length":28,"messageText":"Duplicate index signature for type 'number'.","category":1,"code":2374},{"start":256556,"length":12,"messageText":"Subsequent variable declarations must have the same type. Variable 'Notification' must be of type '{ new (title: string, options?: NotificationOptions | undefined): Notification; prototype: Notification; readonly permission: NotificationPermission; requestPermission(deprecatedCallback?: NotificationPermissionCallback | undefined): Promise<...>; }', but here has type '{ new (title: string, options?: NotificationOptions | undefined): Notification; prototype: Notification; readonly permission: NotificationPermission; }'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1003113,"length":12,"messageText":"'Notification' was also declared here.","category":3,"code":6203}]},{"start":304596,"length":7,"messageText":"Subsequent property declarations must have the same type. Property '\"error\"' must be of type 'RTCErrorEvent', but here has type 'Event'.","category":1,"code":2717,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1122228,"length":7,"messageText":"'\"error\"' was also declared here.","category":3,"code":6203}]},{"start":308174,"length":7,"messageText":"Subsequent property declarations must have the same type. Property 'onerror' must be of type '((this: RTCDataChannel, ev: RTCErrorEvent) => any) | null', but here has type '((this: RTCDataChannel, ev: Event) => any) | null'.","category":1,"code":2717,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1125814,"length":7,"messageText":"'onerror' was also declared here.","category":3,"code":6203}]},{"start":386641,"length":3,"messageText":"Subsequent variable declarations must have the same type. Variable 'URL' must be of type '{ new (url: string | URL, base?: string | URL | undefined): URL; prototype: URL; canParse(url: string | URL, base?: string | URL | undefined): boolean; createObjectURL(obj: Blob | MediaSource): string; parse(url: string | URL, base?: string | ... 1 more ... | undefined): URL | null; revokeObjectURL(url: string): voi...', but here has type '{ new (url: string | URL, base?: string | URL | undefined): URL; prototype: URL; canParse(url: string | URL, base?: string | URL | undefined): boolean; createObjectURL(obj: Blob): string; parse(url: string | URL, base?: string | ... 1 more ... | undefined): URL | null; revokeObjectURL(url: string): void; }'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1535247,"length":3,"messageText":"'URL' was also declared here.","category":3,"code":6203}]},{"start":593370,"length":8,"messageText":"Subsequent variable declarations must have the same type. Variable 'location' must be of type 'Location', but here has type 'WorkerLocation'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1815673,"length":8,"messageText":"'location' was also declared here.","category":3,"code":6203}]},{"start":593643,"length":9,"messageText":"Subsequent variable declarations must have the same type. Variable 'navigator' must be of type 'Navigator', but here has type 'WorkerNavigator'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1816495,"length":9,"messageText":"'navigator' was also declared here.","category":3,"code":6203}]},{"start":593780,"length":7,"messageText":"Subsequent variable declarations must have the same type. Variable 'onerror' must be of type 'OnErrorEventHandler', but here has type '((this: DedicatedWorkerGlobalScope, ev: ErrorEvent) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1836731,"length":7,"messageText":"'onerror' was also declared here.","category":3,"code":6203}]},{"start":593975,"length":16,"messageText":"Subsequent variable declarations must have the same type. Variable 'onlanguagechange' must be of type '((this: Window, ev: Event) => any) | null', but here has type '((this: DedicatedWorkerGlobalScope, ev: Event) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1849360,"length":16,"messageText":"'onlanguagechange' was also declared here.","category":3,"code":6203}]},{"start":594167,"length":9,"messageText":"Subsequent variable declarations must have the same type. Variable 'onoffline' must be of type '((this: Window, ev: Event) => any) | null', but here has type '((this: DedicatedWorkerGlobalScope, ev: Event) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1849853,"length":9,"messageText":"'onoffline' was also declared here.","category":3,"code":6203}]},{"start":594351,"length":8,"messageText":"Subsequent variable declarations must have the same type. Variable 'ononline' must be of type '((this: Window, ev: Event) => any) | null', but here has type '((this: DedicatedWorkerGlobalScope, ev: Event) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1850006,"length":8,"messageText":"'ononline' was also declared here.","category":3,"code":6203}]},{"start":594544,"length":18,"messageText":"Subsequent variable declarations must have the same type. Variable 'onrejectionhandled' must be of type '((this: Window, ev: PromiseRejectionEvent) => any) | null', but here has type '((this: DedicatedWorkerGlobalScope, ev: PromiseRejectionEvent) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1851006,"length":18,"messageText":"'onrejectionhandled' was also declared here.","category":3,"code":6203}]},{"start":594765,"length":20,"messageText":"Subsequent variable declarations must have the same type. Variable 'onunhandledrejection' must be of type '((this: Window, ev: PromiseRejectionEvent) => any) | null', but here has type '((this: DedicatedWorkerGlobalScope, ev: PromiseRejectionEvent) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1851357,"length":20,"messageText":"'onunhandledrejection' was also declared here.","category":3,"code":6203}]},{"start":595102,"length":4,"messageText":"Subsequent variable declarations must have the same type. Variable 'self' must be of type 'Window & typeof globalThis', but here has type 'WorkerGlobalScope & typeof globalThis'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1821910,"length":4,"messageText":"'self' was also declared here.","category":3,"code":6203}]},{"start":599186,"length":9,"messageText":"Subsequent variable declarations must have the same type. Variable 'onmessage' must be of type '((this: Window, ev: MessageEvent) => any) | null', but here has type '((this: DedicatedWorkerGlobalScope, ev: MessageEvent) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1849521,"length":9,"messageText":"'onmessage' was also declared here.","category":3,"code":6203}]},{"start":599392,"length":14,"messageText":"Subsequent variable declarations must have the same type. Variable 'onmessageerror' must be of type '((this: Window, ev: MessageEvent) => any) | null', but here has type '((this: DedicatedWorkerGlobalScope, ev: MessageEvent) => any) | null'.","category":1,"code":2403,"relatedInformation":[{"file":"./node_modules/typescript/lib/lib.dom.d.ts","start":1849687,"length":14,"messageText":"'onmessageerror' was also declared here.","category":3,"code":6203}]}]]],"latestChangedDtsFile":"./vite.config.d.ts","version":"5.9.3"} \ No newline at end of file diff --git a/frontend/vite.config.d.ts b/frontend/vite.config.d.ts deleted file mode 100644 index 340562a..0000000 --- a/frontend/vite.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vite").UserConfig; -export default _default; diff --git a/frontend/vite.config.js b/frontend/vite.config.js deleted file mode 100644 index 0e75c14..0000000 --- a/frontend/vite.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()] -});