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

@ -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()
@ -230,9 +260,11 @@ type MapTime struct {
// 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.
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
Summary Summary `json:"summary"`
// 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