Compare commits

...

16 Commits

Author SHA1 Message Date
Vakanaut
821f9167ca fix(frontend): register AG Charts modules
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-13 20:47:55 +03:00
e882e8ded6 Merge pull request 'feat: complete race history sprint' (#45) from feature/race-history-covers-and-charts into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #45
2026-07-13 17:25:05 +00:00
Vakanaut
a8b87fc6b1 feat: complete race history sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-13 08:31:12 +03:00
46998a1a67 Merge pull request 'docs: plan race cover restoration' (#44) from fix/restore-race-covers into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #44
2026-07-12 15:34:12 +00:00
Vakanaut
2d1390ae63 docs: plan race cover restoration
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 18:33:14 +03:00
f6ad7b924a Merge pull request 'fix: run migrations before starting backend' (#43) from fix/auto-run-migrations into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #43
2026-07-12 14:08:00 +00:00
Vakanaut
e68df524ef fix: run migrations before starting backend
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 17:07:32 +03:00
ea7b8aff7a Merge pull request 'feature/profile-usernames' (#42) from feature/profile-usernames into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #42
2026-07-12 14:01:49 +00:00
Vakanaut
77790a646b Merge remote-tracking branch 'origin/main' into feature/profile-usernames
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
# Conflicts:
#	backend/package-lock.json
#	backend/package.json
#	backend/src/authMiddleware.ts
#	backend/src/authService.ts
#	backend/src/routes/auth.ts
#	backend/src/routes/users.ts
#	backend/test/app.test.ts
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/api/auth.ts
#	frontend/src/api/index.ts
#	frontend/src/api/types.ts
#	frontend/src/api/users.ts
#	frontend/src/app/router.tsx
#	frontend/src/pages/AccountPage.tsx
#	frontend/src/pages/PublicProfilePage.tsx
2026-07-12 17:01:23 +03:00
Vakanaut
d4c72115d8 feat: customize public profile visibility
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 16:57:43 +03:00
Vakanaut
103e3ca209 feat: add public user profiles 2026-07-12 16:51:18 +03:00
e68265d9f7 Merge pull request 'feat: add public user profiles' (#41) from feature/public-profiles into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #41
2026-07-12 13:29:56 +00:00
Vakanaut
07ab687bf3 feat: add public user profiles
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 16:21:53 +03:00
69931e81a8 Merge pull request 'feat: complete account and dashboard sprint' (#40) from feature/p1-account-dashboard into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #40
2026-07-12 12:59:09 +00:00
Vakanaut
793d51fdce feat: complete account and dashboard sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 15:56:26 +03:00
a6108ea927 Merge pull request 'feat: complete p0 hardening sprint' (#39) from feature/p0-hardening-auth-results into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #39
2026-07-12 12:13:45 +00:00
39 changed files with 1845 additions and 285 deletions

View File

@@ -7,7 +7,24 @@
## В работе
### Backend 1.4.2
### Backend 1.6.0
#### Added
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
### Frontend 0.9.0
#### Added
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
- Поиск, фильтры, сортировка и экспорт истории; график прогресса переведён на AG Charts.
## Backend
### [1.4.2] — 2026-07-12
#### Changed
@@ -15,17 +32,6 @@
- Контракт API стартов актуализирован: UUID `id`, пользовательский `slug`, авторизация, CSRF и строгая валидация запросов.
- Получение обложки по сайту организатора ограничено безопасными публичными HTTP(S)-адресами, редиректами, размером и временем ответа.
### Frontend 0.7.1
#### Changed
- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места.
- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки.
- Карточка личного рекорда отображается только при реальном улучшении результата.
- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек.
## Backend
### [1.4.1] — 2026-05-24
#### Changed
@@ -87,6 +93,15 @@
## Frontend
### [0.7.1] — 2026-07-12
#### Changed
- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места.
- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки.
- Карточка личного рекорда отображается только при реальном улучшении результата.
- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек.
### [0.7.0] — 2026-05-24
#### Added

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.6.0",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.6.0",
"private": true,
"scripts": {
"build": "tsc",
@@ -8,6 +8,7 @@
"start": "node dist/index.js",
"db:migrate": "ts-node src/migrate.ts",
"seed": "ts-node src/seed.ts",
"backfill:covers": "ts-node src/backfillRaceCovers.ts",
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
},
"dependencies": {

View File

@@ -7,6 +7,7 @@ import { loadAuth, requireCsrf } from "./authMiddleware";
import authRouter from "./routes/auth";
import healthRouter from "./routes/health";
import racesRouter from "./routes/races";
import usersRouter from "./routes/users";
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
@@ -54,6 +55,7 @@ export function createApp(): express.Express {
app.use("/api", healthRouter);
app.use("/api", authRouter);
app.use("/api", usersRouter);
app.use("/api", racesRouter);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {

View File

@@ -10,6 +10,9 @@ declare global {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
};
csrfTokenHash: string;
sessionToken: string;

View File

@@ -18,6 +18,9 @@ export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}
interface UserRow {
@@ -25,6 +28,9 @@ interface UserRow {
email: string;
password_hash: string;
email_verified_at: Date | string | null;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionRow {
@@ -35,6 +41,25 @@ interface SessionRow {
expires_at: Date | string;
email: string;
email_verified_at: Date | string | null;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionListRow {
id: string;
created_at: Date | string;
last_seen_at: Date | string;
expires_at: Date | string;
current: boolean;
}
export interface ActiveSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}
function toIso(value: Date | string | null): string | null {
@@ -49,6 +74,9 @@ function userFromRow(row: UserRow): AuthUser {
id: row.id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
};
}
@@ -65,7 +93,9 @@ function isUniqueViolation(error: unknown): boolean {
export async function findUserByEmail(email: string): Promise<UserRow | null> {
const normalized = normalizeEmail(email);
const { rows } = await pool.query<UserRow>(
"SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email)) = $1",
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1`,
[normalized],
);
return rows[0] ?? null;
@@ -126,7 +156,8 @@ export async function registerUser(email: string, password: string): Promise<voi
({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at`,
RETURNING id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public`,
[normalized, passwordHash],
));
} catch (error) {
@@ -164,7 +195,10 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, token_hash, csrf_token_hash, expires_at,
(SELECT email FROM users WHERE id = $1) AS email,
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at`,
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at,
(SELECT profile_username FROM users WHERE id = $1) AS profile_username,
(SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public,
(SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`,
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
);
const row = rows[0];
@@ -176,6 +210,9 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
};
}
@@ -198,6 +235,84 @@ export async function revokeSession(sessionToken: string): Promise<void> {
securityLog("session.revoked");
}
export async function listActiveSessions(userId: string, sessionToken: string): Promise<ActiveSession[]> {
const { rows } = await pool.query<SessionListRow>(
`SELECT id, created_at, last_seen_at, expires_at, token_hash = $2 AS current
FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC`,
[userId, sha256Hex(sessionToken)],
);
return rows.map((row) => ({
id: row.id,
createdAt: toIso(row.created_at)!,
lastSeenAt: toIso(row.last_seen_at)!,
expiresAt: toIso(row.expires_at)!,
current: row.current,
}));
}
export async function revokeSessionById(userId: string, sessionId: string, sessionToken: string): Promise<{ revoked: boolean; current: boolean }> {
const { rows } = await pool.query<{ current: boolean }>(
`UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()
RETURNING token_hash = $3 AS current`,
[sessionId, userId, sha256Hex(sessionToken)],
);
const row = rows[0];
if (!row) {
return { revoked: false, current: false };
}
securityLog("session.revoked", { userId });
return { revoked: true, current: row.current };
}
export async function revokeOtherSessions(userId: string, sessionToken: string): Promise<void> {
await pool.query(
`UPDATE sessions
SET revoked_at = NOW()
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > NOW()`,
[userId, sha256Hex(sessionToken)],
);
securityLog("session.others_revoked", { userId });
}
export async function changePassword(
userId: string,
sessionToken: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const newPasswordHash = await hashPassword(newPassword);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ password_hash: string }>(
"SELECT password_hash FROM users WHERE id = $1 FOR UPDATE",
[userId],
);
const user = rows[0];
if (!user || !(await verifyPassword(user.password_hash, currentPassword))) {
await client.query("ROLLBACK");
return false;
}
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [userId, newPasswordHash]);
await client.query(
"UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL",
[userId, sha256Hex(sessionToken)],
);
await client.query("COMMIT");
securityLog("password.changed", { userId });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function rotateCsrf(sessionToken: string): Promise<string | null> {
const csrfToken = randomToken(32);
const { rowCount } = await pool.query(
@@ -212,7 +327,8 @@ export async function rotateCsrf(sessionToken: string): Promise<string | null> {
export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> {
const tokenHash = sha256Hex(sessionToken);
const { rows } = await pool.query<SessionRow>(
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at,
u.profile_username, u.is_future_races_public, u.is_completed_races_public
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`,
@@ -231,11 +347,41 @@ export async function getSession(sessionToken: string): Promise<{ user: AuthUser
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
csrfTokenHash: row.csrf_token_hash,
};
}
export async function updateProfile(
userId: string,
profile: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
},
): Promise<void> {
await pool.query(
`UPDATE users SET
profile_username = CASE WHEN $2 THEN $3 ELSE profile_username END,
is_future_races_public = CASE WHEN $4 THEN $5 ELSE is_future_races_public END,
is_completed_races_public = CASE WHEN $6 THEN $7 ELSE is_completed_races_public END,
updated_at = NOW()
WHERE id = $1`,
[
userId,
profile.profileUsername !== undefined,
profile.profileUsername,
profile.isFutureRacesPublic !== undefined,
profile.isFutureRacesPublic,
profile.isCompletedRacesPublic !== undefined,
profile.isCompletedRacesPublic,
],
);
}
export function csrfMatches(hash: string, token: string): boolean {
return timingSafeEqualHex(hash, sha256Hex(token));
}

View File

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

View File

@@ -12,6 +12,7 @@ export interface RaceRow {
status: string | null;
official_url: string | null;
cover_image_url: string | null;
cover_image_manual: boolean;
start_time: string | null;
cluster_schedule: string | null;
bib_pickup: string | null;

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
import { requireAuth } from "../authMiddleware";
const router = Router();
const router: Router = Router();
router.use(requireAuth);
type ValidationErrorBody = {
@@ -136,12 +136,77 @@ function parseOptionalIntegerQuery(
return { value: parsed };
}
function parseOptionalNumberQuery(
value: unknown,
fieldName: string,
min?: number,
max?: number,
): { value?: number; error?: string } {
if (value == null) {
return {};
}
if (typeof value !== "string" || value.trim() === "") {
return { error: `${fieldName} must be a number` };
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return { error: `${fieldName} must be a number` };
}
if (min != null && parsed < min) {
return { error: `${fieldName} must be between ${min} and ${max}` };
}
if (max != null && parsed > max) {
return { error: `${fieldName} must be between ${min} and ${max}` };
}
return { value: parsed };
}
function parseOptionalDateQuery(value: unknown, fieldName: string): { value?: string; error?: string } {
if (value == null) {
return {};
}
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return { error: `${fieldName} must be YYYY-MM-DD` };
}
const parsed = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
return { error: `${fieldName} must be a valid calendar date` };
}
return { value };
}
/* ─── GET /races ──────────────────────────────────────────── */
router.get("/races", async (req: Request, res: Response) => {
const yearResult = parseOptionalIntegerQuery(req.query.year, "year");
const monthResult = parseOptionalIntegerQuery(req.query.month, "month", 1, 12);
const details = [yearResult.error, monthResult.error].filter(Boolean) as string[];
const statusResult = req.query.status == null
? {}
: typeof req.query.status === "string" && ["planned", "registered", "completed"].includes(req.query.status)
? { value: req.query.status }
: { error: "status must be planned, registered or completed" };
const dateFromResult = parseOptionalDateQuery(req.query.dateFrom, "dateFrom");
const dateToResult = parseOptionalDateQuery(req.query.dateTo, "dateTo");
const distanceMinResult = parseOptionalNumberQuery(req.query.distanceMin, "distanceMin", 0.001, 999.999);
const distanceMaxResult = parseOptionalNumberQuery(req.query.distanceMax, "distanceMax", 0.001, 999.999);
const details = [
yearResult.error,
monthResult.error,
statusResult.error,
dateFromResult.error,
dateToResult.error,
distanceMinResult.error,
distanceMaxResult.error,
].filter(Boolean) as string[];
if (dateFromResult.value && dateToResult.value && dateFromResult.value > dateToResult.value) {
details.push("dateFrom must not be after dateTo");
}
if (distanceMinResult.value != null && distanceMaxResult.value != null && distanceMinResult.value > distanceMaxResult.value) {
details.push("distanceMin must not be greater than distanceMax");
}
if (details.length > 0) {
validationError(res, details);
@@ -164,6 +229,26 @@ router.get("/races", async (req: Request, res: Response) => {
conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`);
params.push(monthResult.value);
}
if (statusResult.value != null) {
conditions.push(`status = $${idx++}`);
params.push(statusResult.value);
}
if (dateFromResult.value != null) {
conditions.push(`race_date >= $${idx++}`);
params.push(dateFromResult.value);
}
if (dateToResult.value != null) {
conditions.push(`race_date <= $${idx++}`);
params.push(dateToResult.value);
}
if (distanceMinResult.value != null) {
conditions.push(`distance_km >= $${idx++}`);
params.push(distanceMinResult.value);
}
if (distanceMaxResult.value != null) {
conditions.push(`distance_km <= $${idx++}`);
params.push(distanceMaxResult.value);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`;
@@ -220,6 +305,8 @@ router.post("/races", async (req: Request, res: Response) => {
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
columns.unshift("owner_user_id");
values.unshift(req.auth!.user.id);
columns.push("cover_image_manual");
values.push(hasManualCover);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -251,15 +338,43 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
validationError(res, schemaDetails(parsed.error));
return;
}
const { columns, values } = bodyToColumns(parsed.data as Record<string, unknown>);
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(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 *`;
try {
const currentResult = await pool.query<Pick<RaceRow, "official_url" | "cover_image_url" | "cover_image_manual">>(
"SELECT official_url, cover_image_url, cover_image_manual FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (currentResult.rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
return;
}
const payload = { ...parsed.data };
const officialUrlChanged = Object.prototype.hasOwnProperty.call(payload, "officialUrl");
const current = currentResult.rows[0]!;
const hasCoverField = Object.prototype.hasOwnProperty.call(payload, "coverImageUrl");
const sameAsExistingCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl === current.cover_image_url;
const manualCoverChanged = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "" && !sameAsExistingCover;
let coverImageManual = current.cover_image_manual;
if (hasCoverField && !sameAsExistingCover) {
coverImageManual = manualCoverChanged;
}
const officialUrl = payload.officialUrl === undefined ? current.official_url : payload.officialUrl;
if (officialUrlChanged && !current.cover_image_manual && !manualCoverChanged && typeof officialUrl === "string" && officialUrl.trim() !== "") {
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
coverImageManual = false;
}
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
if (hasCoverField || officialUrlChanged) {
columns.push("cover_image_manual");
values.push(coverImageManual);
}
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(id);
values.push(req.auth!.user.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
const { rows } = await pool.query<RaceRow>(sql, values);
if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });

View File

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

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { after, test } from "node:test";
import request from "supertest";
import { buildHelmetOptions, createApp } from "../src/app";
import { backfillRaceCovers } from "../src/backfillRaceCovers";
import {
cleanupExpiredAuthRows,
createResetToken,
@@ -64,6 +65,12 @@ async function createUser(email: string, password: string, verified: boolean) {
return inserted.rows[0].id;
}
async function loginAgent(email: string, password: string) {
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
@@ -155,6 +162,29 @@ test("GET /api/races accepts year and month", async () => {
assert.ok(Array.isArray(res.body));
});
test("GET /api/races filters by status, date and distance", async () => {
const { agent, csrfToken } = await authAgent();
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-05-01-filter-planned", date: "2026-05-01", title: "Planned 5", distanceKm: 5, status: "planned",
}).expect(201);
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-01-filter-completed", date: "2026-06-01", title: "Completed 10", distanceKm: 10, status: "completed",
}).expect(201);
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-07-01-filter-completed", date: "2026-07-01", title: "Completed 21", distanceKm: 21.1, status: "completed",
}).expect(201);
const res = await agent.get("/api/races?status=completed&dateFrom=2026-06-01&dateTo=2026-06-30&distanceMax=10").expect(200);
assert.deepEqual(res.body.map((race: { title: string }) => race.title), ["Completed 10"]);
});
test("GET /api/races rejects inverted filter ranges", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races?dateFrom=2026-07-01&dateTo=2026-06-01").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(res.body.details.some((detail: string) => detail.includes("dateFrom")));
});
test("GET /api/races/:id rejects a non-UUID id", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races/does-not-exist").expect(400);
@@ -167,6 +197,69 @@ test("GET /api/races requires authentication", async () => {
assert.equal(res.body.error, "unauthorized");
});
test("public profile uses its username and separate visibility settings", async () => {
const { agent } = await authAgent();
const user = await agent.get("/api/auth/me").expect(200);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-08-01-planned-race",
date: "2026-08-01",
title: "Planned Race",
distanceKm: 10,
})
.expect(201);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-07-01-completed-race",
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
finishTime: "00:40:00",
finishPlace: "12",
notes: "Private note",
})
.expect(201);
await request(app).get("/api/users/public-runner/races").expect(404);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ profileUsername: "public-runner", isFutureRacesPublic: true, isCompletedRacesPublic: false })
.expect(200);
const futureRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(futureRaces.body, [{
date: "2026-08-01",
title: "Planned Race",
distanceKm: 10,
status: null,
coverImageUrl: null,
finishTime: null,
finishPlace: null,
}]);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isFutureRacesPublic: false, isCompletedRacesPublic: true })
.expect(200);
const completedRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(completedRaces.body, [{
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
coverImageUrl: null,
finishTime: "00:40:00",
finishPlace: "12",
}]);
});
test("login uses generic response for missing user and wrong password", async () => {
const password = "correct horse battery staple";
await createVerifiedUser("generic@example.com", password);
@@ -183,6 +276,76 @@ test("login uses generic response for missing user and wrong password", async ()
assert.deepEqual(missingUser.body, wrongPassword.body);
});
test("account session endpoints list and revoke only the current user's sessions", async () => {
userCounter += 1;
const email = `sessions${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
const listed = await first.agent.get("/api/auth/sessions").expect(200);
assert.equal(listed.body.sessions.length, 2);
assert.ok(listed.body.sessions.every((session: { tokenHash?: unknown }) => session.tokenHash === undefined));
const other = listed.body.sessions.find((session: { current: boolean }) => !session.current);
assert.ok(other);
const outsider = await authAgent();
const foreign = (await outsider.agent.get("/api/auth/sessions").expect(200)).body.sessions[0];
await first.agent
.delete(`/api/auth/sessions/${foreign.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(404);
await first.agent
.delete(`/api/auth/sessions/${other.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(204);
await second.agent.get("/api/auth/me").expect(401);
await first.agent.get("/api/auth/sessions").expect(200).then((res) => assert.equal(res.body.sessions.length, 1));
});
test("password change checks the current password and revokes other sessions", async () => {
userCounter += 1;
const email = `password${userCounter}@example.com`;
const password = "correct horse battery staple";
const nextPassword = "another correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: "wrong password", newPassword: nextPassword })
.expect(400);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: password, newPassword: nextPassword })
.expect(200);
await second.agent.get("/api/auth/me").expect(401);
await request(app).post("/api/auth/login").send({ email, password }).expect(401);
await request(app).post("/api/auth/login").send({ email, password: nextPassword }).expect(200);
});
test("revoke other sessions retains the current session", async () => {
userCounter += 1;
const email = `revoke-others${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/sessions/revoke-others")
.set("X-CSRF-Token", first.csrfToken)
.expect(200);
await first.agent.get("/api/auth/me").expect(200);
await second.agent.get("/api/auth/me").expect(401);
});
test("GET /api/races/:id returns not_found for another user's race", async () => {
const first = await authAgent();
const created = await first.agent
@@ -321,6 +484,39 @@ test("POST /api/races stores manual coverImageUrl", async () => {
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("PATCH /api/races refreshes an automatic cover but preserves a manual cover", async () => {
const originalFetch = globalThis.fetch;
let imageName = "first.jpg";
globalThis.fetch = async () => new Response(`<meta property="og:image" content="/${imageName}">`, {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
const created = await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-06-auto-refresh", date: "2026-06-06", title: "Auto Refresh", distanceKm: 10, officialUrl: "https://example.com/first",
}).expect(201);
imageName = "second.jpg";
const refreshed = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/second",
}).expect(200);
assert.equal(refreshed.body.coverImageUrl, "https://example.com/second.jpg");
const manual = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/third", coverImageUrl: "https://example.com/manual.jpg",
}).expect(200);
imageName = "fourth.jpg";
const preserved = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
officialUrl: "https://example.com/fourth",
}).expect(200);
assert.equal(manual.body.coverImageUrl, "https://example.com/manual.jpg");
assert.equal(preserved.body.coverImageUrl, "https://example.com/manual.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races rejects unknown and malformed fields", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
@@ -387,6 +583,58 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () =>
}
});
test("PATCH /api/races auto extracts a missing cover when officialUrl changes", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("", { status: 404 });
try {
const { agent, csrfToken } = await authAgent();
const created = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({ slug: "2026-06-04-patch-cover", date: "2026-06-04", title: "Patch Cover", distanceKm: 10 })
.expect(201);
globalThis.fetch = async () => new Response('<meta property="og:image" content="/patched.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
const updated = await agent
.patch(`/api/races/${created.body.id}`)
.set("X-CSRF-Token", csrfToken)
.send({ officialUrl: "https://example.com/patched" })
.expect(200);
assert.equal(updated.body.coverImageUrl, "https://example.com/patched.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover backfill restores only races without a cover", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response('<meta property="og:image" content="/backfill.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const { agent, csrfToken } = await authAgent();
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
slug: "2026-06-05-backfill", date: "2026-06-05", title: "Backfill", distanceKm: 10,
}).expect(201);
await pool.query("UPDATE races SET official_url = $1 WHERE slug = $2", ["https://example.com/backfill", "2026-06-05-backfill"]);
const stats = await backfillRaceCovers();
assert.ok(stats.candidates >= 1);
assert.ok(stats.restored >= 1);
const result = await pool.query<{ cover_image_url: string }>("SELECT cover_image_url FROM races WHERE slug = $1", ["2026-06-05-backfill"]);
assert.equal(result.rows[0]?.cover_image_url, "https://example.com/backfill.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races succeeds when cover extraction fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {

View File

@@ -15,6 +15,48 @@ SPA обращается к относительному префиксу `/api`
передавать в заголовке `X-CSRF-Token`. Cookie передаётся браузером автоматически;
при отдельном origin запрос должен использовать credentials.
## Аккаунт и сессии
Все маршруты этого раздела требуют авторизации и подтверждённого email. Для
изменяющих состояние запросов обязателен `X-CSRF-Token`.
### `GET /api/auth/sessions`
Возвращает активные сессии текущего пользователя. Хеши и сами токены не
возвращаются.
```json
{
"sessions": [{
"id": "UUID",
"createdAt": "ISO datetime",
"lastSeenAt": "ISO datetime",
"expiresAt": "ISO datetime",
"current": true
}]
}
```
### `POST /api/auth/password`
```json
{ "currentPassword": "…", "newPassword": "не менее 15 символов" }
```
Проверяет текущий пароль, сохраняет новый и отзывает все остальные активные
сессии. Успех: `200 { "ok": true }`; неверный текущий пароль: `400`
`invalid_current_password`.
### `DELETE /api/auth/sessions/:id`
Отзывает указанную активную сессию текущего пользователя. Успех: `204`. Если
отозвана текущая сессия, cookie очищается. Чужая, неактивная или отсутствующая
сессия отвечает `404`.
### `POST /api/auth/sessions/revoke-others`
Отзывает все активные сессии, кроме текущей. Успех: `200 { "ok": true }`.
## Служебные маршруты
### `GET /api/health`
@@ -43,8 +85,10 @@ Liveness без проверки БД:
### `GET /api/races`
Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные
query-параметры: `year` (целое число) и `month` (112); без результатов возвращается
`[]`.
query-параметры: `year` (целое число), `month` (112), `status` (`planned`,
`registered` или `completed`), `dateFrom`/`dateTo` (`YYYY-MM-DD`) и
`distanceMin`/`distanceMax` (километры, от `0.001` до `999.999`). Диапазоны не
могут быть перевёрнуты; без результатов возвращается `[]`.
### `GET /api/races/:id`
@@ -82,6 +126,13 @@ query-параметры: `year` (целое число) и `month` (112); б
Удаляет забег. Успех: `204` без тела.
При изменении `officialUrl` backend best-effort получает обложку, если у старта
ещё нет обложки. Ручная `coverImageUrl` не перезаписывается.
Одноразовое восстановление обложек существующих стартов запускается командой
`npm run backfill:covers`. Команда идемпотентна и печатает только агрегированную
статистику.
## Модель `Race`
| Поле | Тип | POST | PATCH | Ограничение |

View File

@@ -6,8 +6,8 @@
| Контур | Версия | Уже работает |
|---|---:|---|
| 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 в футере. |
| Backend | `1.6.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, фильтры истории, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита и восстановление обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
| Frontend | `0.9.0` | Защищённые маршруты и auth-экраны, экран аккаунта, поиск/фильтры/экспорт истории, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и графиком AG Charts, версия FE/BE в футере. |
## Правила версионирования и выпуска
@@ -21,9 +21,9 @@
| Очередь | BE | FE | Цель | Условие выпуска |
|---|---:|---:|---|---|
| P0 | `1.4.2` | `0.7.1` | Исправить риски текущего релиза и привести контракт в соответствие коду. | Нет невалидных записей, SSRF-защита, критические сценарии проверяются на PostgreSQL. |
| P1 | `1.5.0` | `0.8.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
| P2 | `1.6.0` | `0.9.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
| P3 | `1.7.0` | `1.0.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. |
| P1 | `1.6.0` | `0.9.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
| P2 | `1.7.0` | `1.0.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
| P3 | `1.8.0` | `1.1.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. |
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
@@ -51,48 +51,52 @@
| FE-071-05 | P0 | Завершать подтверждение email понятным экраном успеха | После успешной ссылки `/verify-email?token=…` заголовок явно сообщает, что email подтверждён; ввод email, инструкция и кнопка повторной отправки не показываются. Форма повторной отправки остаётся только для недействительной/отсутствующей ссылки. |
| FE-071-06 | P0 | Упростить ввод результата забега | Время вводится отдельными цифровыми полями «часы», «минуты», «секунды» без ручного ввода `:`. Часы — целое число ≥ 0, минуты и секунды — от 0 до 59; пустые минуты/секунды автоматически нормализуются в `0`. Итоговое время не показывает незаполненные старшие части: без часов — `MM:SS`, без часов и минут — `SS`. Место вводится числами «место» и «всего участников» (второе необязательно), проверяется, что место ≥ 1 и не больше общего числа; в текущий `finishPlace` без изменения API сохраняется `место/всего` либо только место. Старые значения `finishPlace` не теряются до редактирования результата. |
## P1 — BE 1.5.0 / FE 0.8.0
### Backend 1.5.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-150-01 | P0 | Управление паролем и сессиями | Авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
| BE-150-03 | P1 | Обратносуместимая фильтрация стартов | Добавить документированные фильтры `status`, диапазон дат и диапазон дистанции, не меняя текущий ответ-массив `GET /races`. Валидация параметров и покрытие индексами — по фактическому query plan. |
### Frontend 0.8.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| 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
## P1 — BE 1.6.0 / FE 0.9.0
### Backend 1.6.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
| BE-150-03 | Готово | Обратносуместимая фильтрация стартов | Добавлены документированные фильтры `status`, диапазон дат и диапазон дистанции без изменения ответа-массива `GET /races`, с валидацией параметров. |
| BE-150-04 | Готово | Восстановить обложки существующих стартов | Добавлен идемпотентный backfill и обновление обложки при изменении официальной страницы. SSRF-защищённая логика переиспользуется; ручные URL не перезаписываются, результат содержит агрегированную статистику. |
### Frontend 0.9.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. |
| FE-080-02 | Готово | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции, поиск по названию и сортировка синхронизированы с URL. Годы в селекторе строятся из данных. |
| FE-080-03 | Готово | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере; сервер не нужен. |
| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. |
| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. |
| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. |
| FE-080-07 | Готово | Вернуть управление официальной страницей старта | Поле «Официальная страница старта» доступно для любого старта, включая прошедший; для прошедших скрываются только неактуальные поля расписания. |
| FE-080-08 | Готово | Перевести график прогресса на AG Charts | Самописный SVG заменён на AG Charts; сохранены данные, оси, tooltip, клавиатурная доступность, переход по точке, адаптивность и пустое состояние. |
## P2 — BE 1.7.0 / FE 1.0.0
### Backend 1.7.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-160-01 | P1 | Чек-листы подготовки к старту | Новая сущность задач старта: текст, состояние, срок и порядок. Задачи принадлежат тому же пользователю, что и старт; каскадно удаляются вместе со стартом. |
| BE-160-02 | P1 | Надёжные email-напоминания | Настройки пользователя и очередь напоминаний хранятся в БД; отправка идемпотентна, ошибки ретраятся ограниченно и логируются без PII. Расписание запуска — внешний cron/worker, а не `setInterval` в каждом web-инстансе. |
| BE-160-03 | P2 | Подписка на персональный iCalendar | Выдать отдельный отзывной секретный URL только для read-only календаря пользователя. Нужны лимитирование, отсутствие email/заметок в ICS и отзыв/ротация токена. |
### Frontend 0.9.0
### Frontend 1.0.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
| FE-090-04 | P2 | Исправить полную TypeScript-проверку frontend | `tsc -b` проходит без ошибки `Cannot find name 'Worker'` в типах Vite; конфигурации browser/node остаются корректно разделены, проверка исходников не ослабляется. |
## P3 — BE 1.7.0 / FE 1.0.0
## P3 — BE 1.8.0 / FE 1.1.0
### Backend 1.7.0
### Backend 1.8.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
@@ -100,7 +104,7 @@
| BE-170-02 | P0 | Наблюдаемость без утечки данных | Структурированные логи с request ID, готовность БД, счётчики ошибок и alert на недоступность. Email, пароли, токены, cookie и текст заметок не логируются. |
| BE-170-03 | P1 | Машиночитаемый API-контракт | OpenAPI-спецификация синхронизирована с маршрутам и используется в CI для проверки ключевых ответов. Это предотвращает повторное расхождение документации и реализации. |
### Frontend 1.0.0
### Frontend 1.1.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
@@ -130,3 +134,14 @@
2. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary.
3. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек.
4. `FE-071-01``FE-071-06`: устранить неверную аналитику и ошибки критических форм, включая понятное завершение подтверждения email и ввод результата.
## Выполненный спринт
**Цель:** пользователь быстро находит старты, восстанавливает данные истории, выгружает результат и видит прогресс в поддерживаемой chart-визуализации.
1. `BE-150-04` + `FE-080-07`: восстановление обложек существующих стартов и управление официальной страницей, включая прошедшие старты. Ручные обложки не перезаписываются; используется существующая SSRF-защищённая логика.
2. `BE-150-03` + `FE-080-02`: фильтры по статусу, периоду и дистанции, поиск по названию, сортировка и синхронизация с URL. Обновить API-документацию и контрактные тесты.
3. `FE-080-03`: экспорт текущей отфильтрованной выборки в CSV и одноразовый `.ics` без нового backend и зависимостей.
4. `FE-080-08`: заменить самописный SVG-график прогресса на AG Charts. Сохранить текущие данные и метрики, подписи осей, tooltip, клавиатурную доступность, переход по точке в старт, адаптивность и пустое состояние. Не менять API и не добавлять новую обёртку поверх `PaceTrendChart`.
`BE-150-02` в спринт не брать до решения: удаление аккаунта сразу или с 30-дневным восстановлением.

View File

@@ -1,13 +1,15 @@
{
"name": "calendar-run-frontend",
"version": "0.7.1",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.7.1",
"version": "0.9.1",
"dependencies": {
"ag-charts-community": "^14.0.0",
"ag-charts-react": "^14.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
@@ -1221,6 +1223,50 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/ag-charts-community": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/ag-charts-community/-/ag-charts-community-14.0.0.tgz",
"integrity": "sha512-u/O7T/EGxj8s6cZjbYU1CaRObnjMzdfXZVido9uXbeAwBkT4rxXF6OWSq1SkkSrZD1+pwusHn3cbul4Ly3yzPA==",
"license": "MIT",
"dependencies": {
"ag-charts-core": "14.0.0",
"ag-charts-locale": "14.0.0",
"ag-charts-types": "14.0.0"
}
},
"node_modules/ag-charts-core": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/ag-charts-core/-/ag-charts-core-14.0.0.tgz",
"integrity": "sha512-xcFSQ0Ces8JvH5RpKoZIjrFzdvju91gIXODj33Byg6ShwkixrHyLaCf17addfSsqerSSdMkw0iiceD9afU83UQ==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "14.0.0"
}
},
"node_modules/ag-charts-locale": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/ag-charts-locale/-/ag-charts-locale-14.0.0.tgz",
"integrity": "sha512-14mGnsQgG7VzdQwt7Jq2MFPGPtO8y6jAECN4nae7t3fJAPq162mmrgDaqpwHdAZxKPkMNte98DUQ8ULrtSbUpQ==",
"license": "MIT"
},
"node_modules/ag-charts-react": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/ag-charts-react/-/ag-charts-react-14.0.0.tgz",
"integrity": "sha512-PTwfdmyFbePzaKHuGWfB2o8KNXO5wR5fOCyUCRqdUNnCEK8cBPwlDsfe28CRsevQSLPZqTNpW2y5qJhHC4q6EQ==",
"license": "MIT",
"dependencies": {
"ag-charts-community": "14.0.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/ag-charts-types": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.0.tgz",
"integrity": "sha512-89X4J/OeOTzTp505126JayGEn3k6QUKz3UHSR0/ggoVZYzVqtMN13k/B136S1+YBwgnzyJ/NARnnIRTPWsWG9g==",
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.7.1",
"version": "0.9.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"ag-charts-community": "^14.0.0",
"ag-charts-react": "^14.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"

View File

@@ -1,5 +1,5 @@
import { requestJson, setCsrfToken } from "./http";
import type { AuthUser } from "./types";
import type { AuthSession, AuthUser } from "./types";
interface AuthResponse {
user: AuthUser;
@@ -67,3 +67,34 @@ export async function resetPassword(token: string, password: string): Promise<vo
body: JSON.stringify({ token, password }),
});
}
export async function getSessions(): Promise<AuthSession[]> {
const response = await requestJson<{ sessions: AuthSession[] }>("/auth/sessions");
return response.sessions;
}
export async function changePassword(payload: { currentPassword: string; newPassword: string }): Promise<void> {
await requestJson<void>("/auth/password", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function revokeSession(sessionId: string): Promise<void> {
await requestJson<void>(`/auth/sessions/${sessionId}`, { method: "DELETE" });
}
export async function revokeOtherSessions(): Promise<void> {
await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" });
}
export async function updateProfile(payload: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
}): Promise<void> {
await requestJson<void>("/auth/profile", {
method: "PATCH",
body: JSON.stringify(payload),
});
}

View File

@@ -3,6 +3,7 @@ export type ApiErrorCode =
| "not_found"
| "database_unavailable"
| "conflict"
| "username_taken"
| "unauthorized"
| "email_not_verified"
| "csrf_error"
@@ -42,6 +43,7 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "not_found" ||
value === "database_unavailable" ||
value === "conflict" ||
value === "username_taken" ||
value === "unauthorized" ||
value === "email_not_verified" ||
value === "csrf_error" ||
@@ -110,6 +112,8 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Сервис временно недоступен. Попробуйте позже.";
case "conflict":
return "Запись с таким идентификатором уже существует.";
case "username_taken":
return "Это имя пользователя уже занято.";
case "unauthorized":
return "Нужно войти в аккаунт.";
case "email_not_verified":

View File

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

View File

@@ -47,24 +47,25 @@ function normalizeRace(input: unknown): Race {
});
}
const validRace = race as Race;
return {
id: race.id,
slug: race.slug,
date: race.date,
title: race.title,
distanceKm: race.distanceKm,
status: race.status,
officialUrl: race.officialUrl,
coverImageUrl: race.coverImageUrl ?? null,
startTime: race.startTime,
clusterSchedule: race.clusterSchedule,
bibPickup: race.bibPickup,
bibNumber: race.bibNumber,
finishTime: race.finishTime,
finishPlace: race.finishPlace,
notes: race.notes,
createdAt: race.createdAt,
updatedAt: race.updatedAt,
id: validRace.id,
slug: validRace.slug,
date: validRace.date,
title: validRace.title,
distanceKm: validRace.distanceKm,
status: validRace.status,
officialUrl: validRace.officialUrl,
coverImageUrl: validRace.coverImageUrl ?? null,
startTime: validRace.startTime,
clusterSchedule: validRace.clusterSchedule,
bibPickup: validRace.bibPickup,
bibNumber: validRace.bibNumber,
finishTime: validRace.finishTime,
finishPlace: validRace.finishPlace,
notes: validRace.notes,
createdAt: validRace.createdAt,
updatedAt: validRace.updatedAt,
};
}
@@ -80,6 +81,21 @@ function buildRacesQuery(query?: RacesQuery): string {
if (typeof query.month === "number") {
params.set("month", String(query.month));
}
if (query.status) {
params.set("status", query.status);
}
if (query.dateFrom) {
params.set("dateFrom", query.dateFrom);
}
if (query.dateTo) {
params.set("dateTo", query.dateTo);
}
if (typeof query.distanceMin === "number") {
params.set("distanceMin", String(query.distanceMin));
}
if (typeof query.distanceMax === "number") {
params.set("distanceMax", String(query.distanceMax));
}
const serialized = params.toString();
return serialized ? `?${serialized}` : "";

View File

@@ -23,6 +23,11 @@ export interface Race {
export interface RacesQuery {
year?: number;
month?: number;
status?: RaceStatus;
dateFrom?: string;
dateTo?: string;
distanceMin?: number;
distanceMax?: number;
}
export interface CreateRacePayload {
@@ -48,4 +53,25 @@ export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}
export interface PublicRace {
date: string;
title: string;
distanceKm: number;
status: RaceStatus | null;
coverImageUrl: string | null;
finishTime: string | null;
finishPlace: string | null;
}
export interface AuthSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}

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

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

View File

@@ -38,9 +38,19 @@ export function AppLayout(): JSX.Element {
+ Добавить
</NavLink>
{user ? (
<button className="app-shell__link app-shell__link--button" type="button" onClick={() => void logout()}>
Выйти
</button>
<>
<NavLink
to="/account"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Аккаунт
</NavLink>
<button className="app-shell__link app-shell__link--button" type="button" onClick={() => void logout()}>
Выйти
</button>
</>
) : (
<NavLink
to="/login"

View File

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

View File

@@ -1,92 +1,114 @@
import { AgCharts } from "ag-charts-react";
import type { AgChartOptions } from "ag-charts-community";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
type PaceTrendChartProps = {
races: Race[];
distanceKm: number;
};
/** Линейный график: время финиша (минуты) по завершённым стартам выбранной дистанции. */
export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
const { races, distanceKm } = props;
type PaceDatum = {
id: string;
date: string;
dateLabel: string;
minutes: number;
race: Race;
};
const series = races
.filter(
(race) =>
race.status === "completed" &&
isCloseDistance(race.distanceKm, distanceKm) &&
parseFinishTimeToSeconds(race.finishTime) != null,
)
.sort(
(a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime(),
)
.map((race) => {
const seconds = parseFinishTimeToSeconds(race.finishTime)!;
return { race, minutes: seconds / 60 };
});
function formatAxisTime(minutes: number): string {
const totalSeconds = Math.round(minutes * 60);
const hours = Math.floor(totalSeconds / 3600);
const rest = totalSeconds % 3600;
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
}
function formatShortDate(date: string): string {
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
function cssToken(name: string, fallback: string): string {
if (typeof window === "undefined") {
return fallback;
}
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
const navigate = useNavigate();
const series = useMemo<PaceDatum[]>(
() => races
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
.map((race) => ({
id: race.id,
date: race.date,
dateLabel: formatShortDate(race.date),
minutes: parseFinishTimeToSeconds(race.finishTime)! / 60,
race,
})),
[distanceKm, races],
);
const options = useMemo<AgChartOptions<PaceDatum>>(() => ({
data: series,
autoSize: true,
minHeight: 280,
padding: { top: 16, right: 20, bottom: 16, left: 16 },
background: { fill: "transparent" },
animation: { enabled: false },
keyboard: { enabled: true, tabIndex: 0, initialFocus: "data-start" },
axes: {
x: { type: "category", position: "bottom", title: { text: "Дата старта" } },
y: {
type: "number",
position: "left",
title: { text: "Время" },
label: { formatter: ({ value }) => formatAxisTime(Number(value)) },
},
},
series: [{
type: "line",
xKey: "dateLabel",
xName: "Дата старта",
yKey: "minutes",
yName: "Время",
stroke: cssToken("--color-accent", "#1168d8"),
strokeWidth: 3,
marker: {
enabled: true,
size: 8,
fill: cssToken("--color-surface", "#ffffff"),
stroke: cssToken("--color-accent", "#1168d8"),
strokeWidth: 2,
},
tooltip: {
renderer: ({ datum }) => ({
title: datum.race.title,
data: [
{ label: "Дата", value: formatRaceDate(datum.date) },
{ label: "Время", value: datum.race.finishTime ?? "—" },
{ label: "Темп", value: getPaceLabel(datum.race.finishTime, datum.race.distanceKm) ?? "—" },
],
}),
},
}],
listeners: {
seriesNodeClick: ({ datum }) => navigate(`/races/${datum.race.id}`),
},
}), [navigate, series]);
if (series.length < 2) {
return (
<p className="pace-chart__empty">
Нужно минимум два завершённых старта с временем на выбранной дистанции.
</p>
);
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
}
const minutes = series.map((s) => s.minutes);
const minM = Math.min(...minutes);
const maxM = Math.max(...minutes);
const range = maxM - minM || 1;
const n = series.length;
const pad = 4;
const w = 100;
const h = 36;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const points = series
.map((s, i) => {
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const norm = (maxM - s.minutes) / range;
const y = pad + (1 - norm) * innerH;
return `${x},${y}`;
})
.join(" ");
const last = series[series.length - 1]!;
const best = series.reduce((currentBest, item) => (item.minutes < currentBest.minutes ? item : currentBest), series[0]!);
const dotPoints = series.map((s, i) => {
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const norm = (maxM - s.minutes) / range;
const y = pad + (1 - norm) * innerH;
return { x, y, id: s.race.id };
});
return (
<div className="pace-chart">
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
<line className="pace-chart__grid-line" x1={pad} y1={pad} x2={w - pad} y2={pad} />
<line className="pace-chart__grid-line" x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} />
<polyline className="pace-chart__line" fill="none" points={points} />
{dotPoints.map((point, index) => (
<circle
key={point.id}
className={index === dotPoints.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"}
cx={point.x}
cy={point.y}
r="1.6"
/>
))}
</svg>
<div className="pace-chart__stats">
<p className="pace-chart__caption">
Последний: {formatRaceDate(last.race.date)} · {last.race.finishTime} · {last.minutes.toFixed(1)} мин
</p>
<p className="pace-chart__caption pace-chart__caption--best">
Лучший: {formatRaceDate(best.race.date)} · {best.race.finishTime} · {best.minutes.toFixed(1)} мин
</p>
</div>
<div className="pace-chart" role="group" aria-label="Динамика времени на дистанции. Выберите точку, чтобы открыть старт.">
<AgCharts options={options} />
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
</div>
);
}

View File

@@ -19,3 +19,4 @@ export {
export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";
export { getRaceVisual } from "./raceVisuals";
export type { RaceVisualVariant } from "./raceVisuals";
export { downloadTextFile, racesToCsv, racesToIcs } from "./raceExport";

View File

@@ -0,0 +1,60 @@
import type { Race } from "../api";
function escapeCsvCell(value: string | number | null): string {
const text = value == null ? "" : String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function escapeIcsText(value: string): string {
return value.replace(/[\\;,\n]/g, (character) => ({ "\\": "\\\\", ";": "\\;", ",": "\\,", "\n": "\\n" })[character]!);
}
function toIcsDate(date: string): string {
return date.slice(0, 10).replace(/-/g, "");
}
function addOneDay(date: string): string {
const next = new Date(`${date.slice(0, 10)}T00:00:00Z`);
next.setUTCDate(next.getUTCDate() + 1);
return next.toISOString().slice(0, 10);
}
export function racesToCsv(races: Race[]): string {
const headers = ["Дата", "Название", "Дистанция (км)", "Статус", "Время", "Место", "Официальная страница"];
const rows = races.map((race) => [
race.date,
race.title,
race.distanceKm,
race.status,
race.finishTime,
race.finishPlace,
race.officialUrl,
]);
return `\uFEFF${[headers, ...rows].map((row) => row.map(escapeCsvCell).join(",")).join("\r\n")}\r\n`;
}
export function racesToIcs(races: Race[]): string {
const events = races.map((race) => {
const start = race.date.slice(0, 10);
const end = addOneDay(start);
return [
"BEGIN:VEVENT",
`UID:${race.id}@calendar-run`,
`DTSTART;VALUE=DATE:${toIcsDate(start)}`,
`DTEND;VALUE=DATE:${toIcsDate(end)}`,
`SUMMARY:${escapeIcsText(race.title)}`,
`DESCRIPTION:${escapeIcsText(`${race.distanceKm} км${race.officialUrl ? `\n${race.officialUrl}` : ""}`)}`,
"END:VEVENT",
].join("\r\n");
});
return ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Calendar Run//Races//RU", ...events, "END:VCALENDAR", ""].join("\r\n");
}
export function downloadTextFile(filename: string, content: string, type: string): void {
const url = URL.createObjectURL(new Blob([content], { type }));
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components";
@@ -35,10 +35,14 @@ function toCssUrl(value: string): string {
}
export function DashboardPage(): JSX.Element {
const navigate = useNavigate();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
const [latestDistance, setLatestDistance] = useState<string>("");
const [latestYear, setLatestYear] = useState<string>("");
const [latestPage, setLatestPage] = useState(0);
useEffect(() => {
const ac = new AbortController();
@@ -130,6 +134,7 @@ export function DashboardPage(): JSX.Element {
.filter((race) => race.status === "completed")
.map((race) => ({
id: race.id,
date: race.date,
year: parseRaceDate(race.date).getFullYear(),
title: race.title,
distance: formatDistance(race.distanceKm),
@@ -137,9 +142,20 @@ export function DashboardPage(): JSX.Element {
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
place: race.finishPlace?.trim() ? race.finishPlace : "нет данных",
}))
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
.sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}, [races]);
const latestDistances = useMemo(
() => [...new Set(comparisonRows.map((row) => row.distance))].sort((left, right) => Number(left.split(" ")[0]) - Number(right.split(" ")[0])),
[comparisonRows],
);
const latestYears = useMemo(() => [...new Set(comparisonRows.map((row) => row.year))].sort((a, b) => b - a), [comparisonRows]);
const filteredLatestRows = comparisonRows.filter(
(row) => (!latestDistance || row.distance === latestDistance) && (!latestYear || row.year === Number(latestYear)),
);
const latestPageCount = Math.ceil(filteredLatestRows.length / 5);
const latestRows = filteredLatestRows.slice(latestPage * 5, latestPage * 5 + 5);
const seasonProgress =
dashboardMetrics.seasonTotal > 0
? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100)
@@ -316,26 +332,50 @@ export function DashboardPage(): JSX.Element {
<h2 className="dashboard-section__title">Рекорды по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card">
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<article key={item.distanceKm} className={`dashboard-card${item.bestRace ? " dashboard-card--linked" : ""}`}>
{item.bestRace ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${item.bestRace.id}`}
aria-label={`Рекорд на ${formatDistance(item.distanceKm)}: ${item.bestRace.title}`}
>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__value">{item.bestRace.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">{item.bestRace.title}</p>
<p className="dashboard-card__hint">{formatRaceDate(item.bestRace.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
<>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
</>
)}
</article>
))}
</div>
</section>
<section className="dashboard-section" aria-label="Сравнение завершённых стартов">
<h2 className="dashboard-section__title">Сравнение стартов</h2>
<section className="dashboard-section" aria-label="Последние завершённые старты">
<h2 className="dashboard-section__title">Последние старты</h2>
{comparisonRows.length > 0 ? (
<div className="comparison-table-wrapper">
<>
<div className="dashboard-latest__filters" aria-label="Фильтры последних стартов">
<label className="races-filter__field">
<span className="races-filter__label">Дистанция</span>
<select className="races-filter__select" value={latestDistance} onChange={(event) => { setLatestDistance(event.target.value); setLatestPage(0); }}>
<option value="">Все дистанции</option>
{latestDistances.map((distance) => <option key={distance} value={distance}>{distance}</option>)}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Год участия</span>
<select className="races-filter__select" value={latestYear} onChange={(event) => { setLatestYear(event.target.value); setLatestPage(0); }}>
<option value="">Все годы</option>
{latestYears.map((year) => <option key={year} value={year}>{year}</option>)}
</select>
</label>
</div>
<div className="comparison-table-wrapper">
<table className="comparison-table">
<thead>
<tr>
@@ -348,8 +388,20 @@ export function DashboardPage(): JSX.Element {
</tr>
</thead>
<tbody>
{comparisonRows.map((row) => (
<tr key={row.id}>
{latestRows.map((row) => (
<tr
key={row.id}
className="comparison-table__row"
tabIndex={0}
onClick={() => navigate(`/races/${row.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigate(`/races/${row.id}`);
}
}}
aria-label={`Открыть старт: ${row.title}`}
>
<td>{row.year}</td>
<td>{row.title}</td>
<td>{row.distance}</td>
@@ -360,7 +412,16 @@ export function DashboardPage(): JSX.Element {
))}
</tbody>
</table>
</div>
</div>
{filteredLatestRows.length === 0 ? <p className="dashboard-card__empty">По выбранным фильтрам стартов нет.</p> : null}
{latestPageCount > 1 ? (
<nav className="dashboard-latest__pagination" aria-label="Страницы последних стартов">
<button className="btn btn--secondary" type="button" disabled={latestPage === 0} onClick={() => setLatestPage((page) => page - 1)}>Назад</button>
<span aria-live="polite">Страница {latestPage + 1} из {latestPageCount}</span>
<button className="btn btn--secondary" type="button" disabled={latestPage === latestPageCount - 1} onClick={() => setLatestPage((page) => page + 1)}>Вперёд</button>
</nav>
) : null}
</>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для сравнения.</p>
)}

View File

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

View File

@@ -412,19 +412,17 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group">
<legend className="race-form__legend">Организация</legend>
{hideOrgScheduleFields ? null : (
<label className="race-form__field">
<span className="race-form__label">Сайт организатора</span>
<input
className="race-form__input"
type="url"
name="officialUrl"
value={form.officialUrl}
onChange={handleChange}
placeholder="https://…"
/>
</label>
)}
<label className="race-form__field">
<span className="race-form__label">Сайт организатора</span>
<input
className="race-form__input"
type="url"
name="officialUrl"
value={form.officialUrl}
onChange={handleChange}
placeholder="https://…"
/>
</label>
<label className="race-form__field">
<span className="race-form__label">URL обложки</span>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race, RacesQuery } from "../api";
import { Link, useSearchParams } from "react-router-dom";
import type { Race, RaceStatus, RacesQuery } from "../api";
import { ApiError, getRaces } from "../api";
import { RacesCalendar } from "../components/RacesCalendar";
import {
@@ -10,8 +10,9 @@ import {
getRaceStatusClassName,
getRaceStatusLabel,
parseRaceDate,
sortByDateAsc,
sortByDateDesc,
downloadTextFile,
racesToCsv,
racesToIcs,
} from "../lib";
const MONTH_OPTIONS: { value: string; label: string }[] = [
@@ -34,16 +35,12 @@ const VIEW_STORAGE_KEY = "races-view-mode";
type ViewMode = "list" | "calendar";
type RaceListTab = "upcoming" | "completed";
type RaceSort = "dateAsc" | "dateDesc" | "titleAsc" | "distanceAsc";
function yearSelectOptions(): number[] {
function yearSelectOptions(races: Race[], selectedYear = ""): number[] {
const current = new Date().getFullYear();
const start = current - 2;
const end = current + 4;
const years: number[] = [];
for (let y = start; y <= end; y += 1) {
years.push(y);
}
return years;
const selected = Number(selectedYear);
return [...new Set([current, ...races.map((race) => parseRaceDate(race.date).getFullYear()), ...(Number.isInteger(selected) ? [selected] : [])])].sort((a, b) => a - b);
}
function getErrorMessage(error: unknown): string {
@@ -133,12 +130,20 @@ function RaceList(props: { title: string; races: Race[]; variant: RaceListTab })
}
export function RacesPage(): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [yearFilter, setYearFilter] = useState<string>("");
const [monthFilter, setMonthFilter] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>(() => readInitialViewMode());
const [yearFilter, setYearFilter] = useState<string>(() => searchParams.get("year") ?? "");
const [monthFilter, setMonthFilter] = useState<string>(() => searchParams.get("month") ?? "");
const [statusFilter, setStatusFilter] = useState<RaceStatus | "">(() => (searchParams.get("status") as RaceStatus | null) ?? "");
const [dateFrom, setDateFrom] = useState<string>(() => searchParams.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState<string>(() => searchParams.get("dateTo") ?? "");
const [distanceMin, setDistanceMin] = useState<string>(() => searchParams.get("distanceMin") ?? "");
const [distanceMax, setDistanceMax] = useState<string>(() => searchParams.get("distanceMax") ?? "");
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get("q") ?? "");
const [sortOrder, setSortOrder] = useState<RaceSort>(() => (searchParams.get("sort") as RaceSort | null) ?? "dateAsc");
const [viewMode, setViewMode] = useState<ViewMode>(() => searchParams.get("view") === "calendar" ? "calendar" : readInitialViewMode());
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
const setViewModePersist = useCallback((mode: ViewMode) => {
@@ -160,28 +165,45 @@ export function RacesPage(): JSX.Element {
}, [setViewModePersist]);
const listQuery = useMemo((): RacesQuery | undefined => {
const q: RacesQuery = {};
if (viewMode === "calendar") {
const y = yearFilter !== "" ? parseInt(yearFilter, 10) : new Date().getFullYear();
if (!Number.isNaN(y)) {
return { year: y };
}
return undefined;
}
const q: RacesQuery = {};
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
if (!Number.isNaN(y)) {
q.year = y;
}
}
if (monthFilter !== "") {
const m = parseInt(monthFilter, 10);
if (!Number.isNaN(m)) {
q.month = m;
} else {
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
if (!Number.isNaN(y)) {
q.year = y;
}
}
if (monthFilter !== "") {
const m = parseInt(monthFilter, 10);
if (!Number.isNaN(m)) {
q.month = m;
}
}
}
if (statusFilter) {
q.status = statusFilter;
}
if (dateFrom) {
q.dateFrom = dateFrom;
}
if (dateTo) {
q.dateTo = dateTo;
}
const min = Number(distanceMin);
const max = Number(distanceMax);
if (distanceMin && Number.isFinite(min)) {
q.distanceMin = min;
}
if (distanceMax && Number.isFinite(max)) {
q.distanceMax = max;
}
return Object.keys(q).length > 0 ? q : undefined;
}, [viewMode, yearFilter, monthFilter]);
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, statusFilter, viewMode, yearFilter]);
const displayYear = useMemo(() => {
if (yearFilter !== "") {
@@ -223,12 +245,38 @@ export function RacesPage(): JSX.Element {
};
}, [listQuery]);
useEffect(() => {
const next = new URLSearchParams();
if (viewMode === "calendar") next.set("view", viewMode);
if (yearFilter) next.set("year", yearFilter);
if (monthFilter) next.set("month", monthFilter);
if (statusFilter) next.set("status", statusFilter);
if (dateFrom) next.set("dateFrom", dateFrom);
if (dateTo) next.set("dateTo", dateTo);
if (distanceMin) next.set("distanceMin", distanceMin);
if (distanceMax) next.set("distanceMax", distanceMax);
if (searchQuery) next.set("q", searchQuery);
if (sortOrder !== "dateAsc") next.set("sort", sortOrder);
setSearchParams(next, { replace: true });
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, searchQuery, setSearchParams, sortOrder, statusFilter, viewMode, yearFilter]);
const filteredRaces = useMemo(() => {
const needle = searchQuery.trim().toLocaleLowerCase("ru-RU");
const result = races.filter((race) => !needle || race.title.toLocaleLowerCase("ru-RU").includes(needle));
return result.sort((left, right) => {
if (sortOrder === "titleAsc") return left.title.localeCompare(right.title, "ru");
if (sortOrder === "distanceAsc") return left.distanceKm - right.distanceKm || left.title.localeCompare(right.title, "ru");
const direction = sortOrder === "dateDesc" ? -1 : 1;
return direction * (parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
});
}, [races, searchQuery, sortOrder]);
const { upcoming, completed } = useMemo(
() => ({
upcoming: sortByDateAsc(races.filter((race) => race.status !== "completed")),
completed: sortByDateDesc(races.filter((race) => race.status === "completed")),
upcoming: filteredRaces.filter((race) => race.status !== "completed"),
completed: filteredRaces.filter((race) => race.status === "completed"),
}),
[races],
[filteredRaces],
);
const statusMessage = useMemo(() => {
if (errorMessage && !isLoading) {
@@ -295,7 +343,7 @@ export function RacesPage(): JSX.Element {
}}
>
{viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions().map((y) => (
{yearSelectOptions(races, yearFilter).map((y) => (
<option key={y} value={String(y)}>
{y}
</option>
@@ -318,6 +366,48 @@ export function RacesPage(): JSX.Element {
))}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Статус</span>
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
<option value="">Все статусы</option>
<option value="planned">Планирую</option>
<option value="registered">Зарегистрирован</option>
<option value="completed">Пробежал</option>
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Поиск</span>
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дата от</span>
<input className="races-filter__input" type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дата до</span>
<input className="races-filter__input" type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция от, км</span>
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMin} onChange={(event) => setDistanceMin(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Дистанция до, км</span>
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMax} onChange={(event) => setDistanceMax(event.target.value)} />
</label>
<label className="races-filter__field">
<span className="races-filter__label">Сортировка</span>
<select className="races-filter__select" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as RaceSort)}>
<option value="dateAsc">Дата: сначала ранние</option>
<option value="dateDesc">Дата: сначала поздние</option>
<option value="titleAsc">Название: АЯ</option>
<option value="distanceAsc">Дистанция: по возрастанию</option>
</select>
</label>
</div>
<div className="races-filter__actions">
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.csv", racesToCsv(filteredRaces), "text/csv;charset=utf-8")}>Скачать CSV</button>
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.ics", racesToIcs(filteredRaces), "text/calendar;charset=utf-8")}>Скачать календарь</button>
</div>
</div>
</section>
@@ -367,7 +457,7 @@ export function RacesPage(): JSX.Element {
<RacesCalendar
displayYear={displayYear}
monthFilter={monthFilter}
races={races}
races={filteredRaces}
onMonthFilterChange={setMonthFilter}
/>
</div>

View File

@@ -177,6 +177,44 @@ a {
gap: var(--space-4);
}
.account__success {
margin: 0;
color: var(--color-success);
}
.account__sessions {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.account__session {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.account__session p {
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.account__profile-link {
display: flex;
gap: var(--space-2);
}
.account__profile-link .auth-form__input {
min-width: 0;
}
.dashboard-grid {
margin-top: var(--space-6);
display: grid;
@@ -472,13 +510,40 @@ a {
min-width: 10rem;
}
.comparison-table__row {
cursor: pointer;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
outline: none;
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
transform: translateX(0.2rem);
}
.dashboard-latest__filters,
.dashboard-latest__pagination {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
margin: 0 0 var(--space-4);
}
.dashboard-latest__pagination {
align-items: center;
margin-top: var(--space-4);
}
.races-filter__label {
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--color-text-muted);
}
.races-filter__select {
.races-filter__select,
.races-filter__input {
font: inherit;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
@@ -487,6 +552,13 @@ a {
color: var(--color-text);
}
.races-filter__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
}
.pace-chart {
margin-top: var(--space-4);
border: 1px solid var(--color-border);
@@ -495,20 +567,7 @@ a {
background: #fcfdff;
}
.pace-chart__svg {
display: block;
width: 100%;
max-width: 560px;
height: auto;
}
.pace-chart__line {
stroke: var(--color-accent);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.pace-chart__caption {
.pace-chart__hint {
margin: var(--space-3) 0 0;
font-size: var(--font-size-caption);
color: var(--color-text-muted);
@@ -1475,49 +1534,6 @@ body {
#ffffff;
}
.pace-chart__svg {
max-width: 100%;
}
.pace-chart__grid-line {
stroke: rgba(14, 31, 45, 0.12);
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
}
.pace-chart__line {
stroke-width: 2.2;
}
.pace-chart__dot {
fill: #ffffff;
stroke: var(--color-accent);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.pace-chart__dot--last {
fill: var(--color-lime);
stroke: var(--color-bg-deep);
}
.pace-chart__stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.pace-chart__caption {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.72);
color: var(--color-text);
}
.pace-chart__caption--best {
color: #0e5d3d;
}
.races-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.58fr);
@@ -1561,6 +1577,11 @@ body {
background: rgba(255, 255, 255, 0.94);
}
.races-hero__filters .races-filter__input {
border-color: rgba(255, 255, 255, 0.24);
background: rgba(255, 255, 255, 0.94);
}
.races-view-toggle {
border-color: rgba(255, 255, 255, 0.24);
background: rgba(7, 25, 39, 0.28);
@@ -2039,3 +2060,14 @@ body {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.comparison-table__row {
transition: none;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
transform: none;
}
}