Партия: совместное заполнение формы завершения на клиенте

Раскладка, комментарии, причина победы и комментарий о партии уходят в общий
черновик через ~0.6с после последнего действия, а чужие правки подтягиваются по
SSE. Чужой черновик применяется, только если с момента моего последнего действия
прошло больше 1.5с — иначе правка соседа перетирала бы тайл прямо под рукой.

Перед «Завершить» отложенная запись дожимается: иначе последняя правка попала бы
в результаты, но не в черновик, и второй участник увидел бы не то, что записалось.
Само завершение по-прежнему шлёт тело запроса, так что работает и без черновика.

Под подсказкой о перетаскивании появилась строка «Результаты заполняет также
<Ник>», а если партию завершил кто-то другой — тост вместо молча исчезающей формы.

В админке у идущей партии вместо формы правки — пояснение и переход на страницу
партии: сервер её результаты всё равно не примет.

#28

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 19:39:42 +03:00
co-authored by Claude Opus 5
parent 8be9a23c0c
commit 32d64c94af
6 changed files with 254 additions and 8 deletions
+107 -6
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client";
@@ -9,13 +9,16 @@ import { PlaceEditor } from "../components/PlaceEditor";
import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner";
import { formatDate, formatDuration, formatTime } from "../domain/format";
import type { MatchFinishDraftData } from "../domain/types";
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
import {
useDeleteMatch,
useDeleteMatchAttachment,
useFinishMatch,
useMatch,
useSaveFinishDraft,
useUploadMatchAttachment,
} from "../hooks/matches";
@@ -27,7 +30,9 @@ export function MatchDetailPage() {
const parsed = matchId ? Number(matchId) : NaN;
const id = Number.isInteger(parsed) ? parsed : null;
const { data: match, isLoading, refetch } = useMatch(id);
const { data: me } = useMe();
const finish = useFinishMatch();
const saveDraft = useSaveFinishDraft(id);
const del = useDeleteMatch();
const uploadAtt = useUploadMatchAttachment(id ?? 0);
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
@@ -43,6 +48,61 @@ export function MatchDetailPage() {
const [error, setError] = useState<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false);
// ─── Совместное заполнение формы ───
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
// каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе
// отправил бы то, что было на момент его постановки.
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const draftPayload = useRef<MatchFinishDraftData | null>(null);
const lastLocalEdit = useRef(0);
const appliedDraftAt = useRef<string | null>(null);
const iFinished = useRef(false);
const prevStatus = useRef<string | null>(null);
const sendDraft = () => {
draftTimer.current = null;
if (draftPayload.current && id) saveDraft.mutate(draftPayload.current);
};
const queueDraft = (payload: MatchFinishDraftData) => {
draftPayload.current = payload;
lastLocalEdit.current = Date.now();
if (draftTimer.current) clearTimeout(draftTimer.current);
draftTimer.current = setTimeout(sendDraft, 600);
};
useEffect(
() => () => {
if (draftTimer.current) clearTimeout(draftTimer.current);
},
[],
);
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
// иначе правка соседа перетёрла бы тайл прямо под рукой.
const incoming = match?.finish_draft;
useEffect(() => {
if (!incoming || match?.status !== "in_progress") return;
if (incoming.updated_by === me?.id) return;
if (incoming.updated_at === appliedDraftAt.current) return;
if (Date.now() - lastLocalEdit.current < 1500) return;
setBlocks(incoming.data.blocks);
setElim(incoming.data.eliminated);
setComments(incoming.data.comments);
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason);
setOverall(incoming.data.overall_comment ?? "");
appliedDraftAt.current = incoming.updated_at;
}, [incoming, match?.status, me?.id]);
// Партию завершил кто-то другой — форма исчезает, и без этого молча.
useEffect(() => {
if (!match) return;
if (prevStatus.current === "in_progress" && match.status === "finished" && !iFinished.current) {
toast.show("Партию завершил другой участник — показаны его результаты");
}
prevStatus.current = match.status;
}, [match, toast]);
if (isLoading) return <Spinner />;
if (!match) return <div className="muted">Партия не найдена.</div>;
@@ -58,9 +118,28 @@ export function MatchDetailPage() {
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
// Снимок формы для общего черновика: то, что увидят остальные заполняющие.
const draftOf = (patch: Partial<MatchFinishDraftData> = {}): MatchFinishDraftData => ({
blocks: finishBlocks,
eliminated: elim,
comments: Object.fromEntries(
Object.entries(finishComments).map(([uid, text]) => [String(uid), text]),
),
win_reason: winReason,
overall_comment: overall.trim() || null,
...patch,
});
const submitFinish = async () => {
if (!id || !match) return;
setError(null);
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
// но не в черновик, и второй участник увидел бы не то, что записалось.
if (draftTimer.current) {
clearTimeout(draftTimer.current);
sendDraft();
}
iFinished.current = true;
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
let place = 1;
const survivors = finishBlocks.flatMap((ids) => {
@@ -207,6 +286,13 @@ export function MatchDetailPage() {
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
<p className="small" style={{ color: "var(--accent-2)" }}>
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
{" · "}
{formatTime(match.finish_draft.updated_at)}
</p>
)}
<PlaceEditor
players={match.participants}
blocks={finishBlocks}
@@ -215,10 +301,19 @@ export function MatchDetailPage() {
onChange={(b, e) => {
setBlocks(b);
setElim(e);
queueDraft(draftOf({ blocks: b, eliminated: e }));
}}
onComment={(uid, text) => {
const next = { ...finishComments, [uid]: text };
setComments(next);
queueDraft(
draftOf({
comments: Object.fromEntries(
Object.entries(next).map(([k, v]) => [String(k), v]),
),
}),
);
}}
onComment={(uid, text) =>
setComments({ ...finishComments, [uid]: text })
}
/>
</div>
@@ -229,7 +324,10 @@ export function MatchDetailPage() {
options={REASON_OPTIONS}
placeholder="— причина —"
renderOption={(o) => o.label}
onPick={(o) => setWinReason(o.id)}
onPick={(o) => {
setWinReason(o.id);
queueDraft(draftOf({ win_reason: o.id }));
}}
/>
</div>
@@ -240,7 +338,10 @@ export function MatchDetailPage() {
style={{ resize: "none" }}
placeholder="Комментарий о партии"
value={overall}
onChange={(e) => setOverall(e.target.value)}
onChange={(e) => {
setOverall(e.target.value);
queueDraft(draftOf({ overall_comment: e.target.value.trim() || null }));
}}
/>
<div style={{ marginTop: 8 }}>
<MatchMedia
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia";
@@ -33,6 +34,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
const uploadAtt = useAdminUploadAttachment(matchId);
const deleteAtt = useAdminDeleteAttachment(matchId);
const toast = useToast();
const navigate = useNavigate();
const [rows, setRows] = useState<Row[]>([]);
const [playedAt, setPlayedAt] = useState("");
@@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
{isLoading || !match ? (
<Spinner />
) : match.status === "in_progress" ? (
// Результаты идущей партии сервер не примет (409): места без завершения
// оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
<div className="card">
<h3>Партия ещё идёт</h3>
<p className="muted small">
Результаты незавершённой партии правкой не вносятся — их проставляют
при завершении, на странице самой партии. Там же участники заполняют
места совместно.
</p>
<button className="btn btn-block" onClick={() => navigate(`/match/${matchId}`)}>
Открыть страницу партии
</button>
</div>
) : (
<>
<div className="field">