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