diff --git a/README.md b/README.md
index 48bf26a..ebd4af1 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/internal/playtime/playtime.go b/internal/playtime/playtime.go
index ca6c6c5..6fd74cc 100644
--- a/internal/playtime/playtime.go
+++ b/internal/playtime/playtime.go
@@ -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])
}
diff --git a/internal/playtime/playtime_db_test.go b/internal/playtime/playtime_db_test.go
index d93bb94..129d4b2 100644
--- a/internal/playtime/playtime_db_test.go
+++ b/internal/playtime/playtime_db_test.go
@@ -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
diff --git a/internal/web/admin.go b/internal/web/admin.go
index e241360..0609583 100644
--- a/internal/web/admin.go
+++ b/internal/web/admin.go
@@ -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 {
diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js
index 684d044..4ea1d68 100644
--- a/internal/web/assets/admin.js
+++ b/internal/web/assets/admin.js
@@ -85,7 +85,7 @@ function onlineTable(players) {
Player K / D Actions