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 ( -