Busiest hours count players; fewer descriptions; no Verified badges

Busiest hours and the heatmap count the different players on T or CT in
each hour, open segments included, instead of averaging player-hours, so
one player for part of an hour is 1, not 0.38. Their descriptions end in
the span covered (Last 30 days, or Since <date>). Side-by-side chart panels
match heights, and Players per day notes its busiest day.

Dropped the page descriptions on Bans, Gags & mutes, Staff and Playtime,
and the Verified badges on the staff Overview scoreboard and drawer header;
the Verification page and the drawer's Verification section still show it.
The GroupVerify DB test now checks the staff drawer, since the public site
says nothing about verification.
This commit is contained in:
Astra 2026-10-04 22:49:12 +01:00
parent 2516759fa9
commit ba61708bed
11 changed files with 88 additions and 81 deletions

View file

@ -35,7 +35,6 @@ func nonNil[T any](v []T) []T {
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
ctx := r.Context()
view := s.serverView(x, s.poll.Current())
s.markVerified(ctx, view.Players)
var ids []string
for _, p := range view.Players {

View file

@ -85,7 +85,7 @@ function onlineTable(players) {
<thead><tr><th>Player</th><th class="hide-sm">Rank</th><th class="num">K / D</th><th class="num hide-sm">Ping</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${p.verified ? VERIFIED_TAG : ""} ${tagsFor(p)}</div></div></td>
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${tagsFor(p)}</div></div></td>
<td class="hide-sm">${rankTag(p.rank)}</td>
<td class="num">${p.kills} / ${p.deaths}</td>
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
@ -773,7 +773,7 @@ async function openPlayer(sid) {
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}${d.verify?.verified ? ` ${VERIFIED_TAG}` : ""}</div></div>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}</div></div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>

View file

@ -112,27 +112,31 @@ function ptLeaders(d) {
</section>`;
}
// "the last 30 days", or how long the recording's been going when that's shorter.
function ptDaysText(n) {
if (n >= 30) return "the last 30 days";
if (n >= 28) return "the last 4 weeks";
return n > 1 ? `the ${n} days since recording began` : "today, the first day recorded";
// The span an average covers: "Last 30 days." (or 4 weeks), or "Since 4 Oct." while the
// recording is younger than that. n is the number of days, today included.
function ptSpan(n, full) {
if (n >= full) return full === 28 ? "Last 4 weeks." : "Last 30 days.";
const from = new Date();
from.setDate(from.getDate() - Math.max(0, (n || 1) - 1));
return `Since ${from.toLocaleDateString(undefined, { day: "numeric", month: "short" })}.`;
}
function ptBusiest(o) {
const vals = o.byHour.map((v, h) => ({ v, tick: h % 3 === 0 ? ptPad(h) : "", tip: `${ptPad(h)}:00 · ${v} players on average` }));
const players = (v) => `${v} ${v === 1 ? "player" : "players"}`;
const vals = o.byHour.map((v, h) => ({ v, tick: h % 3 === 0 ? ptPad(h) : "", tip: `${ptPad(h)}:00 · ${players(v)} a day` }));
const best = o.byHour.indexOf(Math.max(...o.byHour));
return `<section class="panel">
<div class="panel-head"><div><h2>Busiest hours</h2><p>Players on T or CT (bots don't count), averaged over ${ptDaysText(o.byHourDays)}, in your time zone.</p></div></div>
${ptColumns(vals, { note: o.byHour[best] ? `Busiest at <b>${ptPad(best)}:00</b>, with ${o.byHour[best]} players on average.` : "" })}
<div class="panel-head"><div><h2>Busiest hours</h2><p>Players on T or CT in each hour of the day, on average. Bots don't count. ${ptSpan(o.byHourDays, 30)}</p></div></div>
${ptColumns(vals, { note: o.byHour[best] ? `Busiest at <b>${ptPad(best)}:00</b>, with ${players(o.byHour[best])} a day on average.` : "No one has played on a team yet." })}
</section>`;
}
function ptDaily(o) {
const vals = o.days.map((d, i) => ({ v: d.players || 0, tick: i % 5 === 0 ? ptDay(d.date, { day: "numeric" }) : "", tip: `${ptDay(d.date)} · ${d.players || 0} players` }));
const best = o.days.reduce((b, d) => ((d.players || 0) > (b?.players || 0) ? d : b), null);
return `<section class="panel">
<div class="panel-head"><div><h2>Players per day</h2><p>Different players who joined each day, last 30 days.</p></div></div>
${ptColumns(vals, { fill: "var(--ct)", whole: true })}
${ptColumns(vals, { fill: "var(--ct)", whole: true, note: best ? `Busiest day: <b>${ptDay(best.date)}</b>, with ${best.players} ${best.players === 1 ? "player" : "players"}.` : "No one has joined yet." })}
</section>`;
}
@ -140,7 +144,7 @@ function ptHeatmap(o) {
const max = Math.max(0.1, ...o.heat.flat());
const step = (v) => (v <= 0.05 ? 0 : Math.min(5, Math.ceil((v / max) * 5)));
return `<section class="panel">
<div class="panel-head"><div><h2>When people play</h2><p>Average players on T or CT by day and hour, over ${ptDaysText(o.heatDays)}, in your time zone.</p></div></div>
<div class="panel-head"><div><h2>When people play</h2><p>Players on T or CT by day and hour, on average. ${ptSpan(o.heatDays, 28)}</p></div></div>
<div class="pt-heat"><table>
<thead><tr><th></th>${o.heat[0].map((_, h) => `<th>${h % 3 === 0 ? ptPad(h) : ""}</th>`).join("")}</tr></thead>
<tbody>${o.heat.map((row, wd) => `<tr><th scope="row">${PT_WEEKDAYS[wd]}</th>${row.map((v, h) => `<td><i style="background:var(--heat-${step(v)})" data-tip="${PT_WEEKDAYS[wd]} ${ptPad(h)}:00 · ${v} players"></i></td>`).join("")}</tr>`).join("")}</tbody>
@ -268,9 +272,7 @@ async function ptPage(me, isCurrent) {
const v = $("#view");
const staff = !!me?.playtimeStaff;
v.innerHTML = `
<div class="page-head"><div><h1>Playtime</h1><p class="sub">${staff
? "Who's on, staff activity and where players come from, then everything players see on the public site."
: "Who plays the most, and when the server is busiest. Click a player for their stats."}</p></div></div>
<div class="page-head"><div><h1>Playtime</h1></div></div>
${staff ? `<div id="pt-staff">${loading()}</div><h2 class="crew-sep">${me.playtime === "on" ? "On the public site" : ""}</h2>` : ""}
<div id="pt-public" class="stack pt-stack">${me?.playtime === "on" ? loading() : ""}</div>`;
if (me?.playtime !== "on") {

View file

@ -115,8 +115,6 @@ async function loadResults() {
const list = await api(path + qs({ status, q: state.query, page: state.num }));
if (loadResults.want !== want) return;
box.innerHTML = publicTable(list.items, bans ? "ban" : "comm") + pager(list);
const lead = $("#lead");
if (lead && bans) lead.textContent = `${list.active} ${list.active === 1 ? "player is" : "players are"} banned right now. Search for a name or SteamID64 to check a ban and when it ends.`;
} catch (err) {
box.innerHTML = errorBox(err);
}
@ -125,7 +123,7 @@ async function loadResults() {
function bans() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Bans</h1><p class="sub" id="lead">Search for a name or SteamID64 to check a ban and when it ends.</p></div>
<div><h1>Bans</h1></div>
</div>
<section class="panel">
<div class="toolbar">
@ -140,7 +138,7 @@ function bans() {
function comms() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Gags & mutes</h1><p class="sub">A gag blocks text chat, a mute blocks voice, and a silence blocks both. Blocks carry over between maps and reconnects.</p></div>
<div><h1>Gags & mutes</h1></div>
</div>
<section class="panel">
<div class="toolbar">
@ -173,10 +171,9 @@ async function staff() {
</div>`;
const staffGroups = data.groups.filter((g) => !g.supporter);
const supporters = data.groups.filter((g) => g.supporter);
const online = data.staffOnline;
v.innerHTML = `
<div class="page-head">
<div><h1>Staff</h1><p class="sub">${online ? `${online} staff ${online === 1 ? "is" : "are"} on the server right now.` : "No staff are on the server right now."}</p></div>
<div><h1>Staff</h1></div>
</div>
${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</div>'}
${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}

View file

@ -1199,7 +1199,11 @@ tr:nth-child(3) .pt-place { color: #d08a52; }
.drawer .pt-credit { border-top: 0; padding: 14px 22px; }
.pt-lines { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-size: 14px; }
.pt-stack > *, .pt-grid > * { min-width: 0; }
.pt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start; }
/* Side-by-side panels match heights; charts sit at the top and notes at the bottom. */
.pt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: stretch; }
.pt-grid > .panel { display: flex; flex-direction: column; }
.pt-grid > .panel > .pt-cols { flex: 1; display: flex; flex-direction: column; }
.pt-grid > .panel > .pt-cols .note { margin-top: auto; padding-top: 10px; }
#pt-tip {
position: fixed;

View file

@ -38,8 +38,6 @@ func toPublic(ps []store.Penalty) []publicPenalty {
type livePlayer struct {
live.Player
Rank *rankView `json:"rank,omitempty"`
// Verified by GroupVerify, when that's shown at all (see verifyInfo.Shown). Staff overview only.
Verified bool `json:"verified,omitempty"`
// Staff-only fields, left empty on the public page.
Tags []string `json:"tags,omitempty"`
CanTarget bool `json:"canTarget,omitempty"`

View file

@ -87,38 +87,6 @@ func (s *Server) verifyInfo(ctx context.Context) (verifyInfo, error) {
return verifyInfo{Tables: tables, Settings: settings, Plugin: state, Version: version}, nil
}
// verifiedSet returns which of these players are verified, or nil when verification isn't shown.
// Errors are logged and treated as not shown: verification is extra, never worth failing a page.
func (s *Server) verifiedSet(ctx context.Context, steamids []string) map[string]store.VerifyStatus {
info, err := s.verifyInfo(ctx)
if err == nil && info.Shown() {
var st map[string]store.VerifyStatus
if st, err = s.st.VerifyStatuses(ctx, info.Settings.Location, steamids); err == nil {
return st
}
}
if err != nil {
slog.Warn("GroupVerify status unavailable", "err", err)
}
return nil
}
// markVerified sets Verified on the live players who are.
func (s *Server) markVerified(ctx context.Context, players []livePlayer) {
var ids []string
for _, p := range players {
if !p.Bot {
ids = append(ids, p.SteamID)
}
}
set := s.verifiedSet(ctx, ids)
for i, p := range players {
if st, ok := set[p.SteamID]; ok && st.Verified() {
players[i].Verified = true
}
}
}
// ---------------------------------------------------------------- Staff API
type verifyStatusView struct {

View file

@ -176,8 +176,9 @@ func TestVerifyDB(t *testing.T) {
if got := verified("/api/admin/players/"+sid, mod); got != want {
t.Errorf("staff view of %s: verified %v, want %v", sid, got, want)
}
if got := verified("/api/public/players/"+sid, ""); got != want {
t.Errorf("public view of %s: verified %v, want %v", sid, got, want)
// The public site never says whether anyone is verified.
if _, out := call("GET", "/api/public/players/"+sid, "", nil); out["verified"] != nil {
t.Errorf("public view of %s says verified %v", sid, out["verified"])
}
}
@ -186,17 +187,17 @@ func TestVerifyDB(t *testing.T) {
expect(200, code, out, "remove override")
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
expect(404, code, out, "remove it again")
if verified("/api/public/players/"+charlie, "") {
if verified("/api/admin/players/"+charlie, mod) {
t.Error("still verified after removing the override")
}
// Turned off, nobody is verified, publicly or for staff.
// Turned off, nobody is verified.
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"enabled": false})
expect(200, code, out, "turn off")
if !strings.Contains(out["message"].(string), "off") {
t.Errorf("turn-off message %q", out["message"])
}
if verified("/api/public/players/"+member, "") || verified("/api/admin/players/"+member, mod) {
if verified("/api/admin/players/"+member, mod) {
t.Error("verified while GroupVerify is off")
}
@ -214,7 +215,7 @@ func TestVerifyDB(t *testing.T) {
if want := time.Now().Add(24 * time.Hour).In(tokyo).Format("2006-01-02 15:04"); raw != want && raw != time.Now().Add(24*time.Hour+time.Minute).In(tokyo).Format("2006-01-02 15:04") {
t.Errorf("expires_at %s, want Tokyo wall time %s", raw, want)
}
if !verified("/api/public/players/"+charlie, "") {
if !verified("/api/admin/players/"+charlie, mod) {
t.Error("override in Tokyo time isn't counted")
}
@ -230,7 +231,4 @@ func TestVerifyDB(t *testing.T) {
if _, out := call("GET", "/api/admin/players/"+charlie, mod, nil); out["verify"] != nil {
t.Errorf("player drawer has verify %v without the tables", out["verify"])
}
if verified("/api/public/players/"+charlie, "") {
t.Error("publicly verified without the tables")
}
}