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 && ( + + )}