Группы: статистика игроков — все участники (несыгравшие отдельным блоком), строка кликабельна целиком
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Vendored
+5
@@ -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 []
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user