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:
parent
2516759fa9
commit
ba61708bed
11 changed files with 88 additions and 81 deletions
|
|
@ -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
|
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
|
year or with no expiry, change them and remove them. Root sets the group and turns verification
|
||||||
on and off.
|
on and off.
|
||||||
- **The public site** shows nothing about verification. On the staff panel, the Overview
|
- **No Verified tags** anywhere else: not on the public site, the scoreboards or player headers.
|
||||||
scoreboard shows a "Verified" tag only while verification is on (enabled, with a group set) and
|
Who's verified is on the Verification page, and why in each player drawer's Verification
|
||||||
GroupVerify isn't missing from the server's plugin list.
|
section.
|
||||||
- **Clan tag**: Root can set the scoreboard tag verified players without a rank get (GroupVerify's
|
- **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
|
`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.
|
players without a rank no tag at all. Blank turns it off.
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,36 @@ func (s *Store) hours(ctx context.Context, since time.Time, steamid uint64) ([]B
|
||||||
return out, rows.Err()
|
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.
|
// dayStart is local midnight on t's day in loc.
|
||||||
func dayStart(t time.Time, loc *time.Location) time.Time {
|
func dayStart(t time.Time, loc *time.Location) time.Time {
|
||||||
y, m, d := t.In(loc).Date()
|
y, m, d := t.In(loc).Date()
|
||||||
|
|
@ -230,9 +260,11 @@ type MapTime struct {
|
||||||
// Overview is the public Playtime page. Week figures are the last 7 days; charts the last 30
|
// Overview is the public Playtime page. Week figures are the last 7 days; charts the last 30
|
||||||
// (the heatmap the last 28, four of each weekday), in the viewer's time zone.
|
// (the heatmap the last 28, four of each weekday), in the viewer's time zone.
|
||||||
type Overview struct {
|
type Overview struct {
|
||||||
Summary Summary `json:"summary"`
|
Summary Summary `json:"summary"`
|
||||||
ByHour [24]float64 `json:"byHour"` // average players on T/CT in each local hour of the day
|
// How many different players were on T or CT in each local hour of the day, on average per
|
||||||
Heat [7][24]float64 `json:"heat"` // the same per weekday, Monday first
|
// 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
|
// 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.
|
// recording is younger than that, so a new server doesn't look emptier than it is.
|
||||||
ByHourDays int `json:"byHourDays"`
|
ByHourDays int `json:"byHourDays"`
|
||||||
|
|
@ -276,13 +308,20 @@ func (s *Store) Overview(ctx context.Context, now time.Time, loc *time.Location)
|
||||||
o.ByHourDays++
|
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 {
|
for _, b := range buckets {
|
||||||
h := float64(b.Seconds) / 3600
|
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 {
|
switch {
|
||||||
case !b.Start.Before(week):
|
case !b.Start.Before(week):
|
||||||
o.Summary.Hours += h
|
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
|
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 {
|
for i := range o.ByHour {
|
||||||
o.ByHour[i] = round2(o.ByHour[i])
|
o.ByHour[i] = round2(o.ByHour[i])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,12 +172,14 @@ func TestPlaytimeDB(t *testing.T) {
|
||||||
for _, h := range o.ByHour {
|
for _, h := range o.ByHour {
|
||||||
byHour += h
|
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 {
|
if o.ByHourDays != 11 || o.HeatDays != 11 {
|
||||||
t.Fatalf("averaged over %d days (heatmap %d), want 11", o.ByHourDays, o.HeatDays)
|
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 {
|
if n := byHour * 11; n < 6.8 || n > 8.2 {
|
||||||
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, want)
|
t.Fatalf("%.2f player-hours over the days, want 7 or 8", n)
|
||||||
}
|
}
|
||||||
// Alpha on one day, Bravo on two days ten days apart.
|
// Alpha on one day, Bravo on two days ten days apart.
|
||||||
players := 0
|
players := 0
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
view := s.serverView(x, s.poll.Current())
|
view := s.serverView(x, s.poll.Current())
|
||||||
s.markVerified(ctx, view.Players)
|
|
||||||
|
|
||||||
var ids []string
|
var ids []string
|
||||||
for _, p := range view.Players {
|
for _, p := range view.Players {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
<tbody>
|
||||||
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
|
${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="hide-sm">${rankTag(p.rank)}</td>
|
||||||
<td class="num">${p.kills} / ${p.deaths}</td>
|
<td class="num">${p.kills} / ${p.deaths}</td>
|
||||||
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</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)}">
|
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<span class="avatar lg">${initial(name)}</span>
|
<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>
|
<button class="close" type="button" data-close aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
|
|
|
||||||
|
|
@ -112,27 +112,31 @@ function ptLeaders(d) {
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "the last 30 days", or how long the recording's been going when that's shorter.
|
// The span an average covers: "Last 30 days." (or 4 weeks), or "Since 4 Oct." while the
|
||||||
function ptDaysText(n) {
|
// recording is younger than that. n is the number of days, today included.
|
||||||
if (n >= 30) return "the last 30 days";
|
function ptSpan(n, full) {
|
||||||
if (n >= 28) return "the last 4 weeks";
|
if (n >= full) return full === 28 ? "Last 4 weeks." : "Last 30 days.";
|
||||||
return n > 1 ? `the ${n} days since recording began` : "today, the first day recorded";
|
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) {
|
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));
|
const best = o.byHour.indexOf(Math.max(...o.byHour));
|
||||||
return `<section class="panel">
|
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>
|
<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 ${o.byHour[best]} players on average.` : "" })}
|
${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>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ptDaily(o) {
|
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 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">
|
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>
|
<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>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +144,7 @@ function ptHeatmap(o) {
|
||||||
const max = Math.max(0.1, ...o.heat.flat());
|
const max = Math.max(0.1, ...o.heat.flat());
|
||||||
const step = (v) => (v <= 0.05 ? 0 : Math.min(5, Math.ceil((v / max) * 5)));
|
const step = (v) => (v <= 0.05 ? 0 : Math.min(5, Math.ceil((v / max) * 5)));
|
||||||
return `<section class="panel">
|
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>
|
<div class="pt-heat"><table>
|
||||||
<thead><tr><th></th>${o.heat[0].map((_, h) => `<th>${h % 3 === 0 ? ptPad(h) : ""}</th>`).join("")}</tr></thead>
|
<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>
|
<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 v = $("#view");
|
||||||
const staff = !!me?.playtimeStaff;
|
const staff = !!me?.playtimeStaff;
|
||||||
v.innerHTML = `
|
v.innerHTML = `
|
||||||
<div class="page-head"><div><h1>Playtime</h1><p class="sub">${staff
|
<div class="page-head"><div><h1>Playtime</h1></div></div>
|
||||||
? "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>
|
|
||||||
${staff ? `<div id="pt-staff">${loading()}</div><h2 class="crew-sep">${me.playtime === "on" ? "On the public site" : ""}</h2>` : ""}
|
${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>`;
|
<div id="pt-public" class="stack pt-stack">${me?.playtime === "on" ? loading() : ""}</div>`;
|
||||||
if (me?.playtime !== "on") {
|
if (me?.playtime !== "on") {
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,6 @@ async function loadResults() {
|
||||||
const list = await api(path + qs({ status, q: state.query, page: state.num }));
|
const list = await api(path + qs({ status, q: state.query, page: state.num }));
|
||||||
if (loadResults.want !== want) return;
|
if (loadResults.want !== want) return;
|
||||||
box.innerHTML = publicTable(list.items, bans ? "ban" : "comm") + pager(list);
|
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) {
|
} catch (err) {
|
||||||
box.innerHTML = errorBox(err);
|
box.innerHTML = errorBox(err);
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +123,7 @@ async function loadResults() {
|
||||||
function bans() {
|
function bans() {
|
||||||
$("#view").innerHTML = `
|
$("#view").innerHTML = `
|
||||||
<div class="page-head">
|
<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>
|
</div>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
|
@ -140,7 +138,7 @@ function bans() {
|
||||||
function comms() {
|
function comms() {
|
||||||
$("#view").innerHTML = `
|
$("#view").innerHTML = `
|
||||||
<div class="page-head">
|
<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>
|
</div>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
|
|
@ -173,10 +171,9 @@ async function staff() {
|
||||||
</div>`;
|
</div>`;
|
||||||
const staffGroups = data.groups.filter((g) => !g.supporter);
|
const staffGroups = data.groups.filter((g) => !g.supporter);
|
||||||
const supporters = data.groups.filter((g) => g.supporter);
|
const supporters = data.groups.filter((g) => g.supporter);
|
||||||
const online = data.staffOnline;
|
|
||||||
v.innerHTML = `
|
v.innerHTML = `
|
||||||
<div class="page-head">
|
<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>
|
</div>
|
||||||
${staffGroups.length ? `<section class="panel crew-panel">${staffGroups.map(rankRow).join("")}</section>` : '<div class="empty">No staff are listed yet.</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>` : ""}
|
${supporters.length ? `<h2 class="crew-sep">Supporters</h2><section class="panel crew-panel">${supporters.map(rankRow).join("")}</section>` : ""}
|
||||||
|
|
|
||||||
|
|
@ -1199,7 +1199,11 @@ tr:nth-child(3) .pt-place { color: #d08a52; }
|
||||||
.drawer .pt-credit { border-top: 0; padding: 14px 22px; }
|
.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-lines { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-size: 14px; }
|
||||||
.pt-stack > *, .pt-grid > * { min-width: 0; }
|
.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 {
|
#pt-tip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ func toPublic(ps []store.Penalty) []publicPenalty {
|
||||||
type livePlayer struct {
|
type livePlayer struct {
|
||||||
live.Player
|
live.Player
|
||||||
Rank *rankView `json:"rank,omitempty"`
|
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.
|
// Staff-only fields, left empty on the public page.
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
CanTarget bool `json:"canTarget,omitempty"`
|
CanTarget bool `json:"canTarget,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -87,38 +87,6 @@ func (s *Server) verifyInfo(ctx context.Context) (verifyInfo, error) {
|
||||||
return verifyInfo{Tables: tables, Settings: settings, Plugin: state, Version: version}, nil
|
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
|
// ---------------------------------------------------------------- Staff API
|
||||||
|
|
||||||
type verifyStatusView struct {
|
type verifyStatusView struct {
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,9 @@ func TestVerifyDB(t *testing.T) {
|
||||||
if got := verified("/api/admin/players/"+sid, mod); got != want {
|
if got := verified("/api/admin/players/"+sid, mod); got != want {
|
||||||
t.Errorf("staff view of %s: verified %v, want %v", sid, got, want)
|
t.Errorf("staff view of %s: verified %v, want %v", sid, got, want)
|
||||||
}
|
}
|
||||||
if got := verified("/api/public/players/"+sid, ""); got != want {
|
// The public site never says whether anyone is verified.
|
||||||
t.Errorf("public view of %s: verified %v, want %v", sid, got, want)
|
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")
|
expect(200, code, out, "remove override")
|
||||||
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
|
code, out = call("DELETE", "/api/admin/verify/overrides/"+charlie, mod, nil)
|
||||||
expect(404, code, out, "remove it again")
|
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")
|
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})
|
code, out = call("POST", "/api/admin/verify/settings", owner, map[string]any{"enabled": false})
|
||||||
expect(200, code, out, "turn off")
|
expect(200, code, out, "turn off")
|
||||||
if !strings.Contains(out["message"].(string), "off") {
|
if !strings.Contains(out["message"].(string), "off") {
|
||||||
t.Errorf("turn-off message %q", out["message"])
|
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")
|
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") {
|
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)
|
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")
|
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 {
|
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"])
|
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue