From c8d5efea6536545f6dee3692e97e240c757f703b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 6 Sep 2026 22:00:44 +0300 Subject: [PATCH 01/30] =?UTF-8?q?Git:=20=D0=B8=D0=B3=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0=20(mockups/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tmt3pK9bN5jn7mjm8sFsdH --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8a2ce9f..da9df0e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ backend/openapi.json # Бэкапы (создаёт scripts/backup.sh на Pi) backups/ +# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория) +mockups/ + # AI-ассистенты (локальные, в репозиторий не идут) CLAUDE.md .claude/ From 5e4e4835f296859b76f4ddcea97e27ffe75fa5da Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 15:24:41 +0300 Subject: [PATCH 02/30] =?UTF-8?q?UI:=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=B2=D0=B0=D0=BB=20=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=20=E2=80=94=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20=D0=BD?= =?UTF-8?q?=D0=B0=20100dvh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подвал был position: fixed, шапка — position: sticky при html/body с height: 100%. В мобильных браузерах со скрывающейся адресной строкой layout viewport меняет высоту при прокрутке: fixed-подвал за ним дёргается, а у sticky-шапки нет однозначного прокручиваемого предка. Каркас переведён на 100dvh с единственной прокручиваемой областью (.app-main) — шапка и подвал стали обычными элементами флекс-колонки и дёргаться им больше нечем. Safe-area ограничена потолком 24px: при viewport-fit=cover часть браузеров (DuckDuckGo, Firefox Focus) отдаёт inset поверх уже смещённого вьюпорта, из-за чего шапка раздувалась вдвое, а надпись и колокольчик съезжали вниз. Заодно шапка защищена от распирания длинным названием на узких экранах. #4 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/styles/global.css | 19 +++++++++++++++---- frontend/src/styles/layout.css | 28 ++++++++++++++++------------ frontend/src/styles/tokens.css | 7 +++++-- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 432081c..16d510f 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -11,6 +11,11 @@ html, body, #root { margin: 0; } +/* Высота каркаса — по видимой области (dvh): на мобильных 100% берётся от layout + viewport, который меняется при сворачивании адресной строки. Правило выше остаётся + фоллбэком для движков без dvh. */ +#root { height: 100dvh; } + body { background: var(--bg); color: var(--text); @@ -18,6 +23,7 @@ body { Arial, sans-serif; -webkit-font-smoothing: antialiased; overscroll-behavior-y: none; + overflow: hidden; /* прокручивается только .app-main */ } a { color: var(--accent-2); text-decoration: none; } @@ -190,7 +196,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } .toast { position: fixed; left: 50%; - bottom: calc(var(--bottombar-height) + 80px); + bottom: calc(var(--bottombar-height) + var(--safe-bottom) + 16px); transform: translateX(-50%); background: var(--surface-2); border: 1px solid var(--border); @@ -206,7 +212,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } /* ─── Уведомления ─────────────────────────────────────────────────────────── */ /* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */ -.top-bar-left { display: flex; align-items: center; min-width: 0; } +.top-bar-left { display: flex; align-items: center; min-width: 0; flex: 0 1 auto; } .top-bar-center { flex: 1; text-align: center; @@ -215,7 +221,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } white-space: nowrap; padding: 0 8px; } -.top-bar-right { display: flex; align-items: center; } +.top-bar-right { display: flex; align-items: center; flex: none; } .top-bar-home { background: none; border: none; @@ -223,6 +229,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } color: var(--text); font-weight: 700; font-size: 17px; + /* На узких экранах длинное название иначе распирает шапку по горизонтали. */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .notif-bell-wrap { position: relative; } @@ -258,7 +269,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } top: calc(100% + 8px); right: 0; width: min(320px, 86vw); - max-height: 70vh; + max-height: 70dvh; overflow-y: auto; background: var(--surface); border: 1px solid var(--border); diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index f0597ae..49e7137 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -2,17 +2,16 @@ position: relative; max-width: var(--app-max-w); margin: 0 auto; - min-height: 100%; + height: 100%; display: flex; flex-direction: column; background: var(--bg); box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); } +/* Шапка вне прокручиваемой области по построению — sticky ей больше не нужен. */ .top-bar { - position: sticky; - top: 0; - z-index: 10; + flex: none; display: flex; align-items: center; justify-content: space-between; @@ -22,20 +21,24 @@ } .top-bar .title { font-weight: 700; font-size: 17px; } +/* Единственная прокручиваемая область приложения. */ .app-main { flex: 1; + min-height: 0; /* без этого flex-элемент не сжимается и прокрутка уходит в body */ overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; padding: 14px; - padding-bottom: calc(var(--bottombar-height) + var(--fab-height) + var(--safe-bottom) + 28px); + /* Подвал теперь в потоке — компенсировать нужно только выступающий купол FAB. */ + padding-bottom: 24px; } +/* Панель — обычный элемент флекс-колонки, не fixed: в мобильных браузерах со + скрывающейся адресной строкой fixed-подвал дёргается при прокрутке. + position: relative обязателен — на нём держится absolute-позиционирование .fab. */ .bottom-bar { - position: fixed; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 100%; - max-width: var(--app-max-w); + position: relative; + flex: none; height: calc(var(--bottombar-height) + var(--safe-bottom)); padding-bottom: var(--safe-bottom); background: var(--surface); @@ -131,6 +134,7 @@ /* Вкладки админки: горизонтальная карусель (не ломают вёрстку при переполнении) */ .admin-tabs { + flex: none; display: flex; gap: 8px; padding: 10px 14px; @@ -218,7 +222,7 @@ position: fixed; top: 0; right: 0; - height: 100%; + height: 100dvh; width: min(300px, 84vw); background: var(--surface); border-left: 1px solid var(--border); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 44a25ae..586dee3 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -20,8 +20,11 @@ --fab-height: 62.5px; --radius: 14px; --radius-sm: 10px; - --safe-bottom: env(safe-area-inset-bottom, 0px); - --safe-top: env(safe-area-inset-top, 0px); + /* Safe-area с потолком: часть мобильных браузеров (DuckDuckGo, Firefox Focus) при + viewport-fit=cover отдаёт inset поверх уже смещённого вьюпорта — без ограничения + шапка раздувается вдвое, а её содержимое съезжает вниз. */ + --safe-bottom: min(env(safe-area-inset-bottom, 0px), 24px); + --safe-top: min(env(safe-area-inset-top, 0px), 24px); /* Брейкпоинты (для справки) */ --bp-lg: 768px; From 81ad2a04031620c54b9ce7537a8ca3e5776c50b7 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 15:35:09 +0300 Subject: [PATCH 03/30] =?UTF-8?q?UI:=20=D0=BF=D0=BE=D0=B4=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B8=20=D0=BA=D1=83=D0=BF=D0=BE=D0=BB=20FAB=20=D0=B2?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=82=D0=BD=D1=83=D1=8E=20=D0=BA=20=D0=BD?= =?UTF-8?q?=D0=B8=D0=B6=D0=BD=D0=B5=D0=BC=D1=83=20=D0=BA=D1=80=D0=B0=D1=8E?= =?UTF-8?q?=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подвал держал внутренний отступ в safe-area снизу, а купол FAB был дополнительно поднят на 8px — у нижней кромки оставалась пустая полоса. Отступ снят: высота панели равна --bottombar-height, база купола на нулевой отметке. Токен --safe-bottom больше нигде не используется и удалён, отступ сверху (--safe-top) остаётся у шапки. #4 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/styles/global.css | 2 +- frontend/src/styles/layout.css | 5 ++--- frontend/src/styles/tokens.css | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 16d510f..b329cbf 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -196,7 +196,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } .toast { position: fixed; left: 50%; - bottom: calc(var(--bottombar-height) + var(--safe-bottom) + 16px); + bottom: calc(var(--bottombar-height) + 16px); transform: translateX(-50%); background: var(--surface-2); border: 1px solid var(--border); diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index 49e7137..995e49b 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -39,8 +39,7 @@ .bottom-bar { position: relative; flex: none; - height: calc(var(--bottombar-height) + var(--safe-bottom)); - padding-bottom: var(--safe-bottom); + height: var(--bottombar-height); background: var(--surface); border-top: 1px solid var(--border); display: flex; @@ -70,7 +69,7 @@ position: absolute; left: 50%; transform: translateX(-50%); - bottom: calc(var(--safe-bottom) + 8px); + bottom: 0; width: var(--fab-width); height: var(--fab-height); border: 1px solid var(--accent); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 586dee3..00a64c8 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -22,8 +22,8 @@ --radius-sm: 10px; /* Safe-area с потолком: часть мобильных браузеров (DuckDuckGo, Firefox Focus) при viewport-fit=cover отдаёт inset поверх уже смещённого вьюпорта — без ограничения - шапка раздувается вдвое, а её содержимое съезжает вниз. */ - --safe-bottom: min(env(safe-area-inset-bottom, 0px), 24px); + шапка раздувается вдвое, а её содержимое съезжает вниз. Снизу отступ не нужен: + подвал и купол FAB прижаты вплотную к нижнему краю экрана. */ --safe-top: min(env(safe-area-inset-top, 0px), 24px); /* Брейкпоинты (для справки) */ From 2a4c5d9d2575bbd0e43f705d1ee5ea62b6ac178f Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 15:40:51 +0300 Subject: [PATCH 04/30] =?UTF-8?q?UI:=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BF=D0=BB=D0=BE=D1=82=D0=BD=D1=83=D1=8E=20=D0=BA=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=85=D0=BD=D0=B5=D0=BC=D1=83=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D1=8E=20=E2=80=94=20=D1=81=D0=BD=D1=8F=D1=82=20viewport-?= =?UTF-8?q?fit=3Dcover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В DuckDuckGo шапка держала отступ от адресной строки — тот же safe-area, что раньше отодвигал подвал снизу. Insets браузер отдаёт именно из-за viewport-fit=cover: он передаёт управление безопасной зоной странице. После отказа от отступов сверху и снизу компенсировать нечего, поэтому cover снят — вьюпорт снова размещает браузер, а env() везде даёт ноль. Следом убран --safe-top и три его использования (.top-bar, .side-menu, .toast-top-wrap): без cover переменная всегда нулевая. Токенов safe-area в проекте больше нет. #4 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/index.html | 2 +- frontend/src/styles/global.css | 2 +- frontend/src/styles/layout.css | 3 +-- frontend/src/styles/tokens.css | 5 ----- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 874a03a..3748545 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Forbidden Stars — учёт партий diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index b329cbf..e5cd70b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -448,7 +448,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } /* Всплывашки сверху экрана в момент прихода */ .toast-top-wrap { position: fixed; - top: calc(var(--safe-top) + 10px); + top: 10px; left: 50%; transform: translateX(-50%); width: min(var(--app-max-w), 94vw); diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index 995e49b..f2c98e9 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -15,7 +15,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: calc(var(--safe-top) + 12px) 16px 12px; + padding: 12px 16px; background: var(--surface); border-bottom: 1px solid var(--border); } @@ -231,7 +231,6 @@ z-index: 61; display: flex; flex-direction: column; - padding-top: var(--safe-top); } .side-menu.open { transform: translateX(0); } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 00a64c8..ae0ef8b 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -20,11 +20,6 @@ --fab-height: 62.5px; --radius: 14px; --radius-sm: 10px; - /* Safe-area с потолком: часть мобильных браузеров (DuckDuckGo, Firefox Focus) при - viewport-fit=cover отдаёт inset поверх уже смещённого вьюпорта — без ограничения - шапка раздувается вдвое, а её содержимое съезжает вниз. Снизу отступ не нужен: - подвал и купол FAB прижаты вплотную к нижнему краю экрана. */ - --safe-top: min(env(safe-area-inset-top, 0px), 24px); /* Брейкпоинты (для справки) */ --bp-lg: 768px; From 09be0f57c6054316c976daf404c207e73d27341a Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 15:41:30 +0300 Subject: [PATCH 05/30] =?UTF-8?q?UI:=20=D0=BA=D1=83=D0=BF=D0=BE=D0=BB=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B8?= =?UTF-8?q?=20=E2=80=94=20140x70?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пропорция полукруга сохранена (ширина = 2x высоты), над кромкой панели купол снова выступает — на 6px при высоте подвала 64px. Прежние 125x62.5 после прижатия базы к нижнему краю утапливали купол в панель. #4 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/styles/tokens.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index ae0ef8b..71d0bdc 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -16,8 +16,8 @@ /* Размеры */ --app-max-w: 480px; --bottombar-height: 64px; - --fab-width: 125px; - --fab-height: 62.5px; + --fab-width: 140px; + --fab-height: 70px; --radius: 14px; --radius-sm: 10px; From 534ba640cca4fb87f5d848224effe63072aca3e9 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 15:51:19 +0300 Subject: [PATCH 06/30] =?UTF-8?q?UI:=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D1=83=20=D0=BA=D1=83?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B0=20FAB=20=E2=80=94=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=B2=D1=83=D1=8E=20=D0=B3=D0=B5=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit База купола теперь на нижнем крае экрана, а не смещена относительно кромки панели, и размеры стали 140x70. Комментарий описывал прежнее положение. #4 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/styles/layout.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index f2c98e9..6854a04 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -63,8 +63,9 @@ .bottom-bar .slot .name { color: var(--text); font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); } -/* Круг создания партии: полукруг-купол, по центру разделяет слоты, смещён вниз - (база у нижней кромки панели) и поднят на передний план (z-index выше панели). */ +/* Круг создания партии: полукруг-купол, по центру разделяет слоты, базой стоит на + нижнем крае экрана и поднят на передний план (z-index выше панели). Ширина вдвое + больше высоты (140x70) — над кромкой панели купол выступает на 6px. */ .fab { position: absolute; left: 50%; From b3e4f71bdac1908f0824eb51d633baa681fcf96b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 16:02:36 +0300 Subject: [PATCH 07/30] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D0=B0:=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B0=D1=8F=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D1=82=D0=B5=D0=BC=20=D0=B6=D0=B5=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=BE=D0=BC,=20=D1=87=D1=82=D0=BE=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На странице группы идущая партия лежала в общем списке «Партии» и отличалась от завершённых только бейджем «идёт». Теперь она выносится блоком InProgressMatches — тем же компонентом, что на главной. MatchListItem уже содержит всё, что нужно HomeInProgressMatch (started_at, player_count и тот же MatchListParticipant), поэтому бэкенд не тронут: недостающие group_id и group_name берутся из useMe/useGroup. Из списка «Партии» идущие партии исключены, иначе одна партия показывалась бы на странице дважды. #2 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/pages/GroupPage.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index 783fdb7..d590680 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -6,6 +6,7 @@ import { ApiError } from "../api/client"; import { CreateGroupForm } from "../components/CreateGroupForm"; import { GroupInvitations } from "../components/GroupInvitations"; import { GroupSwitcher } from "../components/GroupSwitcher"; +import { InProgressMatches } from "../components/InProgressMatches"; import { Leaderboard } from "../components/Leaderboard"; import { MatchListView } from "../components/MatchList"; import { PlayerSuggestions } from "../components/PlayerSuggestions"; @@ -83,6 +84,14 @@ export function GroupPage() { ); } + // Идущие партии выносим тем же блоком, что и на главной; из списка «Партии» + // их исключаем, иначе одна партия показалась бы на странице дважды. + const allMatches = matches?.items ?? []; + const inProgress = allMatches + .filter((m) => m.status === "in_progress") + .map((m) => ({ ...m, group_id: groupId, group_name: group?.name ?? "" })); + const finishedMatches = allMatches.filter((m) => m.status !== "in_progress"); + return (
@@ -152,8 +161,10 @@ export function GroupPage() { + +

Партии

- {matches && } + {matches && }
); } From 60b22dd8df78e7e1b6009a4986ea54f9ca0a1181 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 16:17:43 +0300 Subject: [PATCH 08/30] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C:=20=D0=BB=D1=8E=D0=B1=D0=B8=D0=BC=D0=B0=D1=8F=20=D1=84?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=86=D0=B8=D1=8F=20=E2=80=94=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше «любимая» вычислялась как самая частая среди партий с ручным выбором фракции. Теперь это поле профиля: users.favorite_faction_id (FK на factions, ON DELETE SET NULL, миграция 0010) и необязательное поле в PATCH /api/users/me/profile. Роутер профиля переведён на model_dump(exclude_unset=True) и обновляет только пришедшие поля — иначе сохранение одного «о себе» обнуляло бы выбранную фракцию. «Основная фракция» логику не меняет (она и так считалась по всем партиям, включая рандомные раздачи) и переезжает в подпись «Чаще всего играет на»: FactionStat получает name_ru_prepositional из словаря предложного падежа в faction_service. Словарь в коде, а не в БД: набор фракций задан игрой; фракция вне словаря выводится в именительном. Служебное chosen_games существовало только ради старой «любимой» — убрано из SQL вместе с ней. #17 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- .../versions/0010_user_favorite_faction.py | 46 ++++++++++ backend/app/models.py | 10 +++ backend/app/routers/users.py | 12 ++- backend/app/schemas/api.py | 12 ++- backend/app/services/faction_service.py | 24 ++++++ backend/app/services/stats_service.py | 40 +++++---- backend/app/services/user_service.py | 13 ++- backend/tests/test_profile.py | 83 +++++++++++++++---- 8 files changed, 207 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/0010_user_favorite_faction.py diff --git a/backend/alembic/versions/0010_user_favorite_faction.py b/backend/alembic/versions/0010_user_favorite_faction.py new file mode 100644 index 0000000..a5740ea --- /dev/null +++ b/backend/alembic/versions/0010_user_favorite_faction.py @@ -0,0 +1,46 @@ +"""Пользователь: любимая фракция (личный выбор в профиле). + +Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite). + +Revision ID: 0010_user_favorite_faction +Revises: 0009_participant_eliminated +Create Date: 2026-09-07 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0010_user_favorite_faction" +down_revision: Union[str, None] = "0009_participant_eliminated" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + if "favorite_faction_id" not in cols: + with op.batch_alter_table("users") as b: + b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True)) + b.create_foreign_key( + "fk_users_favorite_faction_id", + "factions", + ["favorite_faction_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + if "favorite_faction_id" in cols: + with op.batch_alter_table("users") as b: + b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey") + b.drop_column("favorite_faction_id") diff --git a/backend/app/models.py b/backend/app/models.py index 478d619..445f093 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -97,6 +97,16 @@ class User(SQLModel, table=True): index=True, ) ) + # Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление + # по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан. + favorite_faction_id: int | None = Field( + default=None, + sa_column=Column( + Integer, + ForeignKey("factions.id", ondelete="SET NULL"), + nullable=True, + ), + ) is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1")) # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # (выдача ачивок игрокам — следующий этап). diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 424a769..9f91354 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -31,6 +31,7 @@ def build_me(session: Session, user: User) -> s.MeRead: active_group_id=user.active_group_id, bio=user.bio, avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] + favorite_faction_id=user.favorite_faction_id, groups=groups, ) @@ -71,6 +72,7 @@ def update_me( active_group_id=user.active_group_id, bio=user.bio, avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] + favorite_faction_id=user.favorite_faction_id, ) @@ -81,14 +83,20 @@ def update_my_profile( session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MeRead: - user_service.update_bio(session, user, body.bio) + # Обновляем только реально переданные поля: «О себе» и любимая фракция правятся + # по отдельности, и частичный запрос не должен обнулять соседнее поле. + changed = body.model_dump(exclude_unset=True) + if "bio" in changed: + user_service.update_bio(session, user, changed["bio"]) + if "favorite_faction_id" in changed: + user_service.update_favorite_faction(session, user, changed["favorite_faction_id"]) audit_service.record( session, actor_id=user.id, action="update", entity_type="user", entity_id=user.id, - payload={"bio": True}, + payload={key: True for key in changed}, ip=request.client.host if request.client else None, ) session.commit() diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 2403c2a..aeae3dd 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -90,6 +90,8 @@ class UserRead(BaseModel): active_group_id: int | None = None bio: str | None = None avatar_url: str | None = None + # Любимая фракция — выбор игрока (id справочника); None — не выбрана. + favorite_faction_id: int | None = None class MeRead(UserRead): @@ -101,7 +103,10 @@ class NicknameUpdate(BaseModel): class ProfileUpdate(BaseModel): + # Оба поля необязательны и обновляются, только если реально переданы + # (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию. bio: str | None = None + favorite_faction_id: int | None = None class ActiveGroupUpdate(BaseModel): @@ -311,6 +316,8 @@ class FactionStat(BaseModel): faction_id: int code: str name_ru: str + # Название в предложном падеже — для строки «Чаще всего играет на …». + name_ru_prepositional: str expansion_code: str games: int wins: int @@ -331,8 +338,9 @@ class ProfileStats(BaseModel): factions: list[FactionStat] = [] best_faction: FactionStat | None = None worst_faction: FactionStat | None = None - # Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом). - most_played_faction: FactionStat | None = None + # Любимая — личный выбор игрока в профиле (не статистика). + favorite_faction: FactionRead | None = None + # «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи. main_faction: FactionStat | None = None recent_form: list[RecentFormItem] = [] # Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал diff --git a/backend/app/services/faction_service.py b/backend/app/services/faction_service.py index 8d2529d..7ccae4c 100644 --- a/backend/app/services/faction_service.py +++ b/backend/app/services/faction_service.py @@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]: select(Faction).order_by(Faction.expansion_id, Faction.sort_order) ).all() ) + + +# Предложный падеж названий фракций для фразы «Чаще всего играет на …». +# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется. +# Фракция, добавленная админом мимо словаря, выводится в именительном падеже. +_PREPOSITIONAL: dict[str, str] = { + "orks": "Орках", + "ultramarines": "Ультрамаринах", + "eldar": "Эльдарах", + "chaos": "Хаоситах", + "astra_militarum": "Имперской гвардии", + "tau": "Тау", + "necrons": "Некронах", + "tyranids": "Тиранидах", + "inquisition": "Инквизиции", + "sisters_of_battle": "Сёстрах битвы", + "drukhari": "Друкхари", + "adeptus_mechanicus": "Адептус Механикус", +} + + +def prepositional(code: str, name_ru: str) -> str: + """Название фракции в предложном падеже; вне словаря — именительный как есть.""" + return _PREPOSITIONAL.get(code, name_ru) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 7424dc1..13eeb4f 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -7,8 +7,8 @@ from sqlalchemy import text from sqlmodel import Session, select from app.core.timeutil import iso_utc -from app.models import Group, GroupMember, Match -from app.services import group_service, membership_service, user_service +from app.models import Faction, Group, GroupMember, Match, User +from app.services import faction_service, group_service, membership_service, user_service from app.services.scoring import ( FACTION_MIN_GAMES, MATCH_POINTS_SQL, @@ -141,9 +141,8 @@ 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) - SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games + AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика + -- «лучшая/худшая», сглаживание задавило бы её к 50) FROM scored s JOIN factions f ON f.id = s.faction_id JOIN expansions e ON e.id = f.expansion_id @@ -168,9 +167,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), + "name_ru_prepositional": faction_service.prepositional( + r["code"], r["name_ru"] + ), } ) return out @@ -196,17 +195,30 @@ def _recent_form(session: Session, user_id: int, group_id: int | None, limit: in ] +def _favorite_faction(session: Session, user_id: int) -> dict | None: + """Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики.""" + user = session.get(User, user_id) + if user is None or user.favorite_faction_id is None: + return None + faction = session.get(Faction, user.favorite_faction_id) + if faction is None: + return None + return { + "id": faction.id, + "code": faction.code, + "name_ru": faction.name_ru, + "expansion_id": faction.expansion_id, + } + + def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict: overall = _overall_for_user(session, user_id, group_id) factions = _faction_breakdown(session, user_id, group_id) 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 - # Любимая — по числу партий, где игрок выбрал фракцию САМ (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, @@ -214,7 +226,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) - "factions": factions, "best_faction": best, "worst_faction": worst, - "most_played_faction": most_played, + "favorite_faction": _favorite_faction(session, user_id), "main_faction": main, "recent_form": _recent_form(session, user_id, group_id), "min_games": MIN_GAMES, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 13d7916..2dd6831 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -11,7 +11,7 @@ from sqlmodel import Session, select from app.auth.provider import ExternalIdentity from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError -from app.models import AuthIdentity, GroupMember, User +from app.models import AuthIdentity, Faction, GroupMember, User _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _BIO_MAX = 500 @@ -185,6 +185,17 @@ def update_bio(session: Session, user: User, bio: str | None) -> User: return user +def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User: + """Любимая фракция — личный выбор игрока; None очищает выбор.""" + if faction_id is not None and session.get(Faction, faction_id) is None: + raise ValidationError("Такой фракции не существует.") + user.favorite_faction_id = faction_id + session.add(user) + session.commit() + session.refresh(user) + return user + + def sniff_image_ext(content: bytes) -> str | None: """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" if content.startswith(b"\x89PNG\r\n\x1a\n"): diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index fb62917..2f346af 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -1,4 +1,5 @@ -"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль.""" +"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление), +публичный профиль.""" from __future__ import annotations from fastapi.testclient import TestClient @@ -126,8 +127,8 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка -def test_most_played_faction_ignores_random(client: TestClient, engine): - """Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт.""" +def test_main_faction_counts_random_games(client: TestClient, engine): + """«Чаще всего играет на» — по всей истории, включая рандомные раздачи.""" me = login(client, "Фракционер") exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( @@ -156,17 +157,15 @@ def test_most_played_faction_ignores_random(client: TestClient, engine): ) 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] + # Условия выбора не важны: A сыграна чаще, пусть и по рандому. + assert stats["main_faction"]["faction_id"] == fids[0] + assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена + # Любимая больше не выводится из истории — она задаётся игроком вручную. + assert stats["favorite_faction"] is None -def test_most_played_faction_absent_when_all_random(client: TestClient, engine): - """Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе.""" +def test_main_faction_present_for_random_only_player(client: TestClient, engine): + """Игроку, который всегда играет рандомом, «чаще всего играет на» всё равно считается.""" me = login(client, "Рандомщик") exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( @@ -183,5 +182,61 @@ def test_most_played_faction_absent_when_all_random(client: TestClient, engine): ], ) stats = client.get("/api/users/me/stats").json() - assert stats["most_played_faction"] is None - assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика + assert stats["main_faction"]["faction_id"] == fids[0] + + +def test_favorite_faction_set_and_clear(client: TestClient): + """Любимая фракция — личный выбор игрока: сохраняется и снимается.""" + login(client, "Выбирающий") + fid = client.get("/api/factions").json()[0]["id"] + + r = client.patch( + "/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + assert r.json()["favorite_faction_id"] == fid + assert client.get("/api/users/me").json()["favorite_faction_id"] == fid + # Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики. + assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid + + r = client.patch( + "/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client) + ) + assert r.status_code == 200 + assert r.json()["favorite_faction_id"] is None + assert client.get("/api/users/me/stats").json()["favorite_faction"] is None + + +def test_favorite_faction_rejects_unknown(client: TestClient): + login(client, "Фантазёр") + r = client.patch( + "/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client) + ) + assert r.status_code == 422 + + +def test_bio_update_keeps_favorite_faction(client: TestClient): + """Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию.""" + login(client, "Аккуратный") + fid = client.get("/api/factions").json()[0]["id"] + client.patch( + "/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client) + ) + + r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client)) + assert r.status_code == 200 + assert r.json()["bio"] == "Только био" + assert r.json()["favorite_faction_id"] == fid + + +def test_prepositional_dictionary_and_fallback(): + """Падежная форма берётся из словаря; незнакомый код — именительный как есть.""" + from app.services import faction_service + + assert faction_service.prepositional("orks", "Орки") == "Орках" + assert ( + faction_service.prepositional("astra_militarum", "Имперская гвардия") + == "Имперской гвардии" + ) + # Фракция, заведённая админом мимо словаря, не роняет вывод. + assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы" From befabba752c107a0fa38c6bb2e1bad4236fe7a74 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 16:17:52 +0300 Subject: [PATCH 09/30] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BB=D1=8E=D0=B1?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D0=B9=20=D1=84=D1=80=D0=B0=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=C2=AB?= =?UTF-8?q?=D0=A7=D0=B0=D1=89=D0=B5=20=D0=B2=D1=81=D0=B5=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В кастомизации профиля появился выбор любимой фракции из полного справочника (PickerSelect, все 12 фракций): фракция — свойство игрока, а не группы, поэтому список не зависит от подключённых дополнений. Дополнение показано в строке опции приглушённым текстом, снятие выбора — кнопкой «Убрать». В карточке статистики «Любимая фракция» теперь берётся из профиля, а «Основная фракция» переименована в «Чаще всего играет на» и печатает название в предложном падеже. Публичный профиль правок не потребовал — он использует ту же карточку и ту же схему. schema.d.ts пересобран с живого бэкенда (npm run gen:api). #17 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/api/schema.d.ts | 10 +++- frontend/src/components/ProfileStatsCard.tsx | 9 ++-- frontend/src/hooks/auth.ts | 20 ++++++++ frontend/src/hooks/reference.ts | 8 ++++ frontend/src/pages/AccountPage.tsx | 50 ++++++++++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index ff8fb94..c5830ac 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1357,6 +1357,8 @@ export interface components { code: string; /** Name Ru */ name_ru: string; + /** Name Ru Prepositional */ + name_ru_prepositional: string; /** Expansion Code */ expansion_code: string; /** Games */ @@ -1761,6 +1763,8 @@ export interface components { bio?: string | null; /** Avatar Url */ avatar_url?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; /** * Groups * @default [] @@ -1881,7 +1885,7 @@ export interface components { factions: components["schemas"]["FactionStat"][]; best_faction?: components["schemas"]["FactionStat"] | null; worst_faction?: components["schemas"]["FactionStat"] | null; - most_played_faction?: components["schemas"]["FactionStat"] | null; + favorite_faction?: components["schemas"]["FactionRead"] | null; main_faction?: components["schemas"]["FactionStat"] | null; /** * Recent Form @@ -1898,6 +1902,8 @@ export interface components { ProfileUpdate: { /** Bio */ bio?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; }; /** PublicProfile */ PublicProfile: { @@ -2004,6 +2010,8 @@ export interface components { bio?: string | null; /** Avatar Url */ avatar_url?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; }; /** * UserSuggestion diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index adc1de7..496d9e2 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -38,16 +38,17 @@ export function ProfileStatsCard({
Очки (рейтинг)
{o.score ?? "—"}
- {stats.most_played_faction && ( + {/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */} + {stats.favorite_faction && (
Любимая фракция
- {stats.most_played_faction.name_ru} + {stats.favorite_faction.name_ru}
)} {stats.main_faction && (
-
Основная фракция
- {stats.main_faction.name_ru} +
Чаще всего играет на
+ {stats.main_faction.name_ru_prepositional}
)} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index aef11de..4c36ee8 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -117,6 +117,26 @@ export function useUpdateBio() { }); } +// Любимая фракция — личный выбор игрока (в статистике не вычисляется). null снимает выбор. +// Шлём только это поле: роутер обновляет лишь пришедшие, «о себе» не затирается. +export function useUpdateFavoriteFaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (factionId: number | null): Promise => + unwrap( + await api.PATCH("/api/users/me/profile", { + body: { favorite_faction_id: factionId }, + }), + ), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + // Карточка статистики показывает любимую фракцию — её тоже перезапрашиваем. + qc.invalidateQueries({ queryKey: qk.myStats }); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} + // Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным // fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. function readCsrfToken(): string | null { diff --git a/frontend/src/hooks/reference.ts b/frontend/src/hooks/reference.ts index 7f5133d..0b8abca 100644 --- a/frontend/src/hooks/reference.ts +++ b/frontend/src/hooks/reference.ts @@ -10,6 +10,14 @@ export function useExpansions() { }); } +// Весь справочник фракций (для выбора любимой в профиле — он не зависит от группы). +export function useFactions() { + return useQuery({ + queryKey: qk.factions, + queryFn: async () => unwrap(await api.GET("/api/factions")), + }); +} + export function useGroupFactions(groupId: number | null) { return useQuery({ queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"], diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 14aac89..0914290 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; import { Avatar } from "../components/Avatar"; +import { PickerSelect } from "../components/PickerSelect"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { useToast } from "../context/ToastContext"; @@ -11,9 +12,11 @@ import { useLogout, useMe, useUpdateBio, + useUpdateFavoriteFaction, useUpdateNickname, useUploadAvatar, } from "../hooks/auth"; +import { useExpansions, useFactions } from "../hooks/reference"; import { useMyStats } from "../hooks/stats"; export function AccountPage() { @@ -21,6 +24,9 @@ export function AccountPage() { const { data: stats } = useMyStats(); const updateNick = useUpdateNickname(); const updateBio = useUpdateBio(); + const updateFavorite = useUpdateFavoriteFaction(); + const { data: factions } = useFactions(); + const { data: expansions } = useExpansions(); const uploadAvatar = useUploadAvatar(); const deleteAvatar = useDeleteAvatar(); const logout = useLogout(); @@ -35,6 +41,20 @@ export function AccountPage() { // bio редактируется локально; до первого ввода берём значение из профиля. const bioValue = bio ?? me.bio ?? ""; + // Любимая фракция выбирается из ПОЛНОГО справочника: это свойство игрока, а не + // группы, и не должно зависеть от подключённых у неё дополнений. + const favorite = factions?.find((f) => f.id === me.favorite_faction_id) ?? null; + const expansionName = (id: number) => expansions?.find((e) => e.id === id)?.name_ru ?? ""; + + const saveFavorite = async (factionId: number | null) => { + try { + await updateFavorite.mutateAsync(factionId); + toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + const saveNick = async () => { const nick = nickname.trim(); if (!nick) return; @@ -140,6 +160,36 @@ export function AccountPage() { +
+ +
+ ( + <> + {f.name_ru} + + {expansionName(f.expansion_id)} + + + )} + renderValue={(f) => {f.name_ru}} + onPick={(f) => saveFavorite(f.id)} + /> + {favorite && ( + + )} +
+
+
From 1790e195e049ca2427f0ba913ea26a8a72de9127 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 16:35:10 +0300 Subject: [PATCH 10/30] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D0=B0:=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B5=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B8=D0=B3=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На главной партии приходят из разных групп, поэтому название нужно; на странице самой группы оно повторяет заголовок страницы. Блок получил проп showGroupName (по умолчанию true) — главная не меняется. #2 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/components/InProgressMatches.tsx | 13 +++++++++++-- frontend/src/pages/GroupPage.tsx | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx index d4528c7..bb1971f 100644 --- a/frontend/src/components/InProgressMatches.tsx +++ b/frontend/src/components/InProgressMatches.tsx @@ -4,7 +4,16 @@ import { useNavigate } from "react-router-dom"; import { formatTime, plural } from "../domain/format"; import type { HomeInProgressMatch } from "../domain/types"; -export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) { +/** Блок незавершённых партий. На главной партии приходят из разных групп, поэтому + * название группы нужно; на странице самой группы оно дублирует заголовок — там + * блок вызывается с showGroupName={false}. */ +export function InProgressMatches({ + items, + showGroupName = true, +}: { + items: HomeInProgressMatch[]; + showGroupName?: boolean; +}) { const navigate = useNavigate(); if (items.length === 0) return null; @@ -21,7 +30,7 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) { >
- {m.group_name} + {showGroupName && {m.group_name}} идёт
diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index d590680..5cf5f33 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -161,7 +161,8 @@ export function GroupPage() { - + {/* Название группы не показываем: оно уже есть в заголовке страницы. */} +

Партии

{matches && } From be8089144956787b3b666b79b8e6cf4fc527c022 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 19:56:06 +0300 Subject: [PATCH 11/30] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C:=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B8=D0=B9=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B5=D1=91=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Выборки партий по игроку в бэкенде не было — только по группе. Добавлен stats_service.user_match_list: завершённые партии игрока, свежие сверху; сборка элементов вынесена из group_match_list в общий _match_items, чтобы не дублировать её в двух местах. Витрина профиля задаётся двумя колонками в users (миграция 0011): history_mode (all/best) и history_detail (compact/full). Режим применяется на бэкенде, а не на клиенте: это витрина владельца, и в том же виде профиль видят гости. В режиме best берётся партия с максимальными League Points из SCORED_CTE (при равных очках — более свежая). GET /api/users/{user_id}/matches отдаёт список вместе с mode и detail — гостю хватает одного запроса, чтобы отрисовать историю как задумал владелец. #1 Co-Authored-By: Claude Opus 5 --- .../versions/0011_user_history_prefs.py | 50 ++++++ backend/app/models.py | 14 ++ backend/app/routers/users.py | 31 ++++ backend/app/schemas/api.py | 19 ++ backend/app/services/stats_service.py | 86 +++++++-- backend/app/services/user_service.py | 22 +++ backend/tests/test_profile.py | 165 +++++++++++++++++- 7 files changed, 372 insertions(+), 15 deletions(-) create mode 100644 backend/alembic/versions/0011_user_history_prefs.py diff --git a/backend/alembic/versions/0011_user_history_prefs.py b/backend/alembic/versions/0011_user_history_prefs.py new file mode 100644 index 0000000..539cb44 --- /dev/null +++ b/backend/alembic/versions/0011_user_history_prefs.py @@ -0,0 +1,50 @@ +"""Пользователь: настройки витрины истории партий в профиле. + +Идемпотентна: на свежей БД столбцы создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД добавляет столбцы. render_as_batch включён в env.py (для SQLite). + +Revision ID: 0011_user_history_prefs +Revises: 0010_user_favorite_faction +Create Date: 2026-09-07 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0011_user_history_prefs" +down_revision: Union[str, None] = "0010_user_favorite_faction" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + with op.batch_alter_table("users") as b: + if "history_mode" not in cols: + b.add_column( + sa.Column( + "history_mode", sa.String(8), nullable=False, server_default="all" + ) + ) + if "history_detail" not in cols: + b.add_column( + sa.Column( + "history_detail", sa.String(8), nullable=False, server_default="compact" + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + with op.batch_alter_table("users") as b: + if "history_detail" in cols: + b.drop_column("history_detail") + if "history_mode" in cols: + b.drop_column("history_mode") diff --git a/backend/app/models.py b/backend/app/models.py index 445f093..be047e8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -73,6 +73,10 @@ class User(SQLModel, table=True): "role <> 'admin' OR password_hash IS NOT NULL", name="ck_users_admin_has_password", ), + CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"), + CheckConstraint( + "history_detail IN ('compact','full')", name="ck_users_history_detail" + ), ) id: int | None = Field(default=None, primary_key=True) @@ -97,6 +101,16 @@ class User(SQLModel, table=True): index=True, ) ) + # Витрина истории партий в профиле: что показывать (все / только лучшая по очкам) + # и насколько подробно. Действует и для гостей профиля, не только для владельца. + history_mode: str = Field( + default="all", + sa_column=Column(String(8), nullable=False, server_default="all"), + ) + history_detail: str = Field( + default="compact", + sa_column=Column(String(8), nullable=False, server_default="compact"), + ) # Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление # по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан. favorite_faction_id: int | None = Field( diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 9f91354..0f374e4 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -32,6 +32,8 @@ def build_me(session: Session, user: User) -> s.MeRead: bio=user.bio, avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] favorite_faction_id=user.favorite_faction_id, + history_mode=user.history_mode, + history_detail=user.history_detail, groups=groups, ) @@ -73,6 +75,8 @@ def update_me( bio=user.bio, avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] favorite_faction_id=user.favorite_faction_id, + history_mode=user.history_mode, + history_detail=user.history_detail, ) @@ -90,6 +94,13 @@ def update_my_profile( user_service.update_bio(session, user, changed["bio"]) if "favorite_faction_id" in changed: user_service.update_favorite_faction(session, user, changed["favorite_faction_id"]) + if "history_mode" in changed or "history_detail" in changed: + user_service.update_history_prefs( + session, + user, + mode=changed.get("history_mode"), + detail=changed.get("history_detail"), + ) audit_service.record( session, actor_id=user.id, @@ -155,6 +166,26 @@ def get_user_profile( return user_service.public_profile(session, user_id) +@router.get("/{user_id}/matches", response_model=s.MatchHistory) +def user_matches( + user_id: int, + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + """История партий игрока. Режим — витрина владельца профиля: её видят и гости.""" + owner = user_service.get_user(session, user_id) + data = stats_service.user_match_list( + session, + user_id, + limit=limit, + offset=offset, + best_only=owner.history_mode == "best", + ) + return {**data, "mode": owner.history_mode, "detail": owner.history_detail} + + @router.get("/me/stats", response_model=s.ProfileStats) def my_stats( session: Session = Depends(get_session), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index aeae3dd..aedfa14 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -92,6 +92,9 @@ class UserRead(BaseModel): avatar_url: str | None = None # Любимая фракция — выбор игрока (id справочника); None — не выбрана. favorite_faction_id: int | None = None + # Витрина истории партий в профиле. + history_mode: str = "all" + history_detail: str = "compact" class MeRead(UserRead): @@ -107,6 +110,8 @@ class ProfileUpdate(BaseModel): # (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию. bio: str | None = None favorite_faction_id: int | None = None + history_mode: str | None = None + history_detail: str | None = None class ActiveGroupUpdate(BaseModel): @@ -306,6 +311,20 @@ class LeaderboardEntry(OverallStats): avatar_url: str | None = None +class MatchHistory(BaseModel): + """История партий игрока плюс настройки витрины его профиля. + + Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так, + как выбрал её владелец, не делая второго запроса за профилем.""" + + items: list[MatchListItem] = [] + total: int + limit: int + offset: int + mode: str + detail: str + + class Leaderboard(BaseModel): entries: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = [] diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 13eeb4f..690f537 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -7,7 +7,7 @@ from sqlalchemy import text from sqlmodel import Session, select from app.core.timeutil import iso_utc -from app.models import Faction, Group, GroupMember, Match, User +from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User from app.services import faction_service, group_service, membership_service, user_service from app.services.scoring import ( FACTION_MIN_GAMES, @@ -300,18 +300,10 @@ def group_stats(session: Session, group_id: int) -> dict: } -def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: +def _match_items(session: Session, matches) -> list[dict]: + """Элементы списка партий (общее для списка группы и истории игрока).""" from app.services.match_service import participants_detail # избегаем цикла импорта - total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all()) - matches = session.exec( - select(Match) - .where(Match.group_id == group_id) - .order_by(Match.played_at.desc(), Match.id.desc()) - .offset(offset) - .limit(limit) - ).all() - items = [] for m in matches: parts = [] @@ -343,7 +335,77 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i "participants": parts, } ) - return {"items": items, "total": total, "limit": limit, "offset": offset} + return items + + +def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: + total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all()) + matches = session.exec( + select(Match) + .where(Match.group_id == group_id) + .order_by(Match.played_at.desc(), Match.id.desc()) + .offset(offset) + .limit(limit) + ).all() + return { + "items": _match_items(session, matches), + "total": total, + "limit": limit, + "offset": offset, + } + + +def user_match_list( + session: Session, + user_id: int, + limit: int = 20, + offset: int = 0, + best_only: bool = False, +) -> dict: + """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. + + best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает + место и размер стола); при равных очках берём более свежую.""" + if best_only: + sql = f""" + {SCORED_CTE} + SELECT s.match_id AS match_id + FROM scored s + WHERE s.user_id = :uid + ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC + LIMIT 1 + """ + row = session.execute(text(sql), {"uid": user_id}).mappings().first() + matches = [session.get(Match, row["match_id"])] if row else [] + return { + "items": _match_items(session, matches), + "total": len(matches), + "limit": 1, + "offset": 0, + } + + where = (MatchParticipant.user_id == user_id, Match.status == "finished") + total = len( + session.exec( + select(Match.id) + .join(MatchParticipant, MatchParticipant.match_id == Match.id) + .where(*where) + ).all() + ) + matches = session.exec( + select(Match) + .join(MatchParticipant, MatchParticipant.match_id == Match.id) + .where(*where) + .order_by(Match.played_at.desc(), Match.id.desc()) + .offset(offset) + .limit(limit) + ).all() + return { + "items": _match_items(session, matches), + "total": total, + "limit": limit, + "offset": offset, + } def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 2dd6831..ea6d552 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -185,6 +185,28 @@ def update_bio(session: Session, user: User, bio: str | None) -> User: return user +_HISTORY_MODES = {"all", "best"} +_HISTORY_DETAILS = {"compact", "full"} + + +def update_history_prefs( + session: Session, user: User, *, mode: str | None = None, detail: str | None = None +) -> User: + """Витрина истории партий: что показывать и насколько подробно. None — не менять.""" + if mode is not None: + if mode not in _HISTORY_MODES: + raise ValidationError("Неизвестный режим истории партий.") + user.history_mode = mode + if detail is not None: + if detail not in _HISTORY_DETAILS: + raise ValidationError("Неизвестная подробность истории партий.") + user.history_detail = detail + session.add(user) + session.commit() + session.refresh(user) + return user + + def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User: """Любимая фракция — личный выбор игрока; None очищает выбор.""" if faction_id is not None and session.get(Faction, faction_id) is None: diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 2f346af..477b25f 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -1,10 +1,16 @@ -"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление), -публичный профиль.""" +"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар +(загрузка/отдача/удаление), публичный профиль.""" from __future__ import annotations from fastapi.testclient import TestClient -from tests.conftest import add_group_member, create_finished_match, csrf_headers, login +from tests.conftest import ( + add_group_member, + create_finished_match, + csrf_headers, + login, + start_match, +) # Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип. PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 @@ -240,3 +246,156 @@ def test_prepositional_dictionary_and_fallback(): ) # Фракция, заведённая админом мимо словаря, не роняет вывод. assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы" + + +# ─── История партий в профиле (#1) ─────────────────────────────────────────── + + +def _history(client: TestClient, user_id: int) -> dict: + r = client.get(f"/api/users/{user_id}/matches") + assert r.status_code == 200, r.text + return r.json() + + +def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]: + """Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id).""" + 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"] + mates = [add_group_member(engine, gid, nick) for nick in nicknames] + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + return gid, mates, fids + + +def test_history_lists_only_own_finished_matches(client: TestClient, engine): + """В историю идут только завершённые партии этого игрока.""" + me = login(client, "Историк") + gid, (mate,), fids = _group_with(client, engine, "Сосед") + + create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": mate, "faction_id": fids[1], "place": 2}, + ], + ) + # Незавершённая партия мест не имеет и в историю попадать не должна. + assert start_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[2]}, + {"user_id": mate, "faction_id": fids[3]}, + ], + ).status_code == 200 + + data = _history(client, me["id"]) + assert data["total"] == 1 + assert [m["status"] for m in data["items"]] == ["finished"] + # Значения по умолчанию едут вместе со списком — гостю хватает одного запроса. + assert data["mode"] == "all" + assert data["detail"] == "compact" + + +def test_history_excludes_matches_without_the_player(client: TestClient, engine): + """Чужая партия в историю игрока не попадает, даже внутри его группы.""" + me = login(client, "Наблюдатель") + gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3") + + # Партию заводит сосед, сам игрок в ней не участвует. + login(client, "Игрок2") + create_finished_match( + client, + gid, + [ + {"user_id": mate, "faction_id": fids[0], "place": 1}, + {"user_id": third, "faction_id": fids[1], "place": 2}, + ], + ) + + assert _history(client, me["id"])["total"] == 0 + assert _history(client, mate)["total"] == 1 + + +def test_history_best_mode_picks_highest_points(client: TestClient, engine): + """Режим best берёт партию с максимальными League Points, а не самую свежую. + + Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0.""" + me = login(client, "Лучший") + gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В") + + create_finished_match( + client, + gid, + [ + {"user_id": a, "faction_id": fids[0], "place": 1}, + {"user_id": me["id"], "faction_id": fids[1], "place": 2}, + {"user_id": b, "faction_id": fids[2], "place": 3}, + {"user_id": c, "faction_id": fids[3], "place": 4}, + ], + ) + # Свежее, но по очкам хуже — последнее место на двоих. + create_finished_match( + client, + gid, + [ + {"user_id": a, "faction_id": fids[0], "place": 1}, + {"user_id": me["id"], "faction_id": fids[1], "place": 2}, + ], + ) + + client.patch( + "/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client) + ) + data = _history(client, me["id"]) + assert data["mode"] == "best" + assert data["total"] == 1 + assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками + + +def test_history_prefs_saved_and_validated(client: TestClient): + """Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое.""" + login(client, "Настройщик") + r = client.patch( + "/api/users/me/profile", + json={"history_mode": "best", "history_detail": "full"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert r.json()["history_mode"] == "best" + assert r.json()["history_detail"] == "full" + assert client.get("/api/users/me").json()["history_detail"] == "full" + + bad = client.patch( + "/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client) + ) + assert bad.status_code == 422 + assert client.get("/api/users/me").json()["history_mode"] == "best" + + +def test_history_uses_owner_mode_for_guests(client: TestClient, engine): + """Гость видит историю в том режиме, который выбрал владелец профиля.""" + me = login(client, "Витрина") + gid, (mate,), fids = _group_with(client, engine, "Партнёр") + for _ in range(2): + create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": mate, "faction_id": fids[1], "place": 2}, + ], + ) + client.patch( + "/api/users/me/profile", + json={"history_mode": "best", "history_detail": "full"}, + headers=csrf_headers(client), + ) + + login(client, "Прохожий") + data = _history(client, me["id"]) + assert data["mode"] == "best" + assert data["detail"] == "full" + assert data["total"] == 1 From 9dda7de067031dece85f9194170954c52f267dd5 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 19:56:15 +0300 Subject: [PATCH 12/30] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C:=20=D0=B1=D0=BB=D0=BE=D0=BA=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=B8=D0=B3=D1=80=20=D1=81=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F=D0=BC=D0=B8=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В «Аккаунте» появился блок «История игр» с двумя тумблерами — «Только лучшая партия» и «Подробные карточки»; выбор уходит в профиль, поэтому переживает перезаход и другое устройство. Новый компонент MatchHistory: подробный режим переиспользует список партий группы (MatchListView со всеми участниками), компактный рисует строку с результатом самого игрока — место, фракция, дата и длительность. Публичный профиль показывает ту же историю в режиме владельца и без контролов: страница намеренно read-only. schema.d.ts пересобран с живого бэкенда (npm run gen:api). #1 Co-Authored-By: Claude Opus 5 --- frontend/src/api/queryKeys.ts | 1 + frontend/src/api/schema.d.ts | 102 +++++++++++++++++++++++ frontend/src/components/MatchHistory.tsx | 60 +++++++++++++ frontend/src/hooks/auth.ts | 17 ++++ frontend/src/hooks/users.ts | 15 ++++ frontend/src/pages/AccountPage.tsx | 40 +++++++++ frontend/src/pages/PublicProfilePage.tsx | 15 ++++ 7 files changed, 250 insertions(+) create mode 100644 frontend/src/components/MatchHistory.tsx diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 951a533..99e8c19 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -7,6 +7,7 @@ export const qk = { factions: ["factions"] as const, myStats: ["myStats"] as const, publicProfile: (id: number) => ["publicProfile", id] as const, + userMatches: (id: number) => ["userMatches", id] as const, userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const, groups: ["groups"] as const, group: (id: number) => ["group", id] as const, diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index c5830ac..2b2a03b 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -174,6 +174,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/users/{user_id}/matches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * User Matches + * @description История партий игрока. Режим — витрина владельца профиля: её видят и гости. + */ + get: operations["user_matches_api_users__user_id__matches_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me/stats": { parameters: { query?: never; @@ -1599,6 +1619,30 @@ export interface components { /** Faction Id */ faction_id?: number | null; }; + /** + * MatchHistory + * @description История партий игрока плюс настройки витрины его профиля. + * + * Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так, + * как выбрал её владелец, не делая второго запроса за профилем. + */ + MatchHistory: { + /** + * Items + * @default [] + */ + items: components["schemas"]["MatchListItem"][]; + /** Total */ + total: number; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + /** Mode */ + mode: string; + /** Detail */ + detail: string; + }; /** MatchList */ MatchList: { /** @@ -1765,6 +1809,16 @@ export interface components { avatar_url?: string | null; /** Favorite Faction Id */ favorite_faction_id?: number | null; + /** + * History Mode + * @default all + */ + history_mode: string; + /** + * History Detail + * @default compact + */ + history_detail: string; /** * Groups * @default [] @@ -1904,6 +1958,10 @@ export interface components { bio?: string | null; /** Favorite Faction Id */ favorite_faction_id?: number | null; + /** History Mode */ + history_mode?: string | null; + /** History Detail */ + history_detail?: string | null; }; /** PublicProfile */ PublicProfile: { @@ -2012,6 +2070,16 @@ export interface components { avatar_url?: string | null; /** Favorite Faction Id */ favorite_faction_id?: number | null; + /** + * History Mode + * @default all + */ + history_mode: string; + /** + * History Detail + * @default compact + */ + history_detail: string; }; /** * UserSuggestion @@ -2354,6 +2422,40 @@ export interface operations { }; }; }; + user_matches_api_users__user_id__matches_get: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MatchHistory"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; my_stats_api_users_me_stats_get: { parameters: { query?: never; diff --git a/frontend/src/components/MatchHistory.tsx b/frontend/src/components/MatchHistory.tsx new file mode 100644 index 0000000..b03c6d5 --- /dev/null +++ b/frontend/src/components/MatchHistory.tsx @@ -0,0 +1,60 @@ +import { useNavigate } from "react-router-dom"; + +import { formatDate, formatDuration } from "../domain/format"; +import type { MatchListItem } from "../domain/types"; +import { MatchListView } from "./MatchList"; + +/** + * История партий игрока в профиле. Подробный режим — тот же список, что у группы + * (MatchListView со всеми участниками); компактный — строка с результатом самого + * владельца профиля, поэтому компонент и принимает его userId. + */ +export function MatchHistory({ + items, + userId, + detail, +}: { + items: MatchListItem[]; + userId: number; + detail: string; +}) { + const navigate = useNavigate(); + if (items.length === 0) return
Партий пока нет.
; + if (detail === "full") return ; + + return ( +
+ {items.map((m) => { + const mine = m.participants.find((p) => p.user_id === userId); + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 4c36ee8..2982779 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -137,6 +137,23 @@ export function useUpdateFavoriteFaction() { }); } +// Витрина истории партий: режим (all/best) и подробность (compact/full). +// Шлём только изменившееся поле — роутер обновляет лишь пришедшие. +export function useUpdateHistoryPrefs() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (prefs: { + history_mode?: string; + history_detail?: string; + }): Promise => unwrap(await api.PATCH("/api/users/me/profile", { body: prefs })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + // Список истории приходит вместе с режимом — перезапрашиваем его целиком. + qc.invalidateQueries({ queryKey: qk.userMatches(me.id) }); + }, + }); +} + // Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным // fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. function readCsrfToken(): string | null { diff --git a/frontend/src/hooks/users.ts b/frontend/src/hooks/users.ts index 191a328..4502ecd 100644 --- a/frontend/src/hooks/users.ts +++ b/frontend/src/hooks/users.ts @@ -15,6 +15,21 @@ function useDebounced(value: T, ms = 100): T { return debounced; } +/** История партий игрока. Режим и подробность приходят вместе со списком: + * это витрина ВЛАДЕЛЬЦА профиля, её видят и гости. */ +export function useUserMatches(userId: number | null) { + return useQuery({ + queryKey: userId ? qk.userMatches(userId) : ["userMatches", "none"], + enabled: userId != null, + queryFn: async () => + unwrap( + await api.GET("/api/users/{user_id}/matches", { + params: { path: { user_id: userId as number } }, + }), + ), + }); +} + /** Подсказки по нику среди всех активных игроков (GET /api/users/search). */ export function useUserSearch(q: string, limit = 3) { const dq = useDebounced(q.trim()); diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 0914290..871d573 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -3,9 +3,11 @@ import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; import { Avatar } from "../components/Avatar"; +import { MatchHistory } from "../components/MatchHistory"; import { PickerSelect } from "../components/PickerSelect"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; +import { Switch } from "../components/Switch"; import { useToast } from "../context/ToastContext"; import { useDeleteAvatar, @@ -13,11 +15,13 @@ import { useMe, useUpdateBio, useUpdateFavoriteFaction, + useUpdateHistoryPrefs, useUpdateNickname, useUploadAvatar, } from "../hooks/auth"; import { useExpansions, useFactions } from "../hooks/reference"; import { useMyStats } from "../hooks/stats"; +import { useUserMatches } from "../hooks/users"; export function AccountPage() { const { data: me, isLoading } = useMe(); @@ -25,8 +29,10 @@ export function AccountPage() { const updateNick = useUpdateNickname(); const updateBio = useUpdateBio(); const updateFavorite = useUpdateFavoriteFaction(); + const updateHistory = useUpdateHistoryPrefs(); const { data: factions } = useFactions(); const { data: expansions } = useExpansions(); + const { data: history } = useUserMatches(me?.id ?? null); const uploadAvatar = useUploadAvatar(); const deleteAvatar = useDeleteAvatar(); const logout = useLogout(); @@ -55,6 +61,14 @@ export function AccountPage() { } }; + const saveHistory = async (prefs: { history_mode?: string; history_detail?: string }) => { + try { + await updateHistory.mutateAsync(prefs); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + const saveNick = async () => { const nick = nickname.trim(); if (!nick) return; @@ -203,6 +217,32 @@ export function AccountPage() { {stats && } +
+

История игр

+ {/* Настройки — витрина профиля: в этом же виде историю видят и гости. */} +
+
Только лучшая партия
+ saveHistory({ history_mode: v ? "best" : "all" })} + /> +
+
+
Подробные карточки
+ saveHistory({ history_detail: v ? "full" : "compact" })} + /> +
+
+ {history && ( + + )} +
+
+ diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx index 6b5a843..9c8c0dc 100644 --- a/frontend/src/pages/PublicProfilePage.tsx +++ b/frontend/src/pages/PublicProfilePage.tsx @@ -3,12 +3,14 @@ import { useParams } from "react-router-dom"; import { ApiError } from "../api/client"; import { Avatar } from "../components/Avatar"; +import { MatchHistory } from "../components/MatchHistory"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { useToast } from "../context/ToastContext"; import { useMe } from "../hooks/auth"; import { useInviteMember } from "../hooks/groups"; import { usePublicProfile } from "../hooks/stats"; +import { useUserMatches } from "../hooks/users"; /** * Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от @@ -19,6 +21,7 @@ export function PublicProfilePage() { const id = Number(userId); const { data, isLoading, isError } = usePublicProfile(id); const { data: me } = useMe(); + const { data: history } = useUserMatches(Number.isFinite(id) ? id : null); const invite = useInviteMember(); const toast = useToast(); const [pickOpen, setPickOpen] = useState(false); @@ -98,6 +101,18 @@ export function PublicProfilePage() { )} + + {/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */} + {history && history.items.length > 0 && ( +
+

{history.mode === "best" ? "Лучшая партия" : "История игр"}

+ +
+ )}
); } From d0f01a4cc24128433640ea4466b41f1d57b2653b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 14:39:16 +0300 Subject: [PATCH 13/30] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D1=83=20=C2=AB=D0=B8=D0=B4=D1=91=D1=82?= =?UTF-8?q?=C2=BB=20=D0=B8=D0=B7=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Блок озаглавлен «Незавершённые партии», а каждая карточка заканчивается кнопкой «Завершить» — бейдж дублировал и то и другое; на странице группы, где название группы не показывается, он вдобавок висел в строке один. Заодно снято мёртвое ветвление в MatchListView: незавершённые партии туда не попадают ни из группы (там отдельный блок), ни из истории профиля (бэкенд фильтрует по status="finished"). На странице самой партии бейдж остаётся — там он единственный указатель статуса. #26 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD --- frontend/src/components/InProgressMatches.tsx | 5 +---- frontend/src/components/MatchList.tsx | 14 ++++++-------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx index bb1971f..5b2daa5 100644 --- a/frontend/src/components/InProgressMatches.tsx +++ b/frontend/src/components/InProgressMatches.tsx @@ -29,10 +29,7 @@ export function InProgressMatches({ style={{ margin: 0, width: "100%", textAlign: "left" }} >
-
- {showGroupName && {m.group_name}} - идёт -
+ {showGroupName && {m.group_name}}
{formatTime(m.started_at)} · {m.player_count}{" "} {plural(m.player_count, "игрок", "игрока", "игроков")} diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index 36d0555..014a31e 100644 --- a/frontend/src/components/MatchList.tsx +++ b/frontend/src/components/MatchList.tsx @@ -4,6 +4,9 @@ import { formatDate, formatDuration } from "../domain/format"; import { winReasonLabel } from "../domain/winReasons"; import type { MatchListItem } from "../domain/types"; +/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их + * отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже + * отфильтрованной по status="finished". */ export function MatchListView({ items }: { items: MatchListItem[] }) { const navigate = useNavigate(); if (items.length === 0) return
Партий пока нет.
; @@ -11,7 +14,6 @@ export function MatchListView({ items }: { items: MatchListItem[] }) { return (
{items.map((m) => { - const inProgress = m.status === "in_progress"; const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); return ( )} @@ -206,29 +221,29 @@ export function CreateMatchPage() { 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="— игрок —" renderOption={(m) => playerLabel(m, 28)} renderValue={(m) => playerLabel(m, 24)} - onPick={(m) => update(idx, { user_id: m.id })} + onPick={(m) => update(row.id, { user_id: m.id })} />
- {spin[idx] != null ? ( -
{spin[idx]}
+ {spin[row.id] != null ? ( +
{spin[row.id]}
) : ( 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="— фракция —" 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 })} /> )}
@@ -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 })); + }} />
@@ -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 })); + }} />
([]); const [playedAt, setPlayedAt] = useState(""); @@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: {isLoading || !match ? ( + ) : match.status === "in_progress" ? ( + // Результаты идущей партии сервер не примет (409): места без завершения + // оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой. +
+

Партия ещё идёт

+

+ Результаты незавершённой партии правкой не вносятся — их проставляют + при завершении, на странице самой партии. Там же участники заполняют + места совместно. +

+ +
) : ( <>
From 763745a223741d97e64ff96003982e885c464096 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 20:00:49 +0300 Subject: [PATCH 23/30] =?UTF-8?q?Dev-=D0=BB=D0=B0=D1=83=D0=BD=D1=87=D0=B5?= =?UTF-8?q?=D1=80:=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D1=81=D1=82=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ветка задачи может принести миграцию, а run.ps1/run.sh сразу запускали uvicorn: приложение поднималось на старой схеме и падало 500 на первом обращении к новой таблице. Так вышло с 0012_match_finish_draft — создание партии отвечало «no such table: match_finish_drafts». В test/prod такого нет, там схему накатывает entrypoint.sh контейнера. Отдельно разобран случай «база новее ветки»: после возврата с ветки задачи alembic не находит ревизию, которой в этой ветке ещё нет. Останавливать запуск тут не за что — лишние таблицы старому коду не мешают, поэтому печатаем предупреждение и идём дальше. Настоящая ошибка миграции по-прежнему останавливает запуск. stderr alembic в run.ps1 уводится в файл: в Windows PowerShell 5.1 «2>&1» на нативном exe заворачивает каждую строку в ErrorRecord, а решение принимается по коду возврата. run.ps1 остался строго ASCII. #36 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- run.ps1 | 24 ++++++++++++++++++++++++ run.sh | 17 +++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/run.ps1 b/run.ps1 index 9bf026c..f3158ac 100644 --- a/run.ps1 +++ b/run.ps1 @@ -82,6 +82,30 @@ switch ($appEnv) { Write-Host "node_modules not found. First: cd frontend; npm install" -ForegroundColor Red exit 1 } + # Schema first: a branch may add a migration, and uvicorn would otherwise start + # on the old schema and fail with 500 on the first hit of a new table. + # stderr goes to a file: alembic logs there, and in PowerShell 5.1 "2>&1" on a + # native exe wraps every line into an ErrorRecord. + Write-Host "Applying migrations (alembic upgrade head)..." -ForegroundColor Green + $migLog = [System.IO.Path]::GetTempFileName() + Push-Location $backend + & $py -m alembic upgrade head 2> $migLog + $migCode = $LASTEXITCODE + Pop-Location + $migText = (Get-Content $migLog -Raw) + Remove-Item $migLog -Force -ErrorAction SilentlyContinue + if ($migCode -ne 0) { + Write-Host $migText + if ($migText -match "locate revision") { + # The DB carries a migration this branch does not have (switched back from + # a feature branch). Extra tables do not bother the older code - go on. + Write-Host "DB is newer than this branch - migrations skipped." -ForegroundColor Yellow + } else { + Write-Host "Migrations failed - not starting. Fix the error above and retry." -ForegroundColor Red + exit 1 + } + } + Write-Host "Starting backend (uvicorn --reload) and frontend (vite) in separate windows..." -ForegroundColor Green Start-Process powershell -ArgumentList @( "-NoExit", "-Command", diff --git a/run.sh b/run.sh index ad90724..047d691 100644 --- a/run.sh +++ b/run.sh @@ -50,6 +50,23 @@ case "$app_env" in [ -x "$py" ] || py="$root/backend/.venv/Scripts/python.exe" # Git Bash на Windows [ -x "$py" ] || { echo "Нет venv. Сначала: cd backend && python -m venv .venv && pip install -e '.[dev]'"; exit 1; } [ -d "$root/frontend/node_modules" ] || { echo "Нет node_modules. Сначала: cd frontend && npm install"; exit 1; } + # Сначала схема: ветка могла принести миграцию, и без неё бэк поднимется на + # старой схеме и упадёт 500 на первом же обращении к новой таблице. + echo "Применяю миграции (alembic upgrade head)…" + mig_log="$( cd "$root/backend" && "$py" -m alembic upgrade head 2>&1 )" && mig_code=0 || mig_code=$? + if [ "$mig_code" -ne 0 ]; then + echo "$mig_log" + case "$mig_log" in + *"locate revision"*) + # В базе миграция, которой нет в этой ветке (вернулись с ветки задачи). + # Лишние таблицы старому коду не мешают — продолжаем запуск. + echo "База новее ветки — миграции пропущены." ;; + *) + echo "Миграции не применились — не запускаю. Исправьте ошибку выше и повторите." + exit 1 ;; + esac + fi + echo "Запуск бэка (uvicorn --reload) и фронта (vite)… Ctrl+C — остановить всё." ( cd "$root/backend" && exec "$py" -m uvicorn app.main:app --reload ) & back=$! From 0d81901a63d29009a5d60a0432fbde1ce8ea8de5 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 20:16:33 +0300 Subject: [PATCH 24/30] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D1=82=D0=B8=D1=8F:=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=C2=BB=20=D1=83=20=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правка завершённой партии проверяла состав теми же правилами, что и создание: участник обязан состоять в группе сейчас, фракция — быть доступной сейчас. После отключения дополнения партию, сыгранную на Тау, было уже не исправить, а после удаления игрока из группы — любую партию с ним. Теперь то, что уже записано в партии, проходит всегда, а новые игроки и фракции по-прежнему берутся только из текущего состава: чинить историю можно, занести в неё постороннего — нет. Создание партии не ослабло. Править завершённую партию умел любой участник группы, но только через API — кнопки не было, и на практике это мог сделать лишь админ через админку. Теперь у блока «Результаты» есть «Редактировать», и правка идёт тем же перетаскиванием, что и завершение: раскладка восстанавливается из сохранённых мест (одинаковое место — ничья, выбывшие отдельно), рядом — фракции, причина победы и комментарии. Сохранение шлёт версию партии, так что устаревшая правка отклоняется как раньше. Фракции вынесены отдельным блоком, а не в PlaceEditor: он занят перетаскиванием, и селекты внутри него — лишний риск. В списке доступных фракций к набору группы добавляются те, что уже стоят в партии, — иначе фракцию из отключённого дополнения нельзя было бы даже оставить как есть. #29 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- backend/app/services/match_service.py | 27 +++- backend/tests/test_match_edit.py | 142 +++++++++++++++++++ frontend/src/hooks/matches.ts | 23 +++ frontend/src/pages/MatchDetailPage.tsx | 186 ++++++++++++++++++++++++- 4 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_match_edit.py diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index aecb9ef..e016aca 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -168,16 +168,28 @@ def _validate_roster_basics( group_id: int, user_ids: list[int], faction_ids: list[int], + *, + keep_user_ids: set[int] | None = None, + keep_faction_ids: set[int] | None = None, ) -> None: + """Состав партии: размер, отсутствие дублей, принадлежность группе. + + keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят + независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из + группы игрок делали бы старую партию неисправимой навсегда.""" if len(user_ids) < 2: raise ValidationError("В партии должно быть не менее 2 участников.") if len(user_ids) > MAX_MATCH_PLAYERS: raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.") if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids): raise DuplicateParticipantError() - if not set(user_ids).issubset(_group_member_ids(session, group_id)): + allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set()) + if not set(user_ids).issubset(allowed_users): raise ValidationError("Все участники должны состоять в группе.") - if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)): + allowed_factions = group_service.available_faction_ids(session, group_id) | ( + keep_faction_ids or set() + ) + if not set(faction_ids).issubset(allowed_factions): raise FactionNotAvailableError() @@ -423,18 +435,23 @@ def update_match( match.win_reason = win_reason if participants is not None: + saved = session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + # Что уже записано в партии, остаётся допустимым: состав группы и набор + # дополнений с тех пор могли поменяться, но историю это чинить не мешает. _validate_roster_basics( session, match.group_id, [p.user_id for p in participants], [p.faction_id for p in participants], + keep_user_ids={p.user_id for p in saved}, + keep_faction_ids={p.faction_id for p in saved}, ) places = _resolve_finish_places( [(p.user_id, p.place, p.eliminated) for p in participants] ) - for old in session.exec( - select(MatchParticipant).where(MatchParticipant.match_id == match.id) - ).all(): + for old in saved: session.delete(old) session.flush() for p in participants: diff --git a/backend/tests/test_match_edit.py b/backend/tests/test_match_edit.py new file mode 100644 index 0000000..43b11b8 --- /dev/null +++ b/backend/tests/test_match_edit.py @@ -0,0 +1,142 @@ +"""Правка завершённой партии игроком: история чинится после изменений в группе.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, create_finished_match, csrf_headers, login + + +def _exp_id(client: TestClient, code: str) -> int: + return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code) + + +def _played(client: TestClient, engine) -> tuple[dict, int, int, int, dict]: + """Партия «Аня против Бори» в группе с обоими дополнениями.""" + me = login(client, "Аня") + fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]}, + headers=csrf_headers(client), + ).json()["id"] + b = add_group_member(engine, gid, "Боря") + factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()} + mid = create_finished_match( + client, gid, + [ + {"user_id": me["id"], "faction_id": factions["orks"], "place": 1}, + {"user_id": b, "faction_id": factions["tau"], "place": 2}, + ], + )["id"] + return me, gid, b, mid, factions + + +def _swap_places(client: TestClient, mid: int, me_id: int, b: int, factions: dict) -> dict: + detail = client.get(f"/api/matches/{mid}").json() + fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]} + return { + "participants": [ + {"user_id": me_id, "faction_id": fid[me_id], "place": 2}, + {"user_id": b, "faction_id": fid[b], "place": 1}, + ], + "expected_version": detail["version"], + } + + +def test_edit_after_expansion_disabled(client: TestClient, engine): + """Дополнение выключили — партия на Тау всё равно правится. + + Регрессия: правка проверяла фракции по ТЕКУЩЕМУ набору группы, и партия, + сыгранная на фракции из отключённого дополнения, становилась неисправимой.""" + me, gid, b, mid, factions = _played(client, engine) + + off = client.put( + f"/api/groups/{gid}/expansions", + json={"expansion_ids": [_exp_id(client, "forsaken_voids")]}, + headers=csrf_headers(client), + ) + assert off.status_code == 200, off.text + + r = client.patch( + f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions), + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + places = {p["user_id"]: p["place"] for p in client.get(f"/api/matches/{mid}").json()["participants"]} + assert places[b] == 1 and places[me["id"]] == 2 + + +def test_edit_after_player_left_group(client: TestClient, engine): + """Игрока убрали из группы — партия с ним всё равно правится.""" + me, gid, b, mid, factions = _played(client, engine) + + out = client.delete(f"/api/groups/{gid}/members/{b}", headers=csrf_headers(client)) + assert out.status_code == 200, out.text + + r = client.patch( + f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions), + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + +def test_edit_rejects_new_outsider_and_unavailable_faction(client: TestClient, engine): + """Послабление — только для того, что уже в партии. + + Вписать нового игрока не из группы или фракцию, которой в партии не было и у + группы нет, по-прежнему нельзя: иначе в историю можно занести что угодно.""" + me, gid, b, mid, factions = _played(client, engine) + stranger = client.post( + "/api/auth/dev/users", json={"nickname": "Чужак"}, headers=csrf_headers(client) + ).json() + detail = client.get(f"/api/matches/{mid}").json() + fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]} + + bad_user = client.patch( + f"/api/matches/{mid}", + json={ + "participants": [ + {"user_id": me["id"], "faction_id": fid[me["id"]], "place": 1}, + {"user_id": stranger["id"], "faction_id": fid[b], "place": 2}, + ] + }, + headers=csrf_headers(client), + ) + assert bad_user.status_code == 422, bad_user.text + + # Выключаем дополнение и пробуем поставить ЕГО фракцию, которой в партии не было. + assert client.put( + f"/api/groups/{gid}/expansions", + json={"expansion_ids": [_exp_id(client, "forsaken_voids")]}, + headers=csrf_headers(client), + ).status_code == 200 + bad_faction = client.patch( + f"/api/matches/{mid}", + json={ + "participants": [ + {"user_id": me["id"], "faction_id": factions["necrons"], "place": 1}, + {"user_id": b, "faction_id": fid[b], "place": 2}, + ] + }, + headers=csrf_headers(client), + ) + assert bad_faction.status_code == 422, bad_faction.text + + +def test_create_match_still_validated(client: TestClient, engine): + """Создание партии не ослабло: посторонний игрок по-прежнему отклоняется.""" + me, gid, b, mid, factions = _played(client, engine) + stranger = client.post( + "/api/auth/dev/users", json={"nickname": "Чужак2"}, headers=csrf_headers(client) + ).json() + r = client.post( + "/api/matches", + json={ + "group_id": gid, + "participants": [ + {"user_id": me["id"], "faction_id": factions["orks"]}, + {"user_id": stranger["id"], "faction_id": factions["eldar"]}, + ], + }, + headers=csrf_headers(client), + ) + assert r.status_code == 422, r.text diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 2ccd13b..8945c2e 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -8,6 +8,7 @@ import type { MatchFinish, MatchFinishDraftData, MatchRead, + MatchUpdate, } from "../domain/types"; import { uploadFile } from "../lib/upload"; @@ -88,6 +89,28 @@ export function useFinishMatch() { }); } +/** Правка завершённой партии участником группы (у админки свой эндпойнт). */ +export function useUpdateMatch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { matchId: number; body: MatchUpdate }): Promise => + unwrap( + await api.PATCH("/api/matches/{match_id}", { + params: { path: { match_id: args.matchId } }, + body: args.body, + }), + ), + onSuccess: (m) => { + qc.setQueryData(qk.match(m.id), m); + qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); + qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); + // Места изменились — значит изменились лидерборд, история и профили. + for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); + }, + }); +} + + export function useDeleteMatch() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index a40a429..2ad8878 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -19,8 +19,10 @@ import { useFinishMatch, useMatch, useSaveFinishDraft, + useUpdateMatch, useUploadMatchAttachment, } from "../hooks/matches"; +import { useGroupFactions } from "../hooks/reference"; const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); @@ -31,8 +33,10 @@ export function MatchDetailPage() { const id = Number.isInteger(parsed) ? parsed : null; const { data: match, isLoading, refetch } = useMatch(id); const { data: me } = useMe(); + const { data: groupFactions } = useGroupFactions(match?.group_id ?? null); const finish = useFinishMatch(); const saveDraft = useSaveFinishDraft(id); + const updateMatch = useUpdateMatch(); const del = useDeleteMatch(); const uploadAtt = useUploadMatchAttachment(id ?? 0); const deleteAtt = useDeleteMatchAttachment(id ?? 0); @@ -47,6 +51,9 @@ export function MatchDetailPage() { const [overall, setOverall] = useState(""); const [error, setError] = useState(null); const [confirmRemove, setConfirmRemove] = useState(false); + // Правка завершённой партии: тот же редактор мест, что и при завершении. + const [editing, setEditing] = useState(false); + const [editFactions, setEditFactions] = useState>({}); // ─── Совместное заполнение формы ─── // Отложенная запись черновика: он меняется на каждое движение тайла, слать @@ -181,6 +188,77 @@ export function MatchDetailPage() { } }; + // Раскладка для правки восстанавливается из сохранённых результатов: одинаковое + // место = ничья, выбывшие уходят отдельным списком. + const startEditing = () => { + const byPlace = new Map(); + for (const p of match.participants.filter((x) => !x.eliminated)) { + const key = p.place ?? 99; + byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]); + } + setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids)); + setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id)); + setComments( + Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])), + ); + setWinReason((match.win_reason ?? "objectives") as WinReason); + setOverall(match.overall_comment ?? ""); + setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id]))); + setError(null); + setEditing(true); + }; + + const cancelEditing = () => { + setEditing(false); + setBlocks(null); + setComments(null); + setError(null); + }; + + const submitEdit = async () => { + if (!id || !match) return; + setError(null); + const wasRandom = Object.fromEntries( + match.participants.map((p) => [p.user_id, p.was_random]), + ); + const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; + const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({ + user_id: uid, + faction_id: editFactions[uid], + place, + eliminated, + was_random: wasRandom[uid] ?? false, + comment: commentOf(uid), + }); + let place = 1; + const survivors = finishBlocks.flatMap((ids) => { + const rows = ids.map((uid) => rowOf(uid, place, false)); + place += ids.length; // competition ranking: ничья съедает следующие места + return rows; + }); + try { + await updateMatch.mutateAsync({ + matchId: id, + body: { + participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))], + win_reason: winReason, + overall_comment: overall.trim() || null, + expected_version: match.version, + }, + }); + toast.show("Партия обновлена"); + cancelEditing(); + } catch (e) { + if (isStale(e)) { + toast.show("Партия изменилась на другом устройстве — обновлено"); + refetch(); + cancelEditing(); + return; + } + setError(e instanceof ApiError ? e.message : "Не удалось сохранить"); + } + }; + const remove = async () => { if (!id || !match) return; try { @@ -203,6 +281,15 @@ export function MatchDetailPage() { (a, b) => (a.place ?? 99) - (b.place ?? 99), ); + // К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение + // могли отключить после игры, но менять её фракцию это мешать не должно. + const factionOptions = [ + ...(groupFactions ?? []), + ...match.participants + .filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id)) + .map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })), + ]; + return (
@@ -226,11 +313,104 @@ export function MatchDetailPage() { {match.overall_comment &&

{match.overall_comment}

}
+ {/* Завершённая партия — правка результатов */} + {!inProgress && editing && ( + <> +
+

Правка результатов

+

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

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

Фракции

+
+ {match.participants.map((p) => ( +
+ {p.nickname} +
+ f.id === editFactions[p.user_id]) ?? null} + options={factionOptions.filter( + (f) => + f.id === editFactions[p.user_id] || + !Object.values(editFactions).includes(f.id), + )} + placeholder="— фракция —" + renderOption={(f) => f.name_ru} + onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })} + /> +
+
+ ))} +
+
+ +
+

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

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

О партии

+