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

@ -125,9 +125,9 @@ verified players perks. It keeps everything in SimpleAdmin's database (`gv_verif
it to the ranks that should have it in the rank editor), staff give overrides for a day up to a
year or with no expiry, change them and remove them. Root sets the group and turns verification
on and off.
- **The public site** shows nothing about verification. On the staff panel, the Overview
scoreboard shows a "Verified" tag only while verification is on (enabled, with a group set) and
GroupVerify isn't missing from the server's plugin list.
- **No Verified tags** anywhere else: not on the public site, the scoreboards or player headers.
Who's verified is on the Verification page, and why in each player drawer's Verification
section.
- **Clan tag**: Root can set the scoreboard tag verified players without a rank get (GroupVerify's
`clan_tag`, at most 12 characters). CS2-Tags (1.0.4c-zio6+) applies it, and gives unverified
players without a rank no tag at all. Blank turns it off.

View file

@ -150,6 +150,36 @@ func (s *Store) hours(ctx context.Context, since time.Time, steamid uint64) ([]B
return out, rows.Err()
}
// present lists who was on T or CT in each UTC hour since a time, once per player per hour,
// including the segments still open (up to now).
func (s *Store) present(ctx context.Context, since time.Time) ([]Bucket, []uint64, error) {
where, args := s.and("server_id", []any{since, since, since.Add(-24 * time.Hour)})
rows, err := s.db.QueryContext(ctx, `WITH RECURSIVE seg AS (
SELECT steamid64, GREATEST(started_at, ?) AS t0, COALESCE(ended_at, UTC_TIMESTAMP()) AS t1 FROM pt_segments
WHERE team IN (2, 3) AND COALESCE(ended_at, UTC_TIMESTAMP()) > ? AND started_at >= ?`+where+`
), h AS (
SELECT steamid64, t1, CAST(DATE_FORMAT(t0, '%Y-%m-%d %H:00:00') AS DATETIME) AS b FROM seg WHERE t1 > t0
UNION ALL
SELECT steamid64, t1, b + INTERVAL 1 HOUR FROM h WHERE b + INTERVAL 1 HOUR < t1
)
SELECT DISTINCT b, steamid64 FROM h`, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var hours []Bucket
var ids []uint64
for rows.Next() {
var b Bucket
var id uint64
if err := rows.Scan(&b.Start, &id); err != nil {
return nil, nil, err
}
hours, ids = append(hours, b), append(ids, id)
}
return hours, ids, rows.Err()
}
// dayStart is local midnight on t's day in loc.
func dayStart(t time.Time, loc *time.Location) time.Time {
y, m, d := t.In(loc).Date()
@ -231,8 +261,10 @@ type MapTime struct {
// (the heatmap the last 28, four of each weekday), in the viewer's time zone.
type Overview struct {
Summary Summary `json:"summary"`
ByHour [24]float64 `json:"byHour"` // average players on T/CT in each local hour of the day
Heat [7][24]float64 `json:"heat"` // the same per weekday, Monday first
// How many different players were on T or CT in each local hour of the day, on average per
// day; Heat is the same per weekday, Monday first.
ByHour [24]float64 `json:"byHour"`
Heat [7][24]float64 `json:"heat"`
// The days ByHour and Heat average over: the last 30 (28 for Heat), or fewer while the
// recording is younger than that, so a new server doesn't look emptier than it is.
ByHourDays int `json:"byHourDays"`
@ -276,13 +308,20 @@ func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location)
o.ByHourDays++
}
// Each player counts once for every hour they were on T or CT in.
present, _, err := s.present(ctx, start)
if err != nil {
return o, err
}
for _, b := range present {
local := b.Start.In(loc)
o.ByHour[local.Hour()] += 1 / float64(max(o.ByHourDays, 1))
if wd := (int(local.Weekday()) + 6) % 7; !local.Before(heatFrom) && weekdays[wd] > 0 {
o.Heat[wd][local.Hour()] += 1 / float64(weekdays[wd])
}
}
for _, b := range buckets {
h := float64(b.Seconds) / 3600
local := b.Start.In(loc)
o.ByHour[local.Hour()] += h / float64(max(o.ByHourDays, 1))
if wd := (int(local.Weekday()) + 6) % 7; !local.Before(heatFrom) && weekdays[wd] > 0 {
o.Heat[wd][local.Hour()] += h / float64(weekdays[wd])
}
switch {
case !b.Start.Before(week):
o.Summary.Hours += h
@ -290,7 +329,7 @@ func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location)
o.Summary.HoursPrev += h
}
}
// Two decimals: a quiet hour averages well under one player.
// Two decimals: averaged over days, a quiet hour is a fraction of a player.
for i := range o.ByHour {
o.ByHour[i] = round2(o.ByHour[i])
}

View file

@ -172,12 +172,14 @@ func TestPlaytimeDB(t *testing.T) {
for _, h := range o.ByHour {
byHour += h
}
// Recording began with Bravo's session 10 days ago, so 3.5 h spread over those 11 days.
// Recording began with Bravo's session 10 days ago, so over those 11 days. Each player counts
// once per hour they were on T or CT in: Alpha in 2 hours, Bravo in 3 ten days ago and in 2 or
// 3 now (the open segment runs to the database's clock).
if o.ByHourDays != 11 || o.HeatDays != 11 {
t.Fatalf("averaged over %d days (heatmap %d), want 11", o.ByHourDays, o.HeatDays)
}
if want := 3.5 / 11; byHour < want-0.05 || byHour > want+0.05 {
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, want)
if n := byHour * 11; n < 6.8 || n > 8.2 {
t.Fatalf("%.2f player-hours over the days, want 7 or 8", n)
}
// Alpha on one day, Bravo on two days ten days apart.
players := 0

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")
}
}