Ревью: корректность фронтенда

Находки прохода /code-review high по frontend/src:

- CreateMatchPage: барабан рандома, таймеры и строки адресовались по индексу,
  а удаление строки индексы сдвигает — фракция могла записаться соседу или
  потеряться. Строки получили стабильный id.
- AdminAccountsPage: переключение «активен» глотало ошибку и всё равно
  показывало «Сохранено».
- useServerEvents/useFinishMatch: завершение чужой партии не инвалидировало
  историю игр и публичные профили — открытый профиль показывал состав до
  завершения.
- guards/useMe: обрыв связи не отличался от «нет сессии», и мигание сети
  выкидывало авторизованного пользователя на /login. Транспортные ошибки
  повторяем, гварды показывают сообщение вместо редиректа.
- format: fallback на нераспознанную дату не работал (new Date не бросает
  исключение), и в интерфейс попадало «NaN.NaN NaN:NaN».
- PlaceEditor: без onPointerCancel прерванный перенос оставлял блок с классом
  dragging и сдвигом, которые React не снимает — они выставлены в обход него.
- AdminFactionsPage: refetch после сохранения одной фракции затирал
  несохранённый ввод в остальных.
- MatchDetailPage: nav(-1) после удаления уводил из приложения при открытии
  партии по прямой ссылке; id из URL мог быть NaN и уходил в запрос.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
This commit is contained in:
2026-09-09 15:30:56 +03:00
co-authored by Claude Opus 5
parent 62d75ea176
commit c4b72cca39
10 changed files with 105 additions and 57 deletions
+9 -2
View File
@@ -5,17 +5,24 @@ import { Spinner } from "../components/Spinner";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
import { useAdminMe } from "../hooks/admin"; import { useAdminMe } from "../hooks/admin";
/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */
function OfflineNotice() {
return <div className="muted">Нет связи с сервером. Проверьте подключение и обновите страницу.</div>;
}
export function RequireAuth({ children }: PropsWithChildren) { export function RequireAuth({ children }: PropsWithChildren) {
const { data: me, isLoading } = useMe(); const { data: me, isLoading, isError } = useMe();
const location = useLocation(); const location = useLocation();
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError) return <OfflineNotice />;
if (!me) return <Navigate to="/login" replace state={{ from: location }} />; if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
return <>{children}</>; return <>{children}</>;
} }
export function RequireGroup({ children }: PropsWithChildren) { export function RequireGroup({ children }: PropsWithChildren) {
const { data: me, isLoading } = useMe(); const { data: me, isLoading, isError } = useMe();
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError) return <OfflineNotice />;
if (!me) return <Navigate to="/login" replace />; if (!me) return <Navigate to="/login" replace />;
if (me.active_group_id == null) return <Navigate to="/" replace />; if (me.active_group_id == null) return <Navigate to="/" replace />;
return <>{children}</>; return <>{children}</>;
+4
View File
@@ -182,6 +182,10 @@ export function PlaceEditor({
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts), onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts),
onPointerMove: moveDrag, onPointerMove: moveDrag,
onPointerUp: endDrag, onPointerUp: endDrag,
// Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого
// блок остаётся с классом dragging и сдвигом: они выставлены в обход React,
// и перерендер их не снимет.
onPointerCancel: endDrag,
}); });
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё. // ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
+2 -8
View File
@@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string {
export function formatTime(iso: string | null | undefined): string { export function formatTime(iso: string | null | undefined): string {
if (!iso) return "—"; if (!iso) return "—";
try {
const d = shifted(iso); const d = shifted(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
} catch {
return iso;
}
} }
// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части, // «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
@@ -47,10 +44,7 @@ export function formatDate(iso: string | null | undefined): string {
export function formatDateTime(iso: string | null | undefined): string { export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—"; if (!iso) return "—";
try {
const d = shifted(iso); const d = shifted(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
} catch {
return iso;
}
} }
+3
View File
@@ -12,6 +12,9 @@ export function useMe() {
if (r.response.status === 401) return null; if (r.response.status === 401) return null;
return unwrap(r); return unwrap(r);
}, },
// «Нет сессии» — это только 401 (выше, null). Обрыв связи не ответ сервера:
// без повторов гварды принимали бы его за разлогин и уводили на /login.
retry: (count, err) => !(err instanceof ApiError) && count < 2,
}); });
} }
+6 -1
View File
@@ -12,7 +12,9 @@ function readCsrfToken(): string | null {
export function useMatch(matchId: number | null) { export function useMatch(matchId: number | null) {
return useQuery({ return useQuery({
queryKey: matchId ? qk.match(matchId) : ["match", "none"], // Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы
// свою ошибку под общим ключом-заглушкой.
queryKey: matchId != null ? qk.match(matchId) : ["match", "none"],
enabled: matchId != null, enabled: matchId != null,
queryFn: async () => queryFn: async () =>
unwrap( unwrap(
@@ -63,6 +65,9 @@ export function useFinishMatch() {
qc.invalidateQueries({ queryKey: qk.leaderboard }); qc.invalidateQueries({ queryKey: qk.leaderboard });
qc.invalidateQueries({ queryKey: qk.home }); qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.myStats }); qc.invalidateQueries({ queryKey: qk.myStats });
// Завершённая партия попадает в историю игр и в публичные профили участников.
qc.invalidateQueries({ queryKey: ["userMatches"] });
qc.invalidateQueries({ queryKey: ["publicProfile"] });
}, },
}); });
} }
+5
View File
@@ -40,6 +40,11 @@ export function useServerEvents(enabled: boolean) {
} }
qc.invalidateQueries({ queryKey: qk.home }); qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.leaderboard }); qc.invalidateQueries({ queryKey: qk.leaderboard });
// История игр и публичный профиль тоже меняются от чужой партии: без этих
// двух ключей открытый профиль показывал бы состав до завершения.
qc.invalidateQueries({ queryKey: ["userMatches"] });
qc.invalidateQueries({ queryKey: ["publicProfile"] });
qc.invalidateQueries({ queryKey: qk.myStats });
} else if (ev.type === "group") { } else if (ev.type === "group") {
if (ev.group_id != null) { if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
+50 -35
View File
@@ -15,13 +15,19 @@ import { useGroupMembers } from "../hooks/groups";
import { useRandomizeFaction, useStartMatch } from "../hooks/matches"; import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
interface Draft { interface Draft {
// Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома
// отложенно дописывает результат в свою строку и промахнулся бы по соседа.
id: number;
user_id: number | ""; user_id: number | "";
faction_id: number | ""; faction_id: number | "";
was_random: boolean; was_random: boolean;
} }
let draftSeq = 0;
function emptyDraft(): Draft { function emptyDraft(): Draft {
return { user_id: "", faction_id: "", was_random: false }; draftSeq += 1;
return { id: draftSeq, user_id: "", faction_id: "", was_random: false };
} }
export function CreateMatchPage() { export function CreateMatchPage() {
@@ -51,30 +57,39 @@ export function CreateMatchPage() {
if (lm || lf) return <Spinner />; if (lm || lf) return <Spinner />;
if (!groupId) return <div className="muted">Нет активной группы.</div>; if (!groupId) return <div className="muted">Нет активной группы.</div>;
const takenFactionIds = (exceptIdx: number) => const takenFactionIds = (exceptId: number) =>
rows rows
.filter((_, i) => i !== exceptIdx) .filter((r) => r.id !== exceptId)
.map((r) => r.faction_id) .map((r) => r.faction_id)
.filter((x): x is number => typeof x === "number"); .filter((x): x is number => typeof x === "number");
// Игроки, уже выбранные в других строках, — в списке не предлагаются. // Игроки, уже выбранные в других строках, — в списке не предлагаются.
const takenUserIds = (exceptIdx: number) => const takenUserIds = (exceptId: number) =>
rows rows
.filter((_, i) => i !== exceptIdx) .filter((r) => r.id !== exceptId)
.map((r) => r.user_id) .map((r) => r.user_id)
.filter((x): x is number => typeof x === "number"); .filter((x): x is number => typeof x === "number");
const update = (idx: number, patch: Partial<Draft>) => const update = (id: number, patch: Partial<Draft>) =>
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const addRow = () => setRows((rs) => [...rs, emptyDraft()]); const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); const removeRow = (id: number) => {
const timer = reelTimers.current[id];
if (timer) clearTimeout(timer);
delete reelTimers.current[id];
setSpin((sp) => {
const { [id]: _dropped, ...rest } = sp;
return rest;
});
setRows((rs) => rs.filter((r) => r.id !== id));
};
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление), // «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только // затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
// после этого фракция «выбирается» (onReveal: показываем select с результатом). // после этого фракция «выбирается» (onReveal: показываем select с результатом).
const animateReel = ( const animateReel = (
idx: number, id: number,
names: string[], names: string[],
finalName: string, finalName: string,
onReveal: () => void, onReveal: () => void,
@@ -82,16 +97,16 @@ export function CreateMatchPage() {
new Promise((resolve) => { new Promise((resolve) => {
let delay = 50; let delay = 50;
const tick = () => { const tick = () => {
setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] })); setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] }));
delay *= 1.18; delay *= 1.18;
if (delay < 300) { if (delay < 300) {
reelTimers.current[idx] = setTimeout(tick, delay); reelTimers.current[id] = setTimeout(tick, delay);
} else { } else {
reelTimers.current[idx] = setTimeout(() => { reelTimers.current[id] = setTimeout(() => {
setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной
reelTimers.current[idx] = setTimeout(() => { reelTimers.current[id] = setTimeout(() => {
onReveal(); onReveal();
setSpin((s) => ({ ...s, [idx]: null })); setSpin((s) => ({ ...s, [id]: null }));
resolve(); resolve();
}, 500); // пауза перед «выбором» }, 500); // пауза перед «выбором»
}, delay); }, delay);
@@ -100,20 +115,20 @@ export function CreateMatchPage() {
tick(); tick();
}); });
const randomizeRow = async (idx: number) => { const randomizeRow = async (id: number) => {
const pool = factions ?? []; const pool = factions ?? [];
if (pool.length === 0 || spin[idx] != null) return; if (pool.length === 0 || spin[id] != null) return;
try { try {
const faction = await randomize.mutateAsync({ const faction = await randomize.mutateAsync({
group_id: groupId, group_id: groupId,
exclude_faction_ids: takenFactionIds(idx), exclude_faction_ids: takenFactionIds(id),
}); });
await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () => await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () =>
update(idx, { faction_id: faction.id, was_random: true }), update(id, { faction_id: faction.id, was_random: true }),
); );
} catch (e) { } catch (e) {
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]); if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
setSpin((s) => ({ ...s, [idx]: null })); setSpin((s) => ({ ...s, [id]: null }));
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
} }
}; };
@@ -130,10 +145,10 @@ export function CreateMatchPage() {
const names = all.map((f) => f.name_ru); const names = all.map((f) => f.name_ru);
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию. // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
await Promise.all( await Promise.all(
rows.map((_, idx) => { rows.map((row, idx) => {
const f = pool[idx % pool.length]; const f = pool[idx % pool.length];
return animateReel(idx, names, f.name_ru, () => return animateReel(row.id, names, f.name_ru, () =>
update(idx, { faction_id: f.id, was_random: true }), update(row.id, { faction_id: f.id, was_random: true }),
); );
}), }),
); );
@@ -194,11 +209,11 @@ export function CreateMatchPage() {
</div> </div>
{rows.map((row, idx) => ( {rows.map((row, idx) => (
<div className="participant-row" key={idx}> <div className="participant-row" key={row.id}>
<div className="row-between"> <div className="row-between">
<b>Игрок {idx + 1}</b> <b>Игрок {idx + 1}</b>
{rows.length > 2 && ( {rows.length > 2 && (
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}> <button className="btn btn-ghost btn-danger small" onClick={() => removeRow(row.id)}>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
)} )}
@@ -206,29 +221,29 @@ export function CreateMatchPage() {
<PickerSelect <PickerSelect
selected={playerOptions.find((m) => m.id === row.user_id) ?? null} selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))} options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))}
placeholder="— игрок —" placeholder="— игрок —"
renderOption={(m) => playerLabel(m, 28)} renderOption={(m) => playerLabel(m, 28)}
renderValue={(m) => playerLabel(m, 24)} renderValue={(m) => playerLabel(m, 24)}
onPick={(m) => update(idx, { user_id: m.id })} onPick={(m) => update(row.id, { user_id: m.id })}
/> />
<div className="row"> <div className="row">
{spin[idx] != null ? ( {spin[row.id] != null ? (
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div> <div className="faction-spin" style={{ flex: 1 }}>{spin[row.id]}</div>
) : ( ) : (
<PickerSelect <PickerSelect
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null} selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))} options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))}
placeholder="— фракция —" placeholder="— фракция —"
renderOption={(f) => f.name_ru} renderOption={(f) => f.name_ru}
onPick={(f) => update(idx, { faction_id: f.id, was_random: false })} onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })}
/> />
)} )}
<button <button
className="btn" className="btn"
onClick={() => randomizeRow(idx)} onClick={() => randomizeRow(row.id)}
disabled={spin[idx] != null} disabled={spin[row.id] != null}
title="Случайная фракция" title="Случайная фракция"
> >
<Dices size={18} /> <Dices size={18} />
+6 -2
View File
@@ -23,7 +23,9 @@ const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
export function MatchDetailPage() { export function MatchDetailPage() {
const { matchId } = useParams(); const { matchId } = useParams();
const id = matchId ? Number(matchId) : null; // Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
const parsed = matchId ? Number(matchId) : NaN;
const id = Number.isInteger(parsed) ? parsed : null;
const { data: match, isLoading, refetch } = useMatch(id); const { data: match, isLoading, refetch } = useMatch(id);
const finish = useFinishMatch(); const finish = useFinishMatch();
const del = useDeleteMatch(); const del = useDeleteMatch();
@@ -105,7 +107,9 @@ export function MatchDetailPage() {
try { try {
await del.mutateAsync({ matchId: id, expectedVersion: match.version }); await del.mutateAsync({ matchId: id, expectedVersion: match.version });
toast.show(inProgress ? "Партия отменена" : "Партия удалена"); toast.show(inProgress ? "Партия отменена" : "Партия удалена");
navigate(-1); // Не navigate(-1): партию часто открывают по прямой ссылке, и «назад»
// уводит из приложения вместо возврата к группе.
navigate("/group", { replace: true });
} catch (e) { } catch (e) {
if (isStale(e)) { if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено"); toast.show("Партия изменилась на другом устройстве — обновлено");
@@ -36,8 +36,12 @@ export function AdminAccountsPage() {
}; };
const toggleActive = async (id: number, isActive: boolean) => { const toggleActive = async (id: number, isActive: boolean) => {
await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {}); try {
await update.mutateAsync({ userId: id, is_active: !isActive });
toast.show("Сохранено"); toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
}
}; };
return ( return (
@@ -11,10 +11,17 @@ export function AdminFactionsPage() {
const toast = useToast(); const toast = useToast();
const [names, setNames] = useState<Record<number, string>>({}); const [names, setNames] = useState<Record<number, string>>({});
// Подставляем серверные названия только там, где поле ещё не трогали: refetch после
// сохранения одной фракции иначе затирал бы несохранённый ввод в остальных.
useEffect(() => { useEffect(() => {
if (factions) { if (!factions) return;
setNames(Object.fromEntries(factions.map((f) => [f.id, f.name_ru]))); setNames((prev) => {
const next = { ...prev };
for (const f of factions) {
if (next[f.id] === undefined) next[f.id] = f.name_ru;
} }
return next;
});
}, [factions]); }, [factions]);
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;