feat: complete account and dashboard sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
37
CHANGELOG.md
37
CHANGELOG.md
@@ -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
|
||||
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
131
frontend/src/pages/AccountPage.tsx
Normal file
131
frontend/src/pages/AccountPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user