Группы: статистика игроков — все участники (несыгравшие отдельным блоком), строка кликабельна целиком

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:11:32 +03:00
co-authored by Claude Opus 4.8
parent 366742d6da
commit 87c7f48a3e
6 changed files with 83 additions and 14 deletions
+1
View File
@@ -312,6 +312,7 @@ class GroupStats(BaseModel):
last_match_at: str | None = None last_match_at: str | None = None
leaderboard: list[LeaderboardEntry] = [] leaderboard: list[LeaderboardEntry] = []
provisional: list[LeaderboardEntry] = [] provisional: list[LeaderboardEntry] = []
inactive: list[LeaderboardEntry] = [] # участники без завершённых партий
faction_meta: list[FactionMeta] = [] faction_meta: list[FactionMeta] = []
min_games: int min_games: int
+24 -1
View File
@@ -8,7 +8,7 @@ from sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.models import Group, GroupMember, Match 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 ( from app.services.scoring import (
FACTION_MIN_GAMES, FACTION_MIN_GAMES,
MATCH_POINTS_SQL, 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 { return {
"group_id": group_id, "group_id": group_id,
"total_matches": len(total_matches), "total_matches": len(total_matches),
"last_match_at": last_at, "last_match_at": last_at,
"leaderboard": board["entries"], "leaderboard": board["entries"],
"provisional": board["provisional"], "provisional": board["provisional"],
"inactive": inactive,
"faction_meta": faction_meta, "faction_meta": faction_meta,
"min_games": MIN_GAMES, "min_games": MIN_GAMES,
} }
+20
View File
@@ -287,6 +287,26 @@ def test_disabled_account_cannot_login(client: TestClient, make_admin):
assert guest_dev["is_active"] is False 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): def test_tie_ranking_and_points(client: TestClient, engine):
"""Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется.""" """Ничьи: места 1,2,2 валидны; очки делятся; некорректная расстановка отклоняется."""
me = login(client, "A") me = login(client, "A")
+5
View File
@@ -1286,6 +1286,11 @@ export interface components {
* @default [] * @default []
*/ */
provisional: components["schemas"]["LeaderboardEntry"][]; provisional: components["schemas"]["LeaderboardEntry"][];
/**
* Inactive
* @default []
*/
inactive: components["schemas"]["LeaderboardEntry"][];
/** /**
* Faction Meta * Faction Meta
* @default [] * @default []
+25 -11
View File
@@ -9,20 +9,20 @@ function pct(v: number | null | undefined): string {
function Row({ entry }: { entry: LeaderboardEntry }) { function Row({ entry }: { entry: LeaderboardEntry }) {
return ( return (
<div className="lb-row"> // Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка
// ведёт в общую статистику, а клик по строке игрока — в его профиль.
<Link
to={`/u/${entry.user_id}`}
className="lb-row"
style={{ color: "inherit" }}
onClick={(e) => e.stopPropagation()}
>
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}> <div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
{entry.rank ?? "—"} {entry.rank ?? "—"}
</div> </div>
<Avatar url={entry.avatar_url} nickname={entry.nickname} size={32} /> <Avatar url={entry.avatar_url} nickname={entry.nickname} size={32} />
<div> <div>
<Link <div style={{ fontWeight: 600 }}>{entry.nickname}</div>
to={`/u/${entry.user_id}`}
// stopPropagation: ник кликабелен даже когда вся карточка-обёртка кликабельна.
onClick={(e) => e.stopPropagation()}
style={{ fontWeight: 600, color: "inherit" }}
>
{entry.nickname}
</Link>
<div className="lb-metrics"> <div className="lb-metrics">
<span>Игр: {entry.games}</span> <span>Игр: {entry.games}</span>
<span>Побед: {entry.wins}</span> <span>Побед: {entry.wins}</span>
@@ -30,18 +30,22 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
</div> </div>
</div> </div>
<div className="lb-score">{entry.score ?? "—"}</div> <div className="lb-score">{entry.score ?? "—"}</div>
</div> </Link>
); );
} }
export function Leaderboard({ export function Leaderboard({
entries, entries,
provisional, provisional,
inactive,
}: { }: {
entries: LeaderboardEntry[]; entries: LeaderboardEntry[];
provisional?: 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 <div className="muted small">Пока нет сыгранных партий.</div>; return <div className="muted small">Пока нет сыгранных партий.</div>;
} }
return ( return (
@@ -59,6 +63,16 @@ export function Leaderboard({
))} ))}
</> </>
)} )}
{inactive && inactive.length > 0 && (
<>
<div className="label" style={{ marginTop: 10 }}>
Ещё не играли
</div>
{inactive.map((e) => (
<Row key={e.user_id} entry={e} />
))}
</>
)}
</div> </div>
); );
} }
+8 -2
View File
@@ -56,8 +56,14 @@ export function GroupPage() {
<GroupInvitations /> <GroupInvitations />
<div className="card"> <div className="card">
<h3>Топ в группе</h3> <h3>Статистика игроков</h3>
{stats && <Leaderboard entries={stats.leaderboard} provisional={stats.provisional} />} {stats && (
<Leaderboard
entries={stats.leaderboard}
provisional={stats.provisional}
inactive={stats.inactive}
/>
)}
</div> </div>
<GroupSwitcher /> <GroupSwitcher />