feat: complete p0 hardening sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
Vakanaut
2026-07-12 15:08:00 +03:00
parent b0ebdeca72
commit a317c9b760
36 changed files with 851 additions and 1608 deletions

View File

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

View File

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

View File

@@ -1,18 +1,9 @@
import { requestJson } from "./http";
export type HealthResponse = {
status: string;
version: string;
};
export type BackendMetaResponse = {
version: string;
};
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
return requestJson<HealthResponse>("/health", init);
}
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
return requestJson<BackendMetaResponse>("/meta", init);

View File

@@ -1,7 +1,7 @@
export type { AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors";
export type { BackendMetaResponse, HealthResponse } from "./health";
export { getBackendMeta, getHealth } from "./health";
export type { BackendMetaResponse } from "./health";
export { getBackendMeta } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
export {
forgotPassword,

View File

@@ -1,197 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { buildMonthCells, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
const MONTH_NAMES_RU = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
interface DatePickerFieldProps {
value: string;
name: string;
required?: boolean;
onChange: (value: string) => void;
}
function parseYmd(value: string): { year: number; monthIndex: number; day: number } | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const year = Number(value.slice(0, 4));
const monthIndex = Number(value.slice(5, 7)) - 1;
const day = Number(value.slice(8, 10));
if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) {
return null;
}
if (monthIndex < 0 || monthIndex > 11) {
return null;
}
return { year, monthIndex, day };
}
function getInitialVisibleMonth(value: string): { year: number; monthIndex: number } {
const parsed = parseYmd(value);
if (parsed) {
return { year: parsed.year, monthIndex: parsed.monthIndex };
}
const now = new Date();
return { year: now.getFullYear(), monthIndex: now.getMonth() };
}
export function DatePickerField(props: DatePickerFieldProps): JSX.Element {
const { value, name, required, onChange } = props;
const [isOpen, setIsOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState(() => getInitialVisibleMonth(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const parsed = parseYmd(value);
if (!parsed) {
return;
}
setVisibleMonth({ year: parsed.year, monthIndex: parsed.monthIndex });
}, [value]);
useEffect(() => {
if (!isOpen) {
return;
}
function handlePointerDown(event: MouseEvent): void {
if (rootRef.current?.contains(event.target as Node)) {
return;
}
setIsOpen(false);
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const selected = parseYmd(value);
const todayYmd = toYmd(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
const cells = useMemo(
() => buildMonthCells(visibleMonth.year, visibleMonth.monthIndex),
[visibleMonth],
);
const monthTitle = `${MONTH_NAMES_RU[visibleMonth.monthIndex]} ${visibleMonth.year}`;
function shiftMonth(delta: number): void {
setVisibleMonth((prev) => {
const next = new Date(Date.UTC(prev.year, prev.monthIndex + delta, 1));
return { year: next.getUTCFullYear(), monthIndex: next.getUTCMonth() };
});
}
return (
<div className="date-picker" ref={rootRef}>
<div className="date-picker__control">
<input
className="race-form__input date-picker__input"
type="text"
inputMode="numeric"
name={name}
value={value}
onChange={(event) => {
onChange(event.target.value);
}}
onFocus={() => setIsOpen(true)}
placeholder="2026-05-03"
autoComplete="off"
required={required}
/>
<button
className="date-picker__toggle"
type="button"
aria-label="Открыть календарь"
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
>
</button>
</div>
{isOpen ? (
<div className="date-picker__popover" role="dialog" aria-label="Выбор даты">
<div className="date-picker__header">
<button
className="date-picker__nav"
type="button"
aria-label="Предыдущий месяц"
onClick={() => shiftMonth(-1)}
>
</button>
<p className="date-picker__title">{monthTitle}</p>
<button
className="date-picker__nav"
type="button"
aria-label="Следующий месяц"
onClick={() => shiftMonth(1)}
>
</button>
</div>
<div className="date-picker__weekdays" aria-hidden>
{WEEKDAY_LABELS_SHORT_RU.map((weekday) => (
<span key={weekday} className="date-picker__weekday">
{weekday}
</span>
))}
</div>
<div className="date-picker__cells">
{cells.map((day, idx) => {
if (day === null) {
return <span key={`empty-${idx}`} className="date-picker__cell date-picker__cell--empty" />;
}
const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day);
const isSelected =
selected?.year === visibleMonth.year &&
selected.monthIndex === visibleMonth.monthIndex &&
selected.day === day;
return (
<button
key={ymd}
className={`date-picker__day${isSelected ? " date-picker__day--selected" : ""}${ymd === todayYmd ? " date-picker__day--today" : ""}`}
type="button"
onClick={() => {
onChange(ymd);
setIsOpen(false);
}}
>
{day}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -1,157 +0,0 @@
import { useCallback, useMemo } from "react";
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function parseToParts(value: string): { h: number | null; m: number | null; s: number | null } {
const t = value.trim();
if (!t) {
return { h: null, m: null, s: null };
}
const parts = t.split(":").map((p) => p.trim());
if (parts.length === 2) {
const h = Number(parts[0]);
const m = Number(parts[1]);
if (Number.isInteger(h) && Number.isInteger(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return { h, m, s: 0 };
}
}
if (parts.length >= 3) {
const h = Number(parts[0]);
const m = Number(parts[1]);
const s = Number(parts[2]);
if (
Number.isInteger(h) &&
Number.isInteger(m) &&
Number.isInteger(s) &&
h >= 0 &&
h <= 23 &&
m >= 0 &&
m <= 59 &&
s >= 0 &&
s <= 59
) {
return { h, m, s };
}
}
return { h: null, m: null, s: null };
}
function partsToString(h: number | null, m: number | null, s: number | null): string {
if (h === null || m === null || s === null) {
return "";
}
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
}
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MIN_SEC = Array.from({ length: 60 }, (_, i) => i);
interface StartTimeSelectsProps {
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
export function StartTimeSelects(props: StartTimeSelectsProps): JSX.Element {
const { value, onChange, disabled } = props;
const { h, m, s } = useMemo(() => parseToParts(value), [value]);
const emit = useCallback(
(nextH: number | null, nextM: number | null, nextS: number | null) => {
onChange(partsToString(nextH, nextM, nextS));
},
[onChange],
);
const hourVal = h === null ? "" : String(h);
const minVal = m === null ? "" : String(m);
const secVal = s === null ? "" : String(s);
return (
<div className="race-form__time-picker">
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Часы</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Часы"
disabled={disabled}
value={hourVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nh = Number(v);
emit(nh, m ?? 0, s ?? 0);
}}
>
<option value=""></option>
{HOURS.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Минуты</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Минуты"
disabled={disabled}
value={minVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nm = Number(v);
emit(h ?? 0, nm, s ?? 0);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Секунды</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Секунды"
disabled={disabled}
value={secVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const ns = Number(v);
emit(h ?? 0, m ?? 0, ns);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
</div>
);
}

View File

@@ -1,4 +1,2 @@
export { DatePickerField } from "./DatePickerField";
export { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";
export { StartTimeSelects } from "./StartTimeSelects";

View File

@@ -1,6 +1,7 @@
export {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getPaceLabel,
getRaceCountdownLabel,
getRaceStatusClassName,

View File

@@ -24,18 +24,29 @@ export function parseFinishTimeToSeconds(value: string | null): number | null {
return null;
}
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
const parts = value.split(":");
if (parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
const values = parts.map(Number);
if (parts.length === 2) {
const [minutes, seconds] = parts;
if (values.length === 1) {
return values[0]! <= 59 ? values[0]! : null;
}
if (values.length === 2) {
const [minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
if (values.length === 3) {
const [hours, minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
@@ -62,6 +73,37 @@ export function sortByDateDesc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}
/** Последний результат, который улучшил уже существовавший личный рекорд на сопоставимой дистанции. */
export function findLatestPersonalRecord(races: Race[]): Race | null {
const bestTimes: Array<{ distanceKm: number; seconds: number }> = [];
let latestRecord: Race | null = null;
for (const race of sortByDateAsc(races)) {
if (race.status !== "completed") {
continue;
}
const seconds = parseFinishTimeToSeconds(race.finishTime);
if (seconds === null) {
continue;
}
// ponytail: personal calendars are small; introduce a distance index only if profiling requires it.
const comparable = bestTimes.filter((best) => isCloseDistance(best.distanceKm, race.distanceKm));
if (comparable.length > 0 && seconds < Math.min(...comparable.map((best) => best.seconds))) {
latestRecord = race;
}
if (comparable.length === 0) {
bestTimes.push({ distanceKm: race.distanceKm, seconds });
} else {
comparable.forEach((best) => {
best.seconds = Math.min(best.seconds, seconds);
});
}
}
return latestRecord;
}
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
const today = new Date(now);
today.setHours(0, 0, 0, 0);

View File

@@ -11,13 +11,6 @@ interface RaceVisual {
label: string;
}
interface OfficialRaceVisual {
keywords: string[];
imageSrc: string;
imageFit?: RaceVisualFit;
label: string;
}
const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
short: {
variant: "short",
@@ -56,109 +49,8 @@ const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
},
};
const OFFICIAL_VISUALS: OfficialRaceVisual[] = [
{
keywords: ["забег апрель"],
imageSrc: "https://aprilrun5km.runc.run/uploads/page_card_photos/AprilRun_photo_1.jpg",
label: "Забег Апрель",
},
{
keywords: ["быстрый пес"],
imageSrc: "https://fastdogxc.runc.run/uploads/page_card_photos/Dog_spring_2026-5.jpg",
label: "Кросс",
},
{
keywords: ["лисья гора"],
imageSrc: "https://foxhillxc.runc.run/uploads/page_card_photos/Fox_Spring_2026-0.jpg",
label: "Кросс",
},
{
keywords: ["казанский марафон"],
imageSrc: "https://static.tildacdn.com/tild3961-6436-4462-b738-356665613039/Frame_2131327895.png",
imageFit: "contain",
label: "Казанский марафон",
},
{
keywords: ["мышкинский полумарафон", "по шести холмам"],
imageSrc: "https://static.tildacdn.com/tild6133-6137-4865-b166-623532313531/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["забег.рф", "забег рф"],
imageSrc: "https://xn--80acghh.xn--p1ai/zabeg.jpg",
label: "ЗаБег.РФ",
},
{
keywords: ["переславский марафон", "александровские версты"],
imageSrc: "https://static.tildacdn.com/tild6432-3338-4533-b262-633339353335/photo_1.jpg",
label: "Золотое кольцо",
},
{
keywords: ["красочный забег"],
imageSrc: "https://colorrun5km.runc.run/uploads/page_card_photos/ColorRun2026-1.jpg",
label: "Красочный забег",
},
{
keywords: ["здорово кострома", "здорово, кострома"],
imageSrc: "https://static.tildacdn.com/tild6139-3539-4661-b232-386230336431/kostroma.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег москва"],
imageSrc: "https://nightrun10km.runc.run/uploads/page_card_photos/NightRun_2026-9.jpg",
label: "Ночной забег",
},
{
keywords: ["белые ночи"],
imageSrc: "https://wnmarathon.runc.run/uploads/page_card_photos/WN_photo_01.jpg",
label: "Белые ночи",
},
{
keywords: ["сергиевым путем", "сергиевым путём"],
imageSrc: "https://static.tildacdn.com/tild6236-3466-4239-b666-393061326338/serg.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег нижний новгород"],
imageSrc: "https://rrweb.russiarunning.com/-x740/generalimages/0531a1b8-3876-4620-8961-2fa374e474e5.png",
imageFit: "contain",
label: "Ночной забег",
},
{
keywords: ["suvorov extreme"],
imageSrc: "https://goldenultra.ru/grut/files/photos/100.jpg",
label: "Трейл",
},
{
keywords: ["рыбинский полумарафон", "великий хлебный путь"],
imageSrc: "https://static.tildacdn.com/tild6130-3230-4332-b932-366166366633/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ярославский полумарафон", "золотое кольцо"],
imageSrc: "https://static.tildacdn.com/tild6331-6333-4635-b635-376262373361/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["моя столица"],
imageSrc: "https://static.tildacdn.com/tild3263-3036-4639-b830-653365663832/-min.jpg",
imageFit: "contain",
label: "Моя столица",
},
];
function normalizeTitle(value: string): string {
return value
.toLowerCase()
.replaceAll("ё", "е")
.replace(/[«»|]/g, " ")
.replace(/[^\p{L}\p{N}.&]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function getFallbackRaceVisual(race: Race): RaceVisual {
const title = normalizeTitle(race.title);
const title = race.title.toLowerCase();
if (title.includes("ночной")) {
return FALLBACK_VISUALS.night;
@@ -196,20 +88,5 @@ export function getRaceVisual(race: Race): RaceVisual {
};
}
const title = normalizeTitle(race.title);
const official = OFFICIAL_VISUALS.find((visual) =>
visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))),
);
if (!official) {
return fallback;
}
return {
...fallback,
imageSrc: official.imageSrc,
fallbackSrc: fallback.imageSrc,
imageFit: official.imageFit ?? fallback.imageFit,
label: official.label,
};
return fallback;
}

View File

@@ -120,6 +120,7 @@ export function RegisterPage(): JSX.Element {
const [turnstileToken, setTurnstileToken] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleToken = useCallback((token: string) => setTurnstileToken(token), []);
@@ -127,11 +128,14 @@ export function RegisterPage(): JSX.Element {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
} finally {
setIsSubmitting(false);
}
};
@@ -150,7 +154,9 @@ export function RegisterPage(): JSX.Element {
<TurnstileField onToken={handleToken} />
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={!turnstileToken}>Создать аккаунт</button>
<button className="btn btn--primary" type="submit" disabled={!turnstileToken || isSubmitting}>
{isSubmitting ? "Создаём аккаунт…" : "Создать аккаунт"}
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/login">Уже есть аккаунт</Link>
@@ -165,30 +171,72 @@ export function VerifyEmailPage(): JSX.Element {
const [email, setEmail] = useState(user?.email ?? "");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [isResending, setIsResending] = useState(false);
useEffect(() => {
const token = params.get("token");
if (!token) {
return;
}
void verifyEmail(token)
.then(async () => {
await refresh();
setMessage("Email подтверждён. Теперь можно пользоваться календарём.");
})
.catch((err) => setError(errorMessage(err, "Ссылка недействительна.")));
let isMounted = true;
setIsVerifying(true);
setError(null);
void (async () => {
try {
await verifyEmail(token);
try {
await refresh();
} catch {
// Подтверждение уже выполнено; обновление сессии не должно скрывать успех.
}
if (isMounted) {
setIsVerified(true);
}
} catch (err) {
if (isMounted) {
setError(errorMessage(err, "Ссылка недействительна."));
}
} finally {
if (isMounted) {
setIsVerifying(false);
}
}
})();
return () => {
isMounted = false;
};
}, [params, refresh]);
const resend = async () => {
setError(null);
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
setMessage(null);
setIsResending(true);
try {
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить письмо повторно."));
} finally {
setIsResending(false);
}
};
if (isVerified) {
return (
<section className="page page--auth">
<h1 className="page__title">Email подтверждён</h1>
<p className="page__subtitle">Теперь можно пользоваться календарём.</p>
<p className="auth-form__links"><Link className="page-link" to="/">Перейти к календарю</Link></p>
</section>
);
}
return (
<section className="page page--auth">
<h1 className="page__title">Подтверждение email</h1>
<p className="page__subtitle">Для доступа к календарю подтвердите email по ссылке из письма.</p>
<p className="page__subtitle">{isVerifying ? "Проверяем ссылку…" : "Для доступа к календарю подтвердите email по ссылке из письма."}</p>
<form className="auth-form" onSubmit={(e) => { e.preventDefault(); void resend(); }}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
@@ -196,7 +244,9 @@ export function VerifyEmailPage(): JSX.Element {
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit">Отправить письмо ещё раз</button>
<button className="btn btn--primary" type="submit" disabled={isVerifying || isResending}>
{isResending ? "Отправляем…" : "Отправить письмо ещё раз"}
</button>
</form>
</section>
);
@@ -205,17 +255,35 @@ export function VerifyEmailPage(): JSX.Element {
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await forgotPassword(email);
setMessage("Если email зарегистрирован, ссылка отправлена.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить ссылку."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Сброс пароля</h1>
<form className="auth-form" onSubmit={async (e) => { e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
<button className="btn btn--primary" type="submit">Отправить ссылку</button>
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Отправляем…" : "Отправить ссылку"}</button>
</form>
</section>
);
@@ -226,27 +294,34 @@ export function ResetPasswordPage(): JSX.Element {
const [password, setPassword] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await resetPassword(params.get("token") ?? "", password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Новый пароль</h1>
<form className="auth-form" onSubmit={async (e) => {
e.preventDefault();
const token = params.get("token") ?? "";
try {
await resetPassword(token, password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
}
}}>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(event) => setPassword(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit">Сохранить пароль</button>
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Сохраняем…" : "Сохранить пароль"}</button>
</form>
</section>
);

View File

@@ -7,6 +7,7 @@ import { PaceTrendChart } from "../components";
import {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getRaceCountdownLabel,
getRaceVisual,
getPaceLabel,
@@ -76,10 +77,7 @@ export function DashboardPage(): JSX.Element {
const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null;
const lastPersonalRecord =
past.find(
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
) ?? null;
const lastPersonalRecord = findLatestPersonalRecord(races);
const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
@@ -259,10 +257,8 @@ export function DashboardPage(): JSX.Element {
)}
</article>
<article
className={`dashboard-card dashboard-card--accent-lime${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastPersonalRecord ? (
{dashboardMetrics.lastPersonalRecord ? (
<article className="dashboard-card dashboard-card--accent-lime dashboard-card--linked">
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
@@ -278,13 +274,8 @@ export function DashboardPage(): JSX.Element {
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
) : (
<>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
</>
)}
</article>
</article>
) : null}
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
<h2 className="dashboard-card__title">Сезон</h2>

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
import { DatePickerField, StartTimeSelects } from "../components";
import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib";
import type { CreateRacePayload, Race, RaceStatus } from "../api";
import { isRaceDateInPast } from "../lib";
function slugify(text: string): string {
return text
@@ -36,8 +35,13 @@ interface FormData {
clusterSchedule: string;
bibPickup: string;
bibNumber: string;
finishTime: string;
finishPlace: string;
finishPlaceTotal: string;
originalFinishPlace: string;
isFinishPlaceEdited: boolean;
finishHours: string;
finishMinutes: string;
finishSeconds: string;
notes: string;
}
@@ -52,8 +56,13 @@ const EMPTY_FORM: FormData = {
clusterSchedule: "",
bibPickup: "",
bibNumber: "",
finishTime: "",
finishPlace: "",
finishPlaceTotal: "",
originalFinishPlace: "",
isFinishPlaceEdited: false,
finishHours: "",
finishMinutes: "",
finishSeconds: "",
notes: "",
};
@@ -70,17 +79,88 @@ function raceToFormData(race: Race): FormData {
clusterSchedule: race.clusterSchedule ?? "",
bibPickup: race.bibPickup ?? "",
bibNumber: race.bibNumber ?? "",
finishTime: race.finishTime ?? "",
finishPlace: race.finishPlace ?? "",
...finishTimeToFormData(race.finishTime),
...finishPlaceToFormData(race.finishPlace),
notes: race.notes ?? "",
};
}
function finishTimeToFormData(
value: string | null,
): Pick<FormData, "finishHours" | "finishMinutes" | "finishSeconds"> {
const parts = value?.trim().split(":") ?? [];
if (parts.length === 3) {
return { finishHours: parts[0], finishMinutes: parts[1], finishSeconds: parts[2] };
}
if (parts.length === 2) {
return { finishHours: "", finishMinutes: parts[0], finishSeconds: parts[1] };
}
return { finishHours: "", finishMinutes: "", finishSeconds: parts[0] ?? "" };
}
function finishPlaceToFormData(
value: string | null,
): Pick<FormData, "finishPlace" | "finishPlaceTotal" | "originalFinishPlace" | "isFinishPlaceEdited"> {
const originalFinishPlace = value ?? "";
const [finishPlace = "", finishPlaceTotal = ""] = originalFinishPlace.split("/", 2);
return { finishPlace, finishPlaceTotal, originalFinishPlace, isFinishPlaceEdited: false };
}
function emptyToNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
function parseNonNegativeInteger(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
return Number(trimmed);
}
function getFinishTime(form: FormData): { value: string | null; error: string | null } {
const hasValue = [form.finishHours, form.finishMinutes, form.finishSeconds].some((value) => value.trim() !== "");
if (!hasValue) {
return { value: null, error: null };
}
const hours = form.finishHours.trim() === "" ? null : parseNonNegativeInteger(form.finishHours);
const minutes = form.finishMinutes.trim() === "" ? 0 : parseNonNegativeInteger(form.finishMinutes);
const seconds = form.finishSeconds.trim() === "" ? 0 : parseNonNegativeInteger(form.finishSeconds);
if (hours === null && form.finishHours.trim() !== "") {
return { value: null, error: "Часы должны быть целым числом не меньше 0." };
}
if (minutes === null || seconds === null || minutes > 59 || seconds > 59) {
return { value: null, error: "Минуты и секунды должны быть целыми числами от 0 до 59." };
}
const pad = (value: number) => String(value).padStart(2, "0");
if (hours !== null) {
return { value: `${hours}:${pad(minutes)}:${pad(seconds)}`, error: null };
}
if (form.finishMinutes.trim() !== "") {
return { value: `${pad(minutes)}:${pad(seconds)}`, error: null };
}
return { value: pad(seconds), error: null };
}
function getFinishPlace(form: FormData): { value: string | null; error: string | null } {
if (!form.isFinishPlaceEdited) {
return { value: emptyToNull(form.originalFinishPlace), error: null };
}
if (form.finishPlace.trim() === "" && form.finishPlaceTotal.trim() === "") {
return { value: null, error: null };
}
const place = parseNonNegativeInteger(form.finishPlace);
const total = form.finishPlaceTotal.trim() === "" ? null : parseNonNegativeInteger(form.finishPlaceTotal);
if (place === null || place < 1 || (total !== null && (total < 1 || place > total))) {
return { value: null, error: "Место должно быть не меньше 1 и не больше числа участников." };
}
return { value: total === null ? String(place) : `${place}/${total}`, error: null };
}
function validateForm(form: FormData): string[] {
const errors: string[] = [];
if (!form.date.trim()) {
@@ -93,6 +173,14 @@ function validateForm(form: FormData): string[] {
if (Number.isNaN(km) || km <= 0) {
errors.push("Дистанция должна быть положительным числом.");
}
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
if (finishTime.error) {
errors.push(finishTime.error);
}
if (finishPlace.error) {
errors.push(finishPlace.error);
}
return errors;
}
@@ -167,12 +255,6 @@ export function RaceFormPage(): JSX.Element {
const { name, value } = event.target;
setForm((prev) => {
const next = { ...prev, [name]: value };
if (name === "finishTime") {
const trimmed = value.trim();
if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) {
return { ...next, status: "completed" };
}
}
return next;
});
},
@@ -193,56 +275,38 @@ export function RaceFormPage(): JSX.Element {
setIsSaving(true);
try {
const finishTrimmed = form.finishTime.trim();
const hasParsedFinish =
finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== null;
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
let statusValue: RaceStatus | null =
form.status === "planned" || form.status === "registered" || form.status === "completed"
? form.status
: null;
if (hasParsedFinish) {
if (finishTime.value) {
statusValue = "completed";
}
if (isEditMode && raceId) {
const payload: UpdateRacePayload = {
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
const payload: Omit<CreateRacePayload, "slug"> = {
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: finishTime.value,
finishPlace: finishPlace.value,
notes: emptyToNull(form.notes),
};
if (isEditMode && raceId) {
await updateRace(raceId, payload);
navigate(`/races/${raceId}`);
} else {
const slug = generateId(form.date.trim(), form.title.trim());
const payload: CreateRacePayload = {
slug,
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
const created = await createRace(payload);
const created = await createRace({ ...payload, slug });
navigate(`/races/${created.id}`);
}
} catch (error) {
@@ -291,17 +355,17 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group">
<legend className="race-form__legend">Основная информация</legend>
<div className="race-form__field">
<label className="race-form__field">
<span className="race-form__label">Дата *</span>
<DatePickerField
<input
className="race-form__input"
type="date"
name="date"
value={form.date}
onChange={(next) => {
setForm((prev) => ({ ...prev, date: next }));
}}
onChange={handleChange}
required
/>
</div>
</label>
<label className="race-form__field">
<span className="race-form__label">Название *</span>
@@ -317,7 +381,6 @@ export function RaceFormPage(): JSX.Element {
</label>
<label className="race-form__field">
<span className="race-form__label">Дистанция, км *</span>
<input
className="race-form__input"
type="number"
@@ -376,15 +439,17 @@ export function RaceFormPage(): JSX.Element {
</label>
{hideOrgScheduleFields ? null : (
<div className="race-form__field">
<label className="race-form__field">
<span className="race-form__label">Время старта</span>
<StartTimeSelects
<input
className="race-form__input"
type="time"
step="1"
name="startTime"
value={form.startTime}
onChange={(next) => {
setForm((prev) => ({ ...prev, startTime: next }));
}}
onChange={handleChange}
/>
</div>
</label>
)}
{hideOrgScheduleFields ? null : (
@@ -430,29 +495,37 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group">
<legend className="race-form__legend">Результаты</legend>
<label className="race-form__field">
<div className="race-form__field">
<span className="race-form__label">Финишное время</span>
<input
className="race-form__input"
type="text"
name="finishTime"
value={form.finishTime}
onChange={handleChange}
placeholder="1:45:30"
/>
</label>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Часы</span>
<input className="race-form__input" type="number" name="finishHours" value={form.finishHours} onChange={handleChange} min="0" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Минуты</span>
<input className="race-form__input" type="number" name="finishMinutes" value={form.finishMinutes} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Секунды</span>
<input className="race-form__input" type="number" name="finishSeconds" value={form.finishSeconds} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
</div>
</div>
<label className="race-form__field">
<div className="race-form__field">
<span className="race-form__label">Место на финише</span>
<input
className="race-form__input"
type="text"
name="finishPlace"
value={form.finishPlace}
onChange={handleChange}
placeholder="12/340"
/>
</label>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Место</span>
<input className="race-form__input" type="number" name="finishPlace" value={form.finishPlace} onChange={(event) => setForm((prev) => ({ ...prev, finishPlace: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Всего участников</span>
<input className="race-form__input" type="number" name="finishPlaceTotal" value={form.finishPlaceTotal} onChange={(event) => setForm((prev) => ({ ...prev, finishPlaceTotal: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
</div>
</div>
</fieldset>
) : null}

View File

@@ -825,167 +825,24 @@ a {
min-height: 5rem;
}
.race-form__time-picker {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
.race-form__result-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: var(--space-2);
}
.race-form__time-picker__unit {
.race-form__result-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.race-form__time-picker__label {
.race-form__result-field-label {
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--color-text-muted);
}
.race-form__time-picker__select {
min-width: 4.25rem;
}
.race-form__time-picker__sep {
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-2);
}
.date-picker {
position: relative;
width: min(100%, 22rem);
}
.date-picker__control {
display: flex;
align-items: stretch;
}
.date-picker__input {
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.date-picker__toggle {
width: 3rem;
border: 1px solid var(--color-border);
border-left: none;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--color-bg-deep);
color: #ffffff;
font: inherit;
font-weight: 900;
cursor: pointer;
}
.date-picker__toggle:hover,
.date-picker__toggle:focus-visible {
background: var(--color-accent-strong);
outline: none;
}
.date-picker__popover {
position: absolute;
top: calc(100% + var(--space-2));
left: 0;
z-index: 12;
width: min(20rem, calc(100vw - var(--space-8)));
padding: var(--space-4);
border: 1px solid rgba(17, 104, 216, 0.2);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-card-lift);
}
.date-picker__header {
display: grid;
grid-template-columns: 2.25rem minmax(0, 1fr) 2.25rem;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.date-picker__title {
margin: 0;
color: var(--color-bg-deep);
font-weight: 900;
text-align: center;
}
.date-picker__nav {
width: 2.25rem;
height: 2.25rem;
border: none;
border-radius: var(--radius-sm);
background: rgba(17, 104, 216, 0.08);
color: var(--color-accent);
font: inherit;
font-size: 1.25rem;
font-weight: 900;
cursor: pointer;
}
.date-picker__nav:hover,
.date-picker__nav:focus-visible {
background: var(--color-lime);
color: #071927;
outline: none;
}
.date-picker__weekdays,
.date-picker__cells {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.date-picker__weekdays {
margin-bottom: var(--space-1);
}
.date-picker__weekday {
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 800;
text-align: center;
}
.date-picker__cell,
.date-picker__day {
min-height: 2.25rem;
}
.date-picker__day {
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
font-size: var(--font-size-caption);
font-weight: 700;
cursor: pointer;
}
.date-picker__day:hover,
.date-picker__day:focus-visible {
background: #e8f1ff;
border-color: rgba(17, 104, 216, 0.2);
outline: none;
}
.date-picker__day--today {
border-color: var(--color-accent);
}
.date-picker__day--selected {
background: var(--color-lime);
color: #071927;
}
.race-form__actions {
display: flex;
gap: var(--space-3);

View File

@@ -1 +0,0 @@
{"root":["./src/main.tsx","./src/api/index.ts","./src/app/router.tsx","./src/app/layouts/applayout.tsx","./src/components/index.ts","./src/features/index.ts","./src/lib/index.ts","./src/pages/dashboardpage.tsx","./src/pages/racespage.tsx"],"version":"5.9.3"}

View File

@@ -7,7 +7,9 @@
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"strict": true
"strict": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -1,5 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});