From 87c7f48a3ee358896d870521c7ed582f21b992d2 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 20:11:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D1=8B:=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=E2=80=94=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20(=D0=BD=D0=B5=D1=81=D1=8B=D0=B3=D1=80=D0=B0=D0=B2=D1=88?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=BC),=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0=20=D1=86=D0=B5=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/app/schemas/api.py | 1 + backend/app/services/stats_service.py | 25 ++++++++++++++++- backend/tests/test_core_flow.py | 20 ++++++++++++++ frontend/src/api/schema.d.ts | 5 ++++ frontend/src/components/Leaderboard.tsx | 36 +++++++++++++++++-------- frontend/src/pages/GroupPage.tsx | 10 +++++-- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 3a1e73d..859069d 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -312,6 +312,7 @@ class GroupStats(BaseModel): last_match_at: str | None = None leaderboard: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = [] + inactive: list[LeaderboardEntry] = [] # участники без завершённых партий faction_meta: list[FactionMeta] = [] min_games: int diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 5764e81..008c320 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -8,7 +8,7 @@ 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 +from app.services import group_service, membership_service, user_service from app.services.scoring import ( FACTION_MIN_GAMES, MATCH_POINTS_SQL, @@ -240,12 +240,35 @@ def group_stats(session: Session, group_id: int) -> dict: } ) + # Участники без завершённых партий — отдельным блоком (нули, rank=null). + played_ids = {e["user_id"] for e in board["entries"]} | { + e["user_id"] for e in board["provisional"] + } + inactive = [] + for _m, u in membership_service.list_members(session, group_id): + if u.id in played_ids: + continue + inactive.append( + { + "user_id": u.id, + "nickname": u.nickname, + "games": 0, + "wins": 0, + "win_rate": 0.0, + "avg_place": None, + "score": None, + "rank": None, + "avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), + } + ) + return { "group_id": group_id, "total_matches": len(total_matches), "last_match_at": last_at, "leaderboard": board["entries"], "provisional": board["provisional"], + "inactive": inactive, "faction_meta": faction_meta, "min_games": MIN_GAMES, } diff --git a/backend/tests/test_core_flow.py b/backend/tests/test_core_flow.py index cc98721..4a6f407 100644 --- a/backend/tests/test_core_flow.py +++ b/backend/tests/test_core_flow.py @@ -287,6 +287,26 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin): assert guest_dev["is_active"] is False +def test_group_stats_includes_inactive_members(client: TestClient, engine): + """Участники без завершённых партий попадают в отдельный блок inactive (не в provisional).""" + me = login(client, "Капитан") + gid = client.post( + "/api/groups", json={"name": "Эскадра", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + rookie = add_group_member(engine, gid, "Новобранец") + + stats = client.get(f"/api/groups/{gid}/stats").json() + inactive_ids = {e["user_id"] for e in stats["inactive"]} + prov_ids = {e["user_id"] for e in stats["provisional"]} + lb_ids = {e["user_id"] for e in stats["leaderboard"]} + + # Никто ещё не играл → и владелец, и новобранец в inactive, и нигде больше. + assert {me["id"], rookie}.issubset(inactive_ids) + assert not ({me["id"], rookie} & (prov_ids | lb_ids)) + entry = next(e for e in stats["inactive"] if e["user_id"] == rookie) + assert entry["games"] == 0 and entry["rank"] is None + + def test_tie_ranking_and_points(client: TestClient, engine): """Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется.""" me = login(client, "A") diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 974c4e9..1e91103 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1286,6 +1286,11 @@ export interface components { * @default [] */ provisional: components["schemas"]["LeaderboardEntry"][]; + /** + * Inactive + * @default [] + */ + inactive: components["schemas"]["LeaderboardEntry"][]; /** * Faction Meta * @default [] diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index b1a803d..e5e13f2 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -9,20 +9,20 @@ function pct(v: number | null | undefined): string { function Row({ entry }: { entry: LeaderboardEntry }) { return ( -
+ // Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка + // ведёт в общую статистику, а клик по строке игрока — в его профиль. + e.stopPropagation()} + >
{entry.rank ?? "—"}
- e.stopPropagation()} - style={{ fontWeight: 600, color: "inherit" }} - > - {entry.nickname} - +
{entry.nickname}
Игр: {entry.games} Побед: {entry.wins} @@ -30,18 +30,22 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
{entry.score ?? "—"}
-
+ ); } export function Leaderboard({ entries, provisional, + inactive, }: { entries: LeaderboardEntry[]; provisional?: LeaderboardEntry[]; + inactive?: LeaderboardEntry[]; }) { - if (entries.length === 0 && (!provisional || provisional.length === 0)) { + const empty = + entries.length === 0 && !provisional?.length && !inactive?.length; + if (empty) { return
Пока нет сыгранных партий.
; } return ( @@ -59,6 +63,16 @@ export function Leaderboard({ ))} )} + {inactive && inactive.length > 0 && ( + <> +
+ Ещё не играли +
+ {inactive.map((e) => ( + + ))} + + )} ); } diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index 150df8c..bb01393 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -56,8 +56,14 @@ export function GroupPage() {
-

Топ в группе

- {stats && } +

Статистика игроков

+ {stats && ( + + )}