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
This commit was merged in pull request #40.
This commit is contained in:
2026-07-12 12:59:09 +00:00
19 changed files with 781 additions and 114 deletions

View File

@@ -7,7 +7,22 @@
## В работе
### Backend 1.4.2
### Backend 1.5.0
#### Added
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
### Frontend 0.8.0
#### Added
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
## Backend
### [1.4.2] — 2026-07-12
#### Changed
@@ -15,17 +30,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 +91,15 @@
## Frontend
### [0.7.1] — 2026-07-12
#### Changed
- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места.
- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки.
- Карточка личного рекорда отображается только при реальном улучшении результата.
- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек.
### [0.7.0] — 2026-05-24
#### Added

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.4.2",
"version": "1.5.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.5.0",
"private": true,
"scripts": {
"build": "tsc",

View File

@@ -37,6 +37,22 @@ interface SessionRow {
email_verified_at: Date | string | null;
}
interface SessionListRow {
id: string;
created_at: Date | string;
last_seen_at: Date | string;
expires_at: Date | string;
current: boolean;
}
export interface ActiveSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}
function toIso(value: Date | string | null): string | null {
if (!value) {
return null;
@@ -198,6 +214,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(

View File

@@ -2,16 +2,20 @@ 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,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, setSessionCookie } from "../authMiddleware";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
import { isValidPassword, normalizeEmail } from "../security";
import { verifyTurnstileToken } from "../turnstile";
@@ -37,6 +41,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 +73,13 @@ 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();
function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
}
@@ -129,6 +147,62 @@ router.get("/auth/me", async (req: Request, res: Response, next) => {
}
});
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

@@ -64,6 +64,12 @@ async function createUser(email: string, password: string, verified: boolean) {
return inserted.rows[0].id;
}
async function loginAgent(email: string, password: string) {
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
@@ -183,6 +189,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

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`

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { requestJson, setCsrfToken } from "./http";
import type { AuthUser } from "./types";
import type { AuthSession, AuthUser } from "./types";
interface AuthResponse {
user: AuthUser;
@@ -67,3 +67,23 @@ 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" });
}

View File

@@ -1,15 +1,19 @@
export type { AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export type { AuthSession, AuthUser, CreateRacePayload, 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 {
forgotPassword,
changePassword,
getCurrentUser,
getSessions,
login,
logout,
register,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSession,
verifyEmail,
} from "./auth";

View File

@@ -49,3 +49,11 @@ export interface AuthUser {
email: string;
emailVerifiedAt: string | null;
}
export interface AuthSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}

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

View File

@@ -0,0 +1,131 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession } 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 } = 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 loadSessions = useCallback(async () => {
setSessionsLoading(true);
setSessionsError("");
try {
setSessions(await getSessions());
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось загрузить активные сессии.");
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setPasswordError("");
setPasswordSuccess("");
if (newPassword !== passwordConfirmation) {
setPasswordError("Новый пароль и его повтор не совпадают.");
return;
}
setPasswordSaving(true);
try {
await changePassword({ currentPassword, newPassword });
setCurrentPassword("");
setNewPassword("");
setPasswordConfirmation("");
setPasswordSuccess("Пароль изменён. Остальные активные сессии завершены.");
await loadSessions();
} catch (error) {
setPasswordError(error instanceof ApiError ? error.message : "Не удалось изменить пароль.");
} finally {
setPasswordSaving(false);
}
}
async function handleSessionRevoke(session: AuthSession): Promise<void> {
try {
await revokeSession(session.id);
if (session.current) {
await logout();
return;
}
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессию.");
}
}
async function handleOtherSessionsRevoke(): Promise<void> {
try {
await revokeOtherSessions();
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессии.");
}
}
return (
<section className="page page--auth">
<h1 className="page__title">Аккаунт</h1>
<p className="page__subtitle">{user?.email} email подтверждён.</p>
<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

@@ -177,6 +177,35 @@ a {
gap: var(--space-4);
}
.account__success {
margin: 0;
color: var(--color-success);
}
.account__sessions {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.account__session {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.account__session p {
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.dashboard-grid {
margin-top: var(--space-6);
display: grid;
@@ -472,6 +501,32 @@ a {
min-width: 10rem;
}
.comparison-table__row {
cursor: pointer;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
outline: none;
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
transform: translateX(0.2rem);
}
.dashboard-latest__filters,
.dashboard-latest__pagination {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
margin: 0 0 var(--space-4);
}
.dashboard-latest__pagination {
align-items: center;
margin-top: var(--space-4);
}
.races-filter__label {
font-size: var(--font-size-caption);
font-weight: 600;
@@ -508,6 +563,57 @@ a {
vector-effect: non-scaling-stroke;
}
.pace-chart__axis {
stroke: rgba(14, 31, 45, 0.42);
stroke-width: 0.75;
vector-effect: non-scaling-stroke;
}
.pace-chart__axis-label,
.pace-chart__axis-title,
.pace-chart__point-label {
fill: var(--color-text-muted);
font-size: 2.5px;
}
.pace-chart__axis-title {
font-size: 2.8px;
font-weight: 700;
}
.pace-chart__point-label {
font-size: 2.25px;
}
.pace-chart__point {
cursor: pointer;
outline: none;
}
.pace-chart__point:focus-visible .pace-chart__dot,
.pace-chart__point:hover .pace-chart__dot {
stroke-width: 2;
}
.pace-chart__tooltip,
.pace-chart__hint {
margin: var(--space-3) 0 0;
font-size: var(--font-size-caption);
}
.pace-chart__tooltip {
display: grid;
gap: var(--space-1);
padding: var(--space-3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.78);
color: var(--color-text);
}
.pace-chart__hint {
color: var(--color-text-muted);
}
.pace-chart__caption {
margin: var(--space-3) 0 0;
font-size: var(--font-size-caption);
@@ -2039,3 +2145,14 @@ body {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.comparison-table__row {
transition: none;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
transform: none;
}
}