Playtime records from pt_events; sticky top bar, page scroll locked under the drawer
Records: the most bomb plants, defuses, knife, Zeus and headshot kills for the last 7 days, 30 days or all time, and each player's counts, when PlaytimeHistory 0.2.0's pt_events is there. The public site's top bar now sticks the whole way down, Playtime comes after Server, and only the player drawer scrolls while it's open.
This commit is contained in:
parent
6d0fc6e463
commit
7004945d49
9 changed files with 296 additions and 27 deletions
|
|
@ -165,9 +165,12 @@ SAW_PT_DSN=pt_web:<password>@tcp(<db-host>:3306)/playtime_history
|
||||||
and runs normally either way.
|
and runs normally either way.
|
||||||
- **Public site**: a Playtime tab (once something's recorded) with the top players for the last 7
|
- **Public site**: a Playtime tab (once something's recorded) with the top players for the last 7
|
||||||
days, 30 days or all time (time on T or CT; spectating doesn't count), the last 7 days against
|
days, 30 days or all time (time on T or CT; spectating doesn't count), the last 7 days against
|
||||||
the 7 before, the busiest hours, players per day, a day-by-hour heatmap and maps. Player pages
|
the 7 before, the busiest hours, players per day, a day-by-hour heatmap and maps. With
|
||||||
get time played with the T/CT/spectating split, the last 30 days, K/D, headshots, damage per
|
PlaytimeHistory 0.2.0+ (its `pt_events` table), also Records: the most bomb plants, defuses,
|
||||||
round, MVPs and favourite maps.
|
knife kills, Zeus kills and headshot kills for the same periods, counted as they happen, never in
|
||||||
|
warmup, and not counting kills of bots. Player pages get time played with the T/CT/spectating
|
||||||
|
split, the last 30 days, K/D, headshots, damage per round, MVPs, their record counts and
|
||||||
|
favourite maps.
|
||||||
- **Staff** with `@css/generic` (PlaytimeHistory's own default for looking up others) get, on the
|
- **Staff** with `@css/generic` (PlaytimeHistory's own default for looking up others) get, on the
|
||||||
staff panel's Playtime page and on the public site while signed in: who's on now with how long,
|
staff panel's Playtime page and on the public site while signed in: who's on now with how long,
|
||||||
team, ping and location; each staff member's time in the last 7 or 30 days; disconnect reasons;
|
team, ping and location; each staff member's time in the last 7 or 30 days; disconnect reasons;
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
|
|
@ -22,6 +24,20 @@ const (
|
||||||
TeamCT = 3
|
TeamCT = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Event kinds (pt_events.kind, PlaytimeHistory 0.2.0+).
|
||||||
|
const (
|
||||||
|
EventKill = 1
|
||||||
|
EventPlanted = 2
|
||||||
|
EventDefused = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQL for the kills that count toward records: never kills of bots.
|
||||||
|
const (
|
||||||
|
knifeKill = "kind = 1 AND (weapon LIKE 'knife%' OR weapon = 'bayonet') AND NOT victim_bot"
|
||||||
|
zeusKill = "kind = 1 AND weapon = 'taser' AND NOT victim_bot"
|
||||||
|
headshot = "kind = 1 AND headshot AND NOT victim_bot"
|
||||||
|
)
|
||||||
|
|
||||||
// End kinds of a session (pt_sessions.end_kind).
|
// End kinds of a session (pt_sessions.end_kind).
|
||||||
const (
|
const (
|
||||||
EndOpen = 0
|
EndOpen = 0
|
||||||
|
|
@ -36,7 +52,8 @@ var tables = []string{"pt_players", "pt_sessions", "pt_segments", "pt_nicknames"
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
server int // pt_servers.id to read, or 0 for every server
|
server int // pt_servers.id to read, or 0 for every server
|
||||||
|
events atomic.Bool // pt_events is there (PlaytimeHistory 0.2.0+), as of the last State
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open connects lazily: a database that's down only turns playtime off, never stops the panel.
|
// Open connects lazily: a database that's down only turns playtime off, never stops the panel.
|
||||||
|
|
@ -61,6 +78,9 @@ func New(db *sql.DB, server int) *Store { return &Store{db: db, server: server}
|
||||||
|
|
||||||
func (s *Store) Close() error { return s.db.Close() }
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
// HasEvents is whether pt_events was there at the last State.
|
||||||
|
func (s *Store) HasEvents() bool { return s.events.Load() }
|
||||||
|
|
||||||
// and adds the server filter to a WHERE clause on a table with server_id.
|
// and adds the server filter to a WHERE clause on a table with server_id.
|
||||||
func (s *Store) and(col string, args []any) (string, []any) {
|
func (s *Store) and(col string, args []any) (string, []any) {
|
||||||
if s.server == 0 {
|
if s.server == 0 {
|
||||||
|
|
@ -70,14 +90,18 @@ func (s *Store) and(col string, args []any) (string, []any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// State reports whether PlaytimeHistory's tables are there, and whether it has recorded anything.
|
// State reports whether PlaytimeHistory's tables are there, and whether it has recorded anything.
|
||||||
|
// It also notes whether pt_events is there, which older versions of the plugin don't have.
|
||||||
func (s *Store) State(ctx context.Context) (hasTables, hasData bool, err error) {
|
func (s *Store) State(ctx context.Context) (hasTables, hasData bool, err error) {
|
||||||
var n int
|
var n, events int
|
||||||
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
err = s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT IF(table_name = 'pt_events', NULL, table_name)),
|
||||||
WHERE table_schema = DATABASE() AND table_name IN (?, ?, ?, ?, ?)`,
|
COUNT(DISTINCT IF(table_name = 'pt_events', table_name, NULL))
|
||||||
tables[0], tables[1], tables[2], tables[3], tables[4]).Scan(&n)
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name IN (?, ?, ?, ?, ?, 'pt_events')`,
|
||||||
|
tables[0], tables[1], tables[2], tables[3], tables[4]).Scan(&n, &events)
|
||||||
if err != nil || n < len(tables) {
|
if err != nil || n < len(tables) {
|
||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
|
s.events.Store(events == 1)
|
||||||
where, args := s.and("server_id", nil)
|
where, args := s.and("server_id", nil)
|
||||||
err = s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM pt_sessions WHERE 1=1`+where+`)`, args...).Scan(&hasData)
|
err = s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM pt_sessions WHERE 1=1`+where+`)`, args...).Scan(&hasData)
|
||||||
return true, hasData, err
|
return true, hasData, err
|
||||||
|
|
@ -404,6 +428,86 @@ func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Lea
|
||||||
return out, krows.Err()
|
return out, krows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record is one player's count in one of the Records categories.
|
||||||
|
type Record struct {
|
||||||
|
SteamID string `json:"steamid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records is the top players for each category since a time or (nil) ever, and when events begin.
|
||||||
|
type Records struct {
|
||||||
|
Since *time.Time `json:"since,omitempty"` // the first event recorded
|
||||||
|
Plants []Record `json:"plants"`
|
||||||
|
Defuses []Record `json:"defuses"`
|
||||||
|
KnifeKills []Record `json:"knifeKills"`
|
||||||
|
ZeusKills []Record `json:"zeusKills"`
|
||||||
|
Headshots []Record `json:"headshots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Records(ctx context.Context, since *time.Time, limit int) (Records, error) {
|
||||||
|
r := Records{Plants: []Record{}, Defuses: []Record{}, KnifeKills: []Record{}, ZeusKills: []Record{}, Headshots: []Record{}}
|
||||||
|
where, args := s.and("server_id", nil)
|
||||||
|
var first sql.NullTime
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT MIN(at) FROM pt_events WHERE 1=1`+where, args...).Scan(&first); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
if first.Valid {
|
||||||
|
r.Since = &first.Time
|
||||||
|
}
|
||||||
|
if since != nil {
|
||||||
|
where, args = s.and("e.server_id", []any{*since})
|
||||||
|
where = " AND e.at >= ?" + where
|
||||||
|
} else {
|
||||||
|
where, args = s.and("e.server_id", nil)
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT e.steamid64, COALESCE(MAX(p.last_nickname), ''),
|
||||||
|
SUM(e.kind = 2), SUM(e.kind = 3), SUM(`+knifeKill+`), SUM(`+zeusKill+`), SUM(`+headshot+`)
|
||||||
|
FROM pt_events e LEFT JOIN pt_players p ON p.steamid64 = e.steamid64
|
||||||
|
WHERE 1=1`+where+` GROUP BY e.steamid64`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type counts struct {
|
||||||
|
rec Record
|
||||||
|
n [5]int
|
||||||
|
}
|
||||||
|
var all []counts
|
||||||
|
for rows.Next() {
|
||||||
|
var id uint64
|
||||||
|
var c counts
|
||||||
|
var n [5]sql.NullInt64
|
||||||
|
if err := rows.Scan(&id, &c.rec.Name, &n[0], &n[1], &n[2], &n[3], &n[4]); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
c.rec.SteamID = strconv.FormatUint(id, 10)
|
||||||
|
for i := range n {
|
||||||
|
c.n[i] = int(n[i].Int64)
|
||||||
|
}
|
||||||
|
all = append(all, c)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
top := func(i int) []Record {
|
||||||
|
sorted := slices.Clone(all)
|
||||||
|
slices.SortStableFunc(sorted, func(a, b counts) int { return b.n[i] - a.n[i] })
|
||||||
|
out := []Record{}
|
||||||
|
for _, c := range sorted {
|
||||||
|
if c.n[i] == 0 || len(out) == limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rec := c.rec
|
||||||
|
rec.Count = c.n[i]
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
r.Plants, r.Defuses, r.KnifeKills, r.ZeusKills, r.Headshots = top(0), top(1), top(2), top(3), top(4)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
func inList(ids []uint64) (string, []any) {
|
func inList(ids []uint64) (string, []any) {
|
||||||
args := make([]any, len(ids))
|
args := make([]any, len(ids))
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
|
|
@ -423,6 +527,15 @@ func ParseID(steamid string) (uint64, error) {
|
||||||
|
|
||||||
// ---------------------------------------------------------------- One player
|
// ---------------------------------------------------------------- One player
|
||||||
|
|
||||||
|
// Events is one player's counts from pt_events, which only go back to PlaytimeHistory 0.2.0.
|
||||||
|
type Events struct {
|
||||||
|
Plants int `json:"plants"`
|
||||||
|
Defuses int `json:"defuses"`
|
||||||
|
KnifeKills int `json:"knifeKills"`
|
||||||
|
ZeusKills int `json:"zeusKills"`
|
||||||
|
Headshots int `json:"headshots"`
|
||||||
|
}
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Kills int `json:"kills"`
|
Kills int `json:"kills"`
|
||||||
Deaths int `json:"deaths"`
|
Deaths int `json:"deaths"`
|
||||||
|
|
@ -444,7 +557,8 @@ type Player struct {
|
||||||
Alive float64 `json:"alive"`
|
Alive float64 `json:"alive"`
|
||||||
Dead float64 `json:"dead"`
|
Dead float64 `json:"dead"`
|
||||||
Stats Stats `json:"stats"`
|
Stats Stats `json:"stats"`
|
||||||
Days []Day `json:"days"` // last 30 days
|
Events *Events `json:"events,omitempty"` // nil without pt_events
|
||||||
|
Days []Day `json:"days"` // last 30 days
|
||||||
Maps []MapTime `json:"maps"`
|
Maps []MapTime `json:"maps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,6 +584,17 @@ func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.events.Load() {
|
||||||
|
var e Events
|
||||||
|
var n [5]sql.NullInt64
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT SUM(kind = 2), SUM(kind = 3), SUM(`+knifeKill+`), SUM(`+zeusKill+`), SUM(`+headshot+`)
|
||||||
|
FROM pt_events WHERE steamid64 = ?`, steamid).Scan(&n[0], &n[1], &n[2], &n[3], &n[4]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Plants, e.Defuses, e.KnifeKills, e.ZeusKills, e.Headshots = int(n[0].Int64), int(n[1].Int64), int(n[2].Int64), int(n[3].Int64), int(n[4].Int64)
|
||||||
|
p.Events = &e
|
||||||
|
}
|
||||||
|
|
||||||
days, start := lastDays(now, loc, 30)
|
days, start := lastDays(now, loc, 30)
|
||||||
buckets, err := s.hours(ctx, start, steamid)
|
buckets, err := s.hours(ctx, start, steamid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,23 @@ func TestPlaytimeDB(t *testing.T) {
|
||||||
VALUES (1, ?, 5, 0, 10, 'de_mirage'), (1, ?, 9, 0, 10, 'de_mirage'), (2, ?, 20, 0, 10, 'de_dust2')`,
|
VALUES (1, ?, 5, 0, 10, 'de_mirage'), (1, ?, 9, 0, 10, 'de_mirage'), (2, ?, 20, 0, 10, 'de_dust2')`,
|
||||||
ago(24*time.Hour), ago(48*time.Hour), ago(24*time.Hour))
|
ago(24*time.Hour), ago(48*time.Hour), ago(24*time.Hour))
|
||||||
|
|
||||||
if ok, data, err := st.State(ctx); err != nil || !ok || !data {
|
// Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot;
|
||||||
t.Fatalf("State with data: %v, %v, %v", ok, data, err)
|
// Bravo gets a Zeus kill and a headshot.
|
||||||
|
event := func(id uint64, kind int, at time.Time, weapon any, hs any, bot any) {
|
||||||
|
t.Helper()
|
||||||
|
exec(`INSERT INTO pt_events (event_key, server_id, steamid64, kind, at, map, team, weapon, headshot, victim_bot)
|
||||||
|
VALUES (REPLACE(UUID(), '-', ''), 1, ?, ?, ?, 'de_mirage', 2, ?, ?, ?)`, id, kind, at, weapon, hs, bot)
|
||||||
|
}
|
||||||
|
event(a, EventPlanted, ago(2*time.Hour), nil, nil, nil)
|
||||||
|
event(a, EventPlanted, ago(10*24*time.Hour), nil, nil, nil)
|
||||||
|
event(a, EventDefused, ago(2*time.Hour), nil, nil, nil)
|
||||||
|
event(a, EventKill, ago(2*time.Hour), "knife_karambit", 0, 0)
|
||||||
|
event(a, EventKill, ago(2*time.Hour), "knife", 1, 1) // a bot: doesn't count, not even as a headshot
|
||||||
|
event(b, EventKill, ago(20*time.Minute), "taser", 0, 0)
|
||||||
|
event(b, EventKill, ago(20*time.Minute), "ak47", 1, 0)
|
||||||
|
|
||||||
|
if ok, data, err := st.State(ctx); err != nil || !ok || !data || !st.HasEvents() {
|
||||||
|
t.Fatalf("State with data: %v, %v, %v (events %v)", ok, data, err, st.HasEvents())
|
||||||
}
|
}
|
||||||
|
|
||||||
// The hour split must add up to exactly the segments' lengths.
|
// The hour split must add up to exactly the segments' lengths.
|
||||||
|
|
@ -228,6 +243,32 @@ func TestPlaytimeDB(t *testing.T) {
|
||||||
t.Fatalf("Bravo's activity %+v", x)
|
t.Fatalf("Bravo's activity %+v", x)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rec, err := st.Records(ctx, &week, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
one := func(name string, got []Record, who string, n int) {
|
||||||
|
t.Helper()
|
||||||
|
if len(got) != 1 || got[0].Name != who || got[0].Count != n {
|
||||||
|
t.Fatalf("%s: %+v, want %s with %d", name, got, who, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
one("plants", rec.Plants, "Alpha", 1)
|
||||||
|
one("defuses", rec.Defuses, "Alpha", 1)
|
||||||
|
one("knife kills", rec.KnifeKills, "Alpha", 1)
|
||||||
|
one("zeus kills", rec.ZeusKills, "Bravo", 1)
|
||||||
|
one("headshots", rec.Headshots, "Bravo", 1)
|
||||||
|
if rec.Since == nil || !rec.Since.Equal(ago(10*24*time.Hour)) {
|
||||||
|
t.Fatalf("records since %v", rec.Since)
|
||||||
|
}
|
||||||
|
if rec, err = st.Records(ctx, nil, 5); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
one("plants ever", rec.Plants, "Alpha", 2)
|
||||||
|
if e := p.Events; e == nil || e.Plants != 2 || e.Defuses != 1 || e.KnifeKills != 1 || e.ZeusKills != 0 || e.Headshots != 0 {
|
||||||
|
t.Fatalf("Alpha's events %+v", e)
|
||||||
|
}
|
||||||
|
|
||||||
reasons, err := st.Reasons(ctx, ago(30*24*time.Hour))
|
reasons, err := st.Reasons(ctx, ago(30*24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
35
internal/playtime/testdata/schema.sql
vendored
35
internal/playtime/testdata/schema.sql
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
-- A copy of PlaytimeHistory's Migrations/001_CreateTables.sql (plugins/PlaytimeHistory, 0.1.0), for
|
-- A copy of PlaytimeHistory's Migrations/*.sql (plugins/PlaytimeHistory, 0.2.0), in order, for
|
||||||
-- playtime_db_test.go. Keep it in step with the plugin's migrations.
|
-- playtime_db_test.go. Keep it in step with the plugin's migrations.
|
||||||
|
|
||||||
-- All times are UTC. Tables are prefixed pt_ so they can share a database if ever needed.
|
-- All times are UTC. Tables are prefixed pt_ so they can share a database if ever needed.
|
||||||
|
|
@ -139,3 +139,36 @@ WITH RECURSIVE h AS (
|
||||||
SELECT steamid64, server_id, map, team, alive, hour_start,
|
SELECT steamid64, server_id, map, team, alive, hour_start,
|
||||||
TIMESTAMPDIFF(SECOND, piece_start, LEAST(ended_at, hour_start + INTERVAL 1 HOUR)) AS seconds
|
TIMESTAMPDIFF(SECOND, piece_start, LEAST(ended_at, hour_start + INTERVAL 1 HOUR)) AS seconds
|
||||||
FROM h;
|
FROM h;
|
||||||
|
|
||||||
|
-- Migrations/002_Events.sql (0.2.0)
|
||||||
|
|
||||||
|
-- One row per kill, bomb plant and bomb defuse, written as it happens (0.2.0+). Nothing during
|
||||||
|
-- warmup. Kills leave out suicides and team kills.
|
||||||
|
-- kind: 1 kill, 2 bomb planted, 3 bomb defused. steamid64 is who did it: the killer, planter or
|
||||||
|
-- defuser. The kill-only columns are NULL for plants and defuses, and site is NULL for kills.
|
||||||
|
-- victim_steamid64 is NULL when the victim was a bot (victim_bot 1).
|
||||||
|
CREATE TABLE IF NOT EXISTS `pt_events` (
|
||||||
|
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`event_key` char(32) NOT NULL,
|
||||||
|
`server_id` int NOT NULL,
|
||||||
|
`session_key` char(32) NULL,
|
||||||
|
`steamid64` bigint unsigned NOT NULL,
|
||||||
|
`kind` tinyint NOT NULL,
|
||||||
|
`at` datetime NOT NULL,
|
||||||
|
`map` varchar(64) NOT NULL,
|
||||||
|
`team` tinyint NOT NULL,
|
||||||
|
`weapon` varchar(64) NULL,
|
||||||
|
`headshot` tinyint NULL,
|
||||||
|
`noscope` tinyint NULL,
|
||||||
|
`thrusmoke` tinyint NULL,
|
||||||
|
`wallbang` tinyint NULL,
|
||||||
|
`blind` tinyint NULL,
|
||||||
|
`victim_steamid64` bigint unsigned NULL,
|
||||||
|
`victim_bot` tinyint NULL,
|
||||||
|
`site` int NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uq_event_key` (`event_key`),
|
||||||
|
KEY `ix_kind_at` (`kind`, `at`),
|
||||||
|
KEY `ix_steamid_at` (`steamid64`, `at`),
|
||||||
|
KEY `ix_at` (`at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,12 @@ function errorBox(err) { return `<div class="empty">${esc(err.message)}</div>`;
|
||||||
|
|
||||||
function closeDrawer() { $("#drawer-root").innerHTML = ""; }
|
function closeDrawer() { $("#drawer-root").innerHTML = ""; }
|
||||||
|
|
||||||
|
// Lock the page's own scrolling while the drawer is open, however it's opened or closed.
|
||||||
|
{
|
||||||
|
const root = $("#drawer-root");
|
||||||
|
if (root) new MutationObserver(() => document.documentElement.classList.toggle("drawer-open", root.innerHTML !== "")).observe(root, { childList: true });
|
||||||
|
}
|
||||||
|
|
||||||
function toast(msg) {
|
function toast(msg) {
|
||||||
const root = $("#toast-root");
|
const root = $("#toast-root");
|
||||||
root.innerHTML = `<div class="toast">${esc(msg)}</div>`;
|
root.innerHTML = `<div class="toast">${esc(msg)}</div>`;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ const PT_REASONS = {
|
||||||
NETWORK_DISCONNECT_SHUTDOWN: "Server shut down",
|
NETWORK_DISCONNECT_SHUTDOWN: "Server shut down",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ptState = { period: "7d", staffPeriod: "7d" };
|
const ptState = { period: "7d", staffPeriod: "7d", records: "7d" };
|
||||||
|
|
||||||
|
// The Records categories, from PlaytimeHistory's pt_events (0.2.0+).
|
||||||
|
const PT_RECORDS = [
|
||||||
|
["plants", "Bomb plants"], ["defuses", "Bomb defuses"], ["knifeKills", "Knife kills"], ["zeusKills", "Zeus kills"], ["headshots", "Headshot kills"],
|
||||||
|
];
|
||||||
|
|
||||||
const ptH = (h) => (h >= 100 ? Math.round(h).toLocaleString() : (Math.round(h * 10) / 10).toFixed(1)) + " h";
|
const ptH = (h) => (h >= 100 ? Math.round(h).toLocaleString() : (Math.round(h * 10) / 10).toFixed(1)) + " h";
|
||||||
const ptPct = (a, b) => Math.round((a / Math.max(b, 1)) * 100);
|
const ptPct = (a, b) => Math.round((a / Math.max(b, 1)) * 100);
|
||||||
|
|
@ -146,9 +151,24 @@ function ptMapsPanel(o) {
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptRecords(d) {
|
||||||
|
const r = d.records;
|
||||||
|
const since = r.since ? `Counted since ${ptDay(r.since.slice(0, 10), { day: "numeric", month: "short", year: "numeric" })}, not in warmup; kills of bots don't count.` : "Nothing counted yet.";
|
||||||
|
return `<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div><h2>Records</h2><p>${since}</p></div>
|
||||||
|
${segmented("ptRecords", d.recordsPeriod, PT_PERIODS)}
|
||||||
|
</div>
|
||||||
|
<div class="pt-records">${PT_RECORDS.map(([key, label]) => `<div class="pt-record">
|
||||||
|
<h3>${label}</h3>
|
||||||
|
${r[key].length ? `<ol>${r[key].map((p) => `<li class="clickable" data-player="${esc(p.steamid)}"><span>${esc(p.name || p.steamid)}</span><b>${p.count.toLocaleString()}</b></li>`).join("")}</ol>` : '<p class="faint">None yet.</p>'}
|
||||||
|
</div>`).join("")}</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
function ptPublicPanels(d) {
|
function ptPublicPanels(d) {
|
||||||
const o = d.overview;
|
const o = d.overview;
|
||||||
return `${ptTiles(o.summary)}<div id="pt-leaders">${ptLeaders(d)}</div>
|
return `${ptTiles(o.summary)}<div id="pt-leaders">${ptLeaders(d)}</div>${d.records ? `<div id="pt-records">${ptRecords(d)}</div>` : ""}
|
||||||
<div class="pt-grid">${ptBusiest(o)}${ptDaily(o)}</div>${ptHeatmap(o)}${ptMapsPanel(o)}`;
|
<div class="pt-grid">${ptBusiest(o)}${ptDaily(o)}</div>${ptHeatmap(o)}${ptMapsPanel(o)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,7 +274,7 @@ async function ptPage(me, isCurrent) {
|
||||||
}).catch((err) => { if (isCurrent()) $("#pt-staff").innerHTML = errorBox(err); }));
|
}).catch((err) => { if (isCurrent()) $("#pt-staff").innerHTML = errorBox(err); }));
|
||||||
}
|
}
|
||||||
if (me?.playtime === "on") {
|
if (me?.playtime === "on") {
|
||||||
jobs.push(api(`/api/public/playtime${qs({ period: ptState.period, tz: PT_TZ })}`).then((d) => {
|
jobs.push(api(`/api/public/playtime${qs({ period: ptState.period, records: ptState.records, tz: PT_TZ })}`).then((d) => {
|
||||||
if (isCurrent()) $("#pt-public").innerHTML = ptPublicPanels(d);
|
if (isCurrent()) $("#pt-public").innerHTML = ptPublicPanels(d);
|
||||||
}).catch((err) => { if (isCurrent()) $("#pt-public").innerHTML = errorBox(err); }));
|
}).catch((err) => { if (isCurrent()) $("#pt-public").innerHTML = errorBox(err); }));
|
||||||
}
|
}
|
||||||
|
|
@ -263,12 +283,12 @@ async function ptPage(me, isCurrent) {
|
||||||
|
|
||||||
// Switching a period redraws only that panel.
|
// Switching a period redraws only that panel.
|
||||||
async function ptSwitch(key, value) {
|
async function ptSwitch(key, value) {
|
||||||
if (key === "ptPeriod") {
|
if (key === "ptPeriod" || key === "ptRecords") {
|
||||||
ptState.period = value;
|
ptState[key === "ptPeriod" ? "period" : "records"] = value;
|
||||||
try {
|
try {
|
||||||
const d = await api(`/api/public/playtime${qs({ period: value, tz: PT_TZ })}`);
|
const d = await api(`/api/public/playtime${qs({ period: ptState.period, records: ptState.records, tz: PT_TZ })}`);
|
||||||
const box = $("#pt-leaders");
|
const box = key === "ptPeriod" ? $("#pt-leaders") : $("#pt-records");
|
||||||
if (box) box.innerHTML = ptLeaders(d);
|
if (box) box.innerHTML = key === "ptPeriod" ? ptLeaders(d) : ptRecords(d);
|
||||||
} catch (err) { toast(err.message); }
|
} catch (err) { toast(err.message); }
|
||||||
} else if (key === "ptStaffPeriod") {
|
} else if (key === "ptStaffPeriod") {
|
||||||
ptState.staffPeriod = value;
|
ptState.staffPeriod = value;
|
||||||
|
|
@ -319,6 +339,16 @@ function ptDrawer(pt, online) {
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="margin:10px 0 0;font-size:14px">${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.</p>
|
<p class="muted" style="margin:10px 0 0;font-size:14px">${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.</p>
|
||||||
</section>` : ""}
|
</section>` : ""}
|
||||||
|
${p.events && Object.values(p.events).some((n) => n > 0) ? `<section>
|
||||||
|
<h3>Records</h3>
|
||||||
|
<div class="pt-stats">
|
||||||
|
<div><b>${p.events.plants.toLocaleString()}</b><small>Bomb plants</small></div>
|
||||||
|
<div><b>${p.events.defuses.toLocaleString()}</b><small>Bomb defuses</small></div>
|
||||||
|
<div><b>${p.events.knifeKills.toLocaleString()}</b><small>Knife kills</small></div>
|
||||||
|
<div><b>${p.events.zeusKills.toLocaleString()}</b><small>Zeus kills</small></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin:10px 0 0;font-size:14px">${p.events.headshots.toLocaleString()} headshot kills. Counted as they happen, not in warmup or on bots.</p>
|
||||||
|
</section>` : ""}
|
||||||
${p.maps.length ? `<section>
|
${p.maps.length ? `<section>
|
||||||
<h3>Favourite maps</h3>
|
<h3>Favourite maps</h3>
|
||||||
<div class="pt-maps">${p.maps.map((m) => `<div><span>${esc(m.map)}</span>${ptBar(m.hours, maxMap, ptH(m.hours), "var(--t)")}</div>`).join("")}</div>
|
<div class="pt-maps">${p.maps.map((m) => `<div><span>${esc(m.map)}</span>${ptBar(m.hours, maxMap, ptH(m.hours), "var(--t)")}</div>`).join("")}</div>
|
||||||
|
|
@ -358,7 +388,7 @@ function ptDrawer(pt, online) {
|
||||||
/* ---------- Events ---------- */
|
/* ---------- Events ---------- */
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
const seg = e.target.closest('[data-seg="ptPeriod"] button, [data-seg="ptStaffPeriod"] button');
|
const seg = e.target.closest('[data-seg="ptPeriod"] button, [data-seg="ptStaffPeriod"] button, [data-seg="ptRecords"] button');
|
||||||
if (!seg) return;
|
if (!seg) return;
|
||||||
seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
|
seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
|
||||||
ptSwitch(seg.parentElement.dataset.seg, seg.dataset.value);
|
ptSwitch(seg.parentElement.dataset.seg, seg.dataset.value);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
|
|
||||||
const PAGES = [
|
const PAGES = [
|
||||||
{ id: "server", label: "Server" },
|
{ id: "server", label: "Server" },
|
||||||
{ id: "bans", label: "Bans" },
|
|
||||||
{ id: "comms", label: "Gags & mutes" },
|
|
||||||
{ id: "staff", label: "Staff" },
|
|
||||||
// Once PlaytimeHistory has recorded something, or for staff who see its staff figures.
|
// Once PlaytimeHistory has recorded something, or for staff who see its staff figures.
|
||||||
{ id: "playtime", label: "Playtime", show: () => state.me?.playtime === "on" || !!state.me?.playtimeStaff },
|
{ id: "playtime", label: "Playtime", show: () => state.me?.playtime === "on" || !!state.me?.playtimeStaff },
|
||||||
|
{ id: "bans", label: "Bans" },
|
||||||
|
{ id: "comms", label: "Gags & Mutes" },
|
||||||
|
{ id: "staff", label: "Staff" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// me is null until /api/me answers.
|
// me is null until /api/me answers.
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,13 @@
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
html, body { margin: 0; height: 100%; }
|
/* min-height, not height: a body exactly one screen tall would carry the sticky top bar away
|
||||||
|
once the page scrolls past it. */
|
||||||
|
html { height: 100%; }
|
||||||
|
body { margin: 0; min-height: 100%; }
|
||||||
|
/* While a player's drawer is open only the drawer scrolls (see common.js). */
|
||||||
|
html.drawer-open { overflow: hidden; }
|
||||||
|
html.drawer-open body { overflow: hidden; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
@ -710,6 +716,7 @@ td.num, th.num { text-align: right; white-space: nowrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
|
overscroll-behavior: contain;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
@ -1159,6 +1166,14 @@ tr:nth-child(2) .pt-place { color: #c9ced8; }
|
||||||
tr:nth-child(3) .pt-place { color: #d08a52; }
|
tr:nth-child(3) .pt-place { color: #d08a52; }
|
||||||
.pt-who .rank { margin-left: 6px; }
|
.pt-who .rank { margin-left: 6px; }
|
||||||
|
|
||||||
|
.pt-records { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; padding: 18px; }
|
||||||
|
.pt-record { display: grid; align-content: start; gap: 10px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); }
|
||||||
|
.pt-record ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 14px; counter-reset: rec; }
|
||||||
|
.pt-record li { display: flex; gap: 8px; align-items: baseline; cursor: pointer; counter-increment: rec; }
|
||||||
|
.pt-record li::before { content: counter(rec); width: 1.2em; color: var(--faint); font: 700 14px/1 var(--display); }
|
||||||
|
.pt-record li span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pt-record li:hover span { text-decoration: underline; }
|
||||||
|
.pt-record p { margin: 0; font-size: 14px; }
|
||||||
.pt-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
.pt-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||||
.pt-stats div { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); }
|
.pt-stats div { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); }
|
||||||
.pt-stats b { font: 600 22px/1 var(--display); }
|
.pt-stats b { font: 600 22px/1 var(--display); }
|
||||||
|
|
|
||||||
|
|
@ -122,8 +122,13 @@ func viewerZone(r *http.Request) *time.Location {
|
||||||
|
|
||||||
// periodSince reads ?period=7d|30d|all; all is nil.
|
// periodSince reads ?period=7d|30d|all; all is nil.
|
||||||
func periodSince(r *http.Request, allowAll bool) (string, *time.Time) {
|
func periodSince(r *http.Request, allowAll bool) (string, *time.Time) {
|
||||||
|
return periodParam(r.URL.Query().Get("period"), allowAll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// periodParam turns 7d, 30d or all into when it starts; all is nil. Anything else is 7d.
|
||||||
|
func periodParam(p string, allowAll bool) (string, *time.Time) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
switch p := r.URL.Query().Get("period"); {
|
switch {
|
||||||
case p == "30d":
|
case p == "30d":
|
||||||
t := now.Add(-30 * 24 * time.Hour)
|
t := now.Add(-30 * 24 * time.Hour)
|
||||||
return p, &t
|
return p, &t
|
||||||
|
|
@ -167,7 +172,18 @@ func (s *Server) publicPlaytime(w http.ResponseWriter, r *http.Request) {
|
||||||
for _, l := range leaders {
|
for _, l := range leaders {
|
||||||
lv = append(lv, leaderView{Leader: l, Rank: s.rankOf(x, l.SteamID)})
|
lv = append(lv, leaderView{Leader: l, Rank: s.rankOf(x, l.SteamID)})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"overview": o, "period": period, "leaders": lv})
|
resp := map[string]any{"overview": o, "period": period, "leaders": lv}
|
||||||
|
// Records (?records=7d|30d|all) need pt_events, from PlaytimeHistory 0.2.0.
|
||||||
|
if s.pt.HasEvents() {
|
||||||
|
rp, rsince := periodParam(r.URL.Query().Get("records"), true)
|
||||||
|
rec, err := s.pt.Records(ctx, rsince, 5)
|
||||||
|
if err != nil {
|
||||||
|
internal(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp["records"], resp["recordsPeriod"] = rec, rp
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
type onlineView struct {
|
type onlineView struct {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue