diff --git a/backend/app/main.py b/backend/app/main.py index 6b53aaa..46bccc4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,12 +72,12 @@ class CSRFMiddleware: await self.app(scope, receive, send) -_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим протухшие уведомления (>72ч) +_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления async def _notifications_purge_loop() -> None: - """Фоновая чистка протухших уведомлений (single-worker безопасно). Чтобы удалялись - «отовсюду» даже у неактивных пользователей (помимо очистки при чтении списка).""" + """Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы + удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении).""" import asyncio from app.db.session import Session, engine diff --git a/backend/app/models.py b/backend/app/models.py index adb9f01..478d619 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -344,7 +344,8 @@ class Notification(SQLModel, table=True): """Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.). Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер — - фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка.""" + фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя), + прочитанные чистятся спустя 72 часа — фоном и при чтении списка.""" __tablename__ = "notifications" __table_args__ = ( diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 719efd6..ef4cb4d 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -56,6 +56,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, + avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] ) for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] ] diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py index 080ce2a..1f46b28 100644 --- a/backend/app/routers/notifications.py +++ b/backend/app/routers/notifications.py @@ -1,7 +1,8 @@ """Уведомления игрока: список (pull) и отметка прочитанными. Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент -перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном).""" +перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся +спустя 72 часа (при чтении и фоном).""" from __future__ import annotations from fastapi import APIRouter, Depends diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index b8cc7eb..2403c2a 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -254,6 +254,7 @@ class MatchParticipantRead(BaseModel): eliminated: bool = False was_random: bool comment: str | None = None + avatar_url: str | None = None class AttachmentRead(BaseModel): @@ -330,7 +331,9 @@ class ProfileStats(BaseModel): factions: list[FactionStat] = [] best_faction: FactionStat | None = None worst_faction: FactionStat | None = None + # Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом). most_played_faction: FactionStat | None = None + main_faction: FactionStat | None = None recent_form: list[RecentFormItem] = [] # Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал # неподтверждённый рейтинг, не дублируя константу на фронте. diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 2c646b1..cd3c80d 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -2,7 +2,9 @@ Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере. -Хранение — 72 часа; протухшие удаляются при чтении списка и фоновой задачей. +Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл), +прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой +задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании. """ from __future__ import annotations @@ -14,7 +16,8 @@ from app.core.timeutil import iso_utc from app.models import Group, Match, MatchParticipant, Notification from app.services import notify -RETENTION_HOURS = 72 +RETENTION_HOURS = 72 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения +MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании def _now() -> datetime: @@ -45,6 +48,9 @@ def create_for( Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids ] session.add_all(rows) + session.flush() + for uid in ids: + _cap_user(session, uid) session.commit() for uid in ids: notify.notifications_changed(uid) @@ -64,10 +70,27 @@ def create( return rows[0] if rows else None +def _cap_user(session: Session, user_id: int) -> None: + """Удалить старейшие уведомления пользователя сверх MAX_PER_USER (без commit).""" + extra = session.exec( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc(), Notification.id.desc()) + .offset(MAX_PER_USER) + ).all() + for row in extra: + session.delete(row) + + def purge_expired(session: Session) -> int: - """Удалить уведомления старше RETENTION_HOURS. Возвращает число удалённых.""" + """Удалить ПРОЧИТАННЫЕ уведомления спустя RETENTION_HOURS после прочтения. + + Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна + хранения, не увидел бы их вовсе. Возвращает число удалённых.""" rows = session.exec( - select(Notification).where(Notification.created_at < _cutoff()) + select(Notification).where( + Notification.read_at.is_not(None), Notification.read_at < _cutoff() + ) ).all() for row in rows: session.delete(row) @@ -77,7 +100,7 @@ def purge_expired(session: Session) -> int: def list_for_user(session: Session, user_id: int) -> dict: - """Свежие (<72ч) уведомления пользователя + число непрочитанных. Чистит протухшие.""" + """Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные.""" purge_expired(session) rows = session.exec( select(Notification) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 66b43d5..7424dc1 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -34,6 +34,7 @@ scored AS ( m.group_id AS group_id, m.played_at AS played_at, mp.place AS place, + mp.was_random AS was_random, m.player_count AS player_count, ({MATCH_POINTS_SQL}) AS points, CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win @@ -140,8 +141,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, - AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика - -- «лучшая/худшая», сглаживание задавило бы её к 50) + AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика + -- «лучшая/худшая», сглаживание задавило бы её к 50) + SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games FROM scored s JOIN factions f ON f.id = s.faction_id JOIN expansions e ON e.id = f.expansion_id @@ -166,6 +168,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> "win_rate": _round(r["win_rate"] or 0.0, 4), "avg_place": _round(r["avg_place"], 2), "score": _round(r["score"], 1), + # Служебное (в API-схему не входит): партии, где фракция выбрана + # вручную, — для «любимой фракции» рандомные раздачи не в счёт. + "chosen_games": int(r["chosen_games"] or 0), } ) return out @@ -197,7 +202,12 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) - qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None - most_played = max(factions, key=lambda f: f["games"]) if factions else None + # Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false): + # рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой. + chosen = [f for f in factions if f["chosen_games"] > 0] + most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None + # Основная — просто самая игранная, включая рандомные раздачи. + main = max(factions, key=lambda f: f["games"]) if factions else None return { "user_id": user_id, "overall": overall, @@ -205,6 +215,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) - "best_faction": best, "worst_faction": worst, "most_played_faction": most_played, + "main_faction": main, "recent_form": _recent_form(session, user_id, group_id), "min_games": MIN_GAMES, } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index dc9bbfa..27cc48f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -140,8 +140,15 @@ def finish_match( def create_finished_match( client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives" ) -> dict: - """players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики).""" - roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players] + """players: [{user_id, faction_id, place, comment?, was_random?}] → завершённая партия.""" + roster = [ + { + "user_id": p["user_id"], + "faction_id": p["faction_id"], + "was_random": p.get("was_random", False), + } + for p in players + ] started = start_match(client, group_id, roster) assert started.status_code == 200, started.text results = [ diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index f76b119..fc10232 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -56,23 +56,49 @@ def test_create_list_mark_read(engine): assert after["items"][0]["read_at"] is not None -def test_purge_expired(engine): +def test_purge_removes_only_long_read(engine): + """Протухают лишь ПРОЧИТАННЫЕ (спустя 72ч после прочтения): непрочитанное должно + дождаться игрока, сколько бы тот ни отсутствовал.""" uid = _make_user(engine, "Боб") + old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( + hours=notification_service.RETENTION_HOURS + 1 + ) with Session(engine) as s: - n = Notification(user_id=uid, type="test", title="Старое") - s.add(n) - s.commit() - s.refresh(n) - # Состарим запись за пределы окна хранения. - n.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( - hours=notification_service.RETENTION_HOURS + 1 + # Старое непрочитанное, старое давно прочитанное и свежепрочитанное. + s.add(Notification(user_id=uid, type="test", title="Непрочитанное", created_at=old)) + s.add( + Notification( + user_id=uid, type="test", title="Давно прочитанное", created_at=old, read_at=old + ) + ) + s.add( + Notification( + user_id=uid, + type="test", + title="Свежепрочитанное", + created_at=old, + read_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) ) - s.add(n) s.commit() removed = notification_service.purge_expired(s) assert removed == 1 - assert notification_service.list_for_user(s, uid)["items"] == [] + titles = {n["title"] for n in notification_service.list_for_user(s, uid)["items"]} + assert titles == {"Непрочитанное", "Свежепрочитанное"} + + +def test_cap_per_user(engine): + """Кап MAX_PER_USER: при создании сверх лимита удаляются старейшие.""" + uid = _make_user(engine, "Копитель") + with Session(engine) as s: + for i in range(notification_service.MAX_PER_USER + 5): + notification_service.create(s, uid, type="test", title=f"n{i}") + data = notification_service.list_for_user(s, uid) + assert len(data["items"]) == notification_service.MAX_PER_USER + titles = {n["title"] for n in data["items"]} + assert "n0" not in titles # старейшие срезаны + assert f"n{notification_service.MAX_PER_USER + 4}" in titles # новейшее на месте def test_notifications_require_auth(client: TestClient): diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 72195ec..fb62917 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -124,3 +124,64 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch headers=csrf_headers(client), ) assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка + + +def test_most_played_faction_ignores_random(client: TestClient, engine): + """Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт.""" + me = login(client, "Фракционер") + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + p2 = add_group_member(engine, gid, "Партнёр") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + + # Дважды играл фракцией A по рандому, один раз фракцией B — по своему выбору. + for _ in range(2): + create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True}, + {"user_id": p2, "faction_id": fids[1], "place": 2}, + ], + ) + create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[2], "place": 1}, + {"user_id": p2, "faction_id": fids[1], "place": 2}, + ], + ) + + stats = client.get("/api/users/me/stats").json() + assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше + assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям + + # У соперника все фракции выбраны вручную — любимая считается как раньше. + login(client, "Партнёр") + stats2 = client.get("/api/users/me/stats").json() + assert stats2["most_played_faction"]["faction_id"] == fids[1] + + +def test_most_played_faction_absent_when_all_random(client: TestClient, engine): + """Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе.""" + me = login(client, "Рандомщик") + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + p2 = add_group_member(engine, gid, "Оппонент") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True}, + {"user_id": p2, "faction_id": fids[1], "place": 2, "was_random": True}, + ], + ) + stats = client.get("/api/users/me/stats").json() + assert stats["most_played_faction"] is None + assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index dbc5301..ff8fb94 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1682,6 +1682,8 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Avatar Url */ + avatar_url?: string | null; }; /** MatchRead */ MatchRead: { @@ -1880,6 +1882,7 @@ export interface components { best_faction?: components["schemas"]["FactionStat"] | null; worst_faction?: components["schemas"]["FactionStat"] | null; most_played_faction?: components["schemas"]["FactionStat"] | null; + main_faction?: components["schemas"]["FactionStat"] | null; /** * Recent Form * @default [] diff --git a/frontend/src/components/PickerSelect.tsx b/frontend/src/components/PickerSelect.tsx index a042c00..3a5554a 100644 --- a/frontend/src/components/PickerSelect.tsx +++ b/frontend/src/components/PickerSelect.tsx @@ -8,7 +8,7 @@ import { type ReactNode, useState } from "react"; * selected, найденный в ПОЛНОМ списке: выбранное значение может отсутствовать * в options (например, фракция занята другой строкой после «Рандом всем»). */ -export function PickerSelect({ +export function PickerSelect({ selected, options, placeholder, diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx new file mode 100644 index 0000000..7deb09d --- /dev/null +++ b/frontend/src/components/PlaceEditor.tsx @@ -0,0 +1,289 @@ +import { Scissors } from "lucide-react"; +import { useRef } from "react"; + +import { Avatar } from "./Avatar"; + +export interface PlacePlayer { + user_id: number; + nickname: string; + faction_name: string; + avatar_url?: string | null; +} + +type Target = + | { type: "merge"; idx: number } + | { type: "insert"; idx: number } + | { type: "elim" }; + +interface DragState { + blockIdx: number | null; // null — тянем блок из зоны выбывших + elimId: number | null; + el: HTMLElement; + startY: number; + rects: { idx: number; el: HTMLElement; rect: DOMRect }[]; + elimRect: DOMRect | null; +} + +/** + * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, + * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — + * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — + * выбывший. В каждом блоке — строка комментария об игроке. + * + * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и + * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, + * классы-подсветки) — setState только на отпускании, перерендер сбрасывает transform. + */ +export function PlaceEditor({ + players, + blocks, + eliminated, + comments, + onChange, + onComment, +}: { + players: PlacePlayer[]; + blocks: number[][]; + eliminated: number[]; + comments: Record; + onChange: (blocks: number[][], eliminated: number[]) => void; + onComment: (userId: number, text: string) => void; +}) { + const listRef = useRef(null); + const elimRef = useRef(null); + const indRef = useRef(null); + const dragRef = useRef(null); + + const byId = new Map(players.map((p) => [p.user_id, p])); + const placeOf = (i: number) => + 1 + blocks.slice(0, i).reduce((sum, b) => sum + b.length, 0); + + // ─── Drag ─── + + const computeTarget = (pointerY: number, d: DragState): Target => { + if (d.elimRect && pointerY > d.elimRect.top && pointerY < d.elimRect.bottom) { + return { type: "elim" }; + } + for (const r of d.rects) { + if (r.el === d.el) continue; + const h = r.rect.height; + if (pointerY > r.rect.top + h * 0.3 && pointerY < r.rect.bottom - h * 0.3) { + return { type: "merge", idx: r.idx }; + } + } + let idx = 0; + for (const r of d.rects) { + if (r.el === d.el) continue; + if (pointerY > r.rect.top + r.rect.height / 2) idx = r.idx + 1; + } + return { type: "insert", idx }; + }; + + const clearHints = () => { + if (indRef.current) indRef.current.style.display = "none"; + listRef.current + ?.querySelectorAll(".merge-target") + .forEach((n) => n.classList.remove("merge-target")); + elimRef.current?.classList.remove("highlight"); + }; + + const showHint = (t: Target, d: DragState) => { + clearHints(); + if (t.type === "elim") { + elimRef.current?.classList.add("highlight"); + return; + } + if (t.type === "merge") { + d.rects.find((r) => r.idx === t.idx)?.el.classList.add("merge-target"); + return; + } + const ind = indRef.current; + const list = listRef.current; + if (!ind || !list || d.rects.length === 0) return; + const listTop = list.getBoundingClientRect().top; + const others = d.rects.filter((r) => r.el !== d.el); + if (others.length === 0) return; + const y = + t.idx === 0 + ? others[0].rect.top - listTop - 5 + : (d.rects.find((r) => r.idx === t.idx - 1)?.rect.bottom ?? 0) - listTop + 2; + ind.style.top = `${y}px`; + ind.style.display = "block"; + }; + + const startDrag = ( + e: React.PointerEvent, + opts: { blockIdx?: number; elimId?: number }, + ) => { + e.preventDefault(); + const grip = e.currentTarget; + const el = (grip.closest("[data-idx]") ?? grip.closest(".rank-block")) as HTMLElement | null; + const list = listRef.current; + if (!el || !list) return; + dragRef.current = { + blockIdx: opts.blockIdx ?? null, + elimId: opts.elimId ?? null, + el, + startY: e.clientY, + rects: [...list.querySelectorAll(":scope > [data-idx]")].map((n) => ({ + idx: Number(n.dataset.idx), + el: n, + rect: n.getBoundingClientRect(), + })), + elimRect: elimRef.current?.getBoundingClientRect() ?? null, + }; + el.classList.add("dragging"); + grip.setPointerCapture(e.pointerId); + }; + + const moveDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + d.el.style.transform = `translateY(${e.clientY - d.startY}px)`; + showHint(computeTarget(e.clientY, d), d); + }; + + const endDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + dragRef.current = null; + clearHints(); + d.el.classList.remove("dragging"); + d.el.style.transform = ""; + + const t = computeTarget(e.clientY, d); + const moved = d.elimId != null ? [d.elimId] : [...blocks[d.blockIdx!]]; + const nextBlocks = blocks.map((b) => [...b]); + let nextElim = [...eliminated]; + const takeMoved = () => { + if (d.elimId != null) nextElim = nextElim.filter((x) => x !== d.elimId); + else nextBlocks.splice(d.blockIdx!, 1); + }; + + if (t.type === "elim") { + if (d.elimId != null) return; // уже там + takeMoved(); + nextElim.push(...moved); + } else if (t.type === "merge") { + const target = nextBlocks[t.idx]; // ссылка ДО удаления: индексы сместятся + takeMoved(); + target.push(...moved); + } else { + let idx = t.idx; + if (d.elimId == null && d.blockIdx! < idx) idx -= 1; + takeMoved(); + nextBlocks.splice(idx, 0, moved); + } + onChange(nextBlocks, nextElim); + }; + + const gripProps = (opts: { blockIdx?: number; elimId?: number }) => ({ + className: "rank-grip", + onPointerDown: (e: React.PointerEvent) => startDrag(e, opts), + onPointerMove: moveDrag, + onPointerUp: endDrag, + }); + + // ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё. + const unlink = (userId: number) => { + const bi = blocks.findIndex((b) => b.includes(userId)); + if (bi < 0) return; + const rest = blocks[bi].filter((x) => x !== userId); + const next = blocks.map((b) => [...b]); + next[bi] = rest; + next.splice(bi + 1, 0, [userId]); + if (rest.length === 0) next.splice(bi, 1); + onChange(next, eliminated); + }; + + // ─── Разметка ─── + + const playerRow = (userId: number, lead?: React.ReactNode, extra?: React.ReactNode) => { + const p = byId.get(userId); + if (!p) return null; + return ( +
+ {lead} + +
+
{p.nickname}
+
{p.faction_name}
+
+ {extra} +
+ ); + }; + + const commentInput = (userId: number) => ( + onComment(userId, e.target.value)} + /> + ); + + return ( +
+
+ {blocks.map((ids, i) => { + const place = placeOf(i); + const badge = ( + {place} + ); + if (ids.length === 1) { + return ( +
+ {playerRow(ids[0],
⠿
, badge)} + {commentInput(ids[0])} +
+ ); + } + return ( +
+
⠿
+
+ {ids.map((id) => ( +
+ {playerRow( + id, + undefined, + , + )} + {commentInput(id)} +
+ ))} +
+
+ ничья + {badge} +
+
+ ); + })} +
+
+ +
+
Выбыли (перетащите сюда) — считаются последним местом
+ {eliminated.map((id) => ( +
+ {playerRow( + id, +
⠿
, + ✕, + )} + {commentInput(id)} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index 009d565..adc1de7 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -44,6 +44,12 @@ export function ProfileStatsCard({ {stats.most_played_faction.name_ru}
)} + {stats.main_faction && ( +
+
Основная фракция
+ {stats.main_faction.name_ru} +
+ )} ); } diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index f452107..b54c2cb 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -4,6 +4,8 @@ import { useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { MatchMedia } from "../components/MatchMedia"; +import { PickerSelect } from "../components/PickerSelect"; +import { PlaceEditor } from "../components/PlaceEditor"; import { PlayerLink } from "../components/PlayerLink"; import { Spinner } from "../components/Spinner"; import { formatDate, formatDuration, formatTime } from "../domain/format"; @@ -17,14 +19,7 @@ import { useUploadMatchAttachment, } from "../hooks/matches"; -interface FinishRow { - user_id: number; - nickname: string; - faction_name: string; - place: number; - eliminated: boolean; - comment: string; -} +const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); export function MatchDetailPage() { const { matchId } = useParams(); @@ -37,7 +32,10 @@ export function MatchDetailPage() { const toast = useToast(); const navigate = useNavigate(); - const [rows, setRows] = useState(null); + // Раскладка мест: упорядоченные блоки user_id (длина >1 = ничья) + выбывшие. + const [blocks, setBlocks] = useState(null); + const [elim, setElim] = useState([]); + const [comments, setComments] = useState | null>(null); const [winReason, setWinReason] = useState("objectives"); const [overall, setOverall] = useState(""); const [error, setError] = useState(null); @@ -49,20 +47,11 @@ export function MatchDetailPage() { const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) const inProgress = match.status === "in_progress"; - // Ленивая инициализация строк завершения из участников. - const finishRows: FinishRow[] = - rows ?? - match.participants.map((p, i) => ({ - user_id: p.user_id, - nickname: p.nickname, - faction_name: p.faction_name, - place: i + 1, - eliminated: false, - comment: p.comment ?? "", - })); - - const upd = (i: number, patch: Partial) => - setRows(finishRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + // Ленивая инициализация раскладки из участников: каждый — отдельным блоком. + const finishBlocks: number[][] = blocks ?? match.participants.map((p) => [p.user_id]); + const finishComments: Record = + comments ?? + Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])); // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; @@ -70,16 +59,31 @@ export function MatchDetailPage() { const submitFinish = async () => { if (!id || !match) return; setError(null); + const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; + let place = 1; + const survivors = finishBlocks.flatMap((ids) => { + const rows = ids.map((uid) => ({ + user_id: uid, + place, + eliminated: false, + comment: commentOf(uid), + })); + place += ids.length; // competition ranking: ничья съедает следующие места + return rows; + }); try { await finish.mutateAsync({ matchId: id, body: { - participants: finishRows.map((r) => ({ - user_id: r.user_id, - place: r.eliminated ? null : r.place, - eliminated: r.eliminated, - comment: r.comment.trim() || null, - })), + participants: [ + ...survivors, + ...elim.map((uid) => ({ + user_id: uid, + place: null, + eliminated: true, + comment: commentOf(uid), + })), + ], win_reason: winReason, overall_comment: overall.trim() || null, expected_version: match.version, @@ -192,58 +196,45 @@ export function MatchDetailPage() { {canModify && ( -
-

Завершение партии

- {finishRows.map((r, i) => ( -
-
- {r.nickname} - {r.faction_name} -
-
-
- - -
-
- upd(i, { comment: e.target.value })} - /> -
- ))} - -
- - + <> +
+

Места

+

+ Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого + игрока, чтобы разделить место (ничья). +

+ { + setBlocks(b); + setElim(e); + }} + onComment={(uid, text) => + setComments({ ...finishComments, [uid]: text }) + } + />
-
- +
+

Причина победы

+ o.id === winReason) ?? null} + options={REASON_OPTIONS} + placeholder="— причина —" + renderOption={(o) => o.label} + onPick={(o) => setWinReason(o.id)} + /> +
+ +
+

О партии