Playtime stats between players only; no ping; page-wide grid

K/D, headshot %, kills and deaths (drawer, Top players, staff session
lists) now come from pt_events, so kills of bots and deaths to bots don't
count, as in Records; damage per round and MVPs are gone, since only the
game's counters have them and those include bots. Average ping and the
record count footnote are no longer shown. The server header's grid now
runs behind the whole page.
This commit is contained in:
Astra 2026-10-04 22:41:12 +01:00
parent c7fbff0e43
commit 2516759fa9
6 changed files with 120 additions and 81 deletions

View file

@ -169,12 +169,14 @@ SAW_PT_DSN=pt_web:<password>@tcp(<db-host>:3306)/playtime_history
PlaytimeHistory 0.2.0+ (its `pt_events` table), also Records: the most bomb plants, defuses, PlaytimeHistory 0.2.0+ (its `pt_events` table), also Records: the most bomb plants, defuses,
knife kills, Zeus kills and headshot kills for the same periods, counted as they happen, never in 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 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 split, the last 30 days, K/D, headshot %, kills and deaths, their record counts and
favourite maps. favourite maps. Kills, deaths and headshots (here, on Top players and in staff session lists)
are between players only, from `pt_events`: kills of bots, deaths to bots and suicides don't
count, so they start with PlaytimeHistory 0.2.0.
- **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;
countries; and in player drawers, the names they've used, recent sessions, location and ping. countries; and in player drawers, the names they've used, recent sessions and location.
Root also sees whether the plugin is loaded and what `css_pt_status` says. Who's on comes from Root also sees whether the plugin is loaded and what `css_pt_status` says. Who's on comes from
the live status, so admins hidden with `css_hide` stay hidden. the live status, so admins hidden with `css_hide` stay hidden.
- **Days and hours of the day** are in the viewer's own time zone (the browser sends it). An hour - **Days and hours of the day** are in the viewer's own time zone (the browser sends it). An hour

View file

@ -431,31 +431,63 @@ func (s *Store) Leaders(ctx context.Context, since *time.Time, limit int) ([]Lea
return out, err return out, err
} }
in, args := inList(ids) if !s.events.Load() {
q := `SELECT steamid64, COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0) FROM pt_sessions WHERE steamid64 IN (` + in + `)` return out, nil
if since != nil {
q += " AND started_at >= ?"
args = append(args, *since)
} }
where, args := s.and("server_id", args) kd, err := s.killsDeaths(ctx, ids, since)
krows, err := s.db.QueryContext(ctx, q+where+" GROUP BY steamid64", args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer krows.Close() for i, id := range ids {
kd := map[string][2]int{} out[i].Kills, out[i].Deaths = kd[id].Kills, kd[id].Deaths
for krows.Next() { }
var id uint64 return out, nil
var k, d int }
if err := krows.Scan(&id, &k, &d); err != nil {
// killsDeaths counts kills and deaths between players from pt_events, since a time or (nil)
// ever. Kills of bots and deaths to bots, the world or themselves don't count: only a player
// killing another player is logged with a victim.
func (s *Store) killsDeaths(ctx context.Context, ids []uint64, since *time.Time) (map[uint64]Stats, error) {
in, ids1 := inList(ids)
_, ids2 := inList(ids)
when := ""
var args []any
part := func(idArgs []any) []any {
a := append([]any{}, idArgs...)
if since != nil {
a = append(a, *since)
}
if s.server != 0 {
a = append(a, s.server)
}
return a
}
if since != nil {
when = " AND at >= ?"
}
srv, _ := s.and("server_id", nil)
args = append(part(ids1), part(ids2)...)
rows, err := s.db.QueryContext(ctx, `SELECT id, SUM(k), SUM(d), SUM(h) FROM (
SELECT steamid64 AS id, 1 AS k, 0 AS d, IF(headshot, 1, 0) AS h FROM pt_events
WHERE kind = 1 AND NOT victim_bot AND steamid64 IN (`+in+`)`+when+srv+`
UNION ALL
SELECT victim_steamid64, 0, 1, 0 FROM pt_events
WHERE kind = 1 AND victim_steamid64 IN (`+in+`)`+when+srv+`
) x GROUP BY id`, args...)
if err != nil {
return nil, err return nil, err
} }
kd[strconv.FormatUint(id, 10)] = [2]int{k, d} defer rows.Close()
out := map[uint64]Stats{}
for rows.Next() {
var id uint64
var st Stats
if err := rows.Scan(&id, &st.Kills, &st.Deaths, &st.Headshots); err != nil {
return nil, err
} }
for i := range out { out[id] = st
out[i].Kills, out[i].Deaths = kd[out[i].SteamID][0], kd[out[i].SteamID][1]
} }
return out, krows.Err() return out, rows.Err()
} }
// Record is one player's count in one of the Records categories. // Record is one player's count in one of the Records categories.
@ -566,14 +598,11 @@ type Events struct {
Headshots int `json:"headshots"` Headshots int `json:"headshots"`
} }
// Stats is kills, deaths and headshot kills between players (pt_events, so 0.2.0 onward).
type Stats struct { type Stats struct {
Kills int `json:"kills"` Kills int `json:"kills"`
Deaths int `json:"deaths"` Deaths int `json:"deaths"`
Assists int `json:"assists"`
Headshots int `json:"headshots"` Headshots int `json:"headshots"`
Damage int `json:"damage"`
MVPs int `json:"mvps"`
Rounds int `json:"rounds"`
} }
// Player is what anyone can see about one player's playtime. Hours are on T or CT unless named. // Player is what anyone can see about one player's playtime. Hours are on T or CT unless named.
@ -586,7 +615,7 @@ type Player struct {
Spec float64 `json:"spec"` // spectating or unassigned Spec float64 `json:"spec"` // spectating or unassigned
Alive float64 `json:"alive"` Alive float64 `json:"alive"`
Dead float64 `json:"dead"` Dead float64 `json:"dead"`
Stats Stats `json:"stats"` Stats *Stats `json:"stats,omitempty"` // nil without pt_events
Events *Events `json:"events,omitempty"` // nil without pt_events Events *Events `json:"events,omitempty"` // nil without pt_events
Days []Day `json:"days"` // last 30 days Days []Day `json:"days"` // last 30 days
Maps []MapTime `json:"maps"` Maps []MapTime `json:"maps"`
@ -607,12 +636,14 @@ func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *
h := func(secs int64) float64 { return round1(float64(secs) / 3600) } h := func(secs int64) float64 { return round1(float64(secs) / 3600) }
p.T, p.CT, p.Spec, p.Alive, p.Dead = h(t), h(ct), h(spec), h(alive), h(dead) p.T, p.CT, p.Spec, p.Alive, p.Dead = h(t), h(ct), h(spec), h(alive), h(dead)
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(kills), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(assists), 0), if s.events.Load() {
COALESCE(SUM(headshots), 0), COALESCE(SUM(damage), 0), COALESCE(SUM(mvp), 0), COALESCE(SUM(rounds_played), 0) kd, err := s.killsDeaths(ctx, []uint64{steamid}, nil)
FROM pt_sessions WHERE steamid64 = ?`, steamid). if err != nil {
Scan(&p.Stats.Kills, &p.Stats.Deaths, &p.Stats.Assists, &p.Stats.Headshots, &p.Stats.Damage, &p.Stats.MVPs, &p.Stats.Rounds); err != nil {
return nil, err return nil, err
} }
st := kd[steamid]
p.Stats = &st
}
if s.events.Load() { if s.events.Load() {
var e Events var e Events
@ -655,22 +686,20 @@ type Session struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Kills *int `json:"kills,omitempty"` Kills *int `json:"kills,omitempty"`
Deaths *int `json:"deaths,omitempty"` Deaths *int `json:"deaths,omitempty"`
Ping *int `json:"ping,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
City string `json:"city,omitempty"` City string `json:"city,omitempty"`
} }
// PlayerStaff is what only staff see: names, sessions, location and ping. // PlayerStaff is what only staff see: names, sessions and location.
type PlayerStaff struct { type PlayerStaff struct {
Names []Nickname `json:"names"` Names []Nickname `json:"names"`
Recent []Session `json:"recent"` Recent []Session `json:"recent"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
CountryName string `json:"countryName,omitempty"` CountryName string `json:"countryName,omitempty"`
City string `json:"city,omitempty"` City string `json:"city,omitempty"`
Ping *int `json:"ping,omitempty"` // average over the last 30 days
} }
func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time) (PlayerStaff, error) { func (s *Store) PlayerStaff(ctx context.Context, steamid uint64) (PlayerStaff, error) {
out := PlayerStaff{Names: []Nickname{}, Recent: []Session{}} out := PlayerStaff{Names: []Nickname{}, Recent: []Session{}}
rows, err := s.db.QueryContext(ctx, `SELECT nickname, first_seen, last_seen, times_seen FROM pt_nicknames rows, err := s.db.QueryContext(ctx, `SELECT nickname, first_seen, last_seen, times_seen FROM pt_nicknames
WHERE steamid64 = ? ORDER BY last_seen DESC LIMIT 20`, steamid) WHERE steamid64 = ? ORDER BY last_seen DESC LIMIT 20`, steamid)
@ -690,19 +719,28 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time)
return out, err return out, err
} }
where, args := s.and("server_id", []any{steamid}) // Kills and deaths between players from pt_events, for sessions since it began; NULL before.
rows, err = s.db.QueryContext(ctx, `SELECT started_at, ended_at, COALESCE(duration_seconds, TIMESTAMPDIFF(SECOND, started_at, heartbeat_at)), kd := "NULL, NULL"
end_kind, connect_map, COALESCE(disconnect_map, ''), COALESCE(disconnect_reason_name, ''), kills, deaths, ping_avg, if s.events.Load() {
COALESCE(country_iso, ''), COALESCE(city, '') kd = `IF(s.started_at >= (SELECT MIN(at) FROM pt_events),
FROM pt_sessions WHERE steamid64 = ?`+where+` ORDER BY started_at DESC LIMIT 10`, args...) (SELECT COUNT(*) FROM pt_events e WHERE e.session_key = s.session_key AND e.kind = 1 AND NOT e.victim_bot), NULL),
IF(s.started_at >= (SELECT MIN(at) FROM pt_events),
(SELECT COUNT(*) FROM pt_events e WHERE e.kind = 1 AND e.victim_steamid64 = s.steamid64
AND e.at >= s.started_at AND e.at <= COALESCE(s.ended_at, s.heartbeat_at, UTC_TIMESTAMP())), NULL)`
}
where, args := s.and("s.server_id", []any{steamid})
rows, err = s.db.QueryContext(ctx, `SELECT s.started_at, s.ended_at, COALESCE(s.duration_seconds, TIMESTAMPDIFF(SECOND, s.started_at, s.heartbeat_at)),
s.end_kind, s.connect_map, COALESCE(s.disconnect_map, ''), COALESCE(s.disconnect_reason_name, ''), `+kd+`,
COALESCE(s.country_iso, ''), COALESCE(s.city, '')
FROM pt_sessions s WHERE s.steamid64 = ?`+where+` ORDER BY s.started_at DESC LIMIT 10`, args...)
if err != nil { if err != nil {
return out, err return out, err
} }
for rows.Next() { for rows.Next() {
var x Session var x Session
var ended sql.NullTime var ended sql.NullTime
var kills, deaths, ping sql.NullInt64 var kills, deaths sql.NullInt64
if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths, &ping, if err := rows.Scan(&x.Started, &ended, &x.Seconds, &x.EndKind, &x.Map, &x.EndMap, &x.Reason, &kills, &deaths,
&x.Country, &x.City); err != nil { &x.Country, &x.City); err != nil {
rows.Close() rows.Close()
return out, err return out, err
@ -710,7 +748,7 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time)
if ended.Valid { if ended.Valid {
x.Ended = &ended.Time x.Ended = &ended.Time
} }
x.Kills, x.Deaths, x.Ping = intPtr(kills), intPtr(deaths), intPtr(ping) x.Kills, x.Deaths = intPtr(kills), intPtr(deaths)
out.Recent = append(out.Recent, x) out.Recent = append(out.Recent, x)
} }
rows.Close() rows.Close()
@ -724,15 +762,6 @@ func (s *Store) PlayerStaff(ctx context.Context, steamid uint64, now time.Time)
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
return out, err return out, err
} }
var ping sql.NullFloat64
if err := s.db.QueryRowContext(ctx, `SELECT AVG(ping_avg) FROM pt_sessions WHERE steamid64 = ? AND started_at >= ?`,
steamid, now.Add(-30*24*time.Hour)).Scan(&ping); err != nil {
return out, err
}
if ping.Valid {
n := int(ping.Float64 + 0.5)
out.Ping = &n
}
return out, nil return out, nil
} }

View file

@ -116,18 +116,20 @@ func TestPlaytimeDB(t *testing.T) {
// Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot; // Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot;
// Bravo gets a Zeus kill and a headshot. // Bravo gets a Zeus kill and a headshot.
event := func(id uint64, kind int, at time.Time, weapon any, hs any, bot any) { // Kills say who died (nil for a bot) and the killer's session.
event := func(id uint64, sess string, kind int, at time.Time, weapon any, hs any, victim any, bot any) {
t.Helper() t.Helper()
exec(`INSERT INTO pt_events (event_key, server_id, steamid64, kind, at, map, team, weapon, headshot, victim_bot) exec(`INSERT INTO pt_events (event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot,
VALUES (REPLACE(UUID(), '-', ''), 1, ?, ?, ?, 'de_mirage', 2, ?, ?, ?)`, id, kind, at, weapon, hs, bot) victim_steamid64, victim_bot)
VALUES (REPLACE(UUID(), '-', ''), 1, NULLIF(?, ''), ?, ?, ?, 'de_mirage', 2, ?, ?, ?, ?)`, sess, id, kind, at, weapon, hs, victim, bot)
} }
event(a, EventPlanted, ago(2*time.Hour), nil, nil, nil) event(a, "", EventPlanted, ago(10*24*time.Hour), nil, nil, nil, nil)
event(a, EventPlanted, ago(10*24*time.Hour), nil, nil, nil) event(a, "a1", EventPlanted, ago(2*time.Hour), nil, nil, nil, nil)
event(a, EventDefused, ago(2*time.Hour), nil, nil, nil) event(a, "a1", EventDefused, ago(2*time.Hour), nil, nil, nil, nil)
event(a, EventKill, ago(2*time.Hour), "knife_karambit", 0, 0) event(a, "a1", EventKill, ago(2*time.Hour), "knife_karambit", 0, b, 0)
event(a, EventKill, ago(2*time.Hour), "knife", 1, 1) // a bot: doesn't count, not even as a headshot event(a, "a1", EventKill, ago(2*time.Hour), "knife", 1, nil, 1) // a bot: doesn't count, not even as a headshot
event(b, EventKill, ago(20*time.Minute), "taser", 0, 0) event(b, "b2", EventKill, ago(20*time.Minute), "taser", 0, a, 0)
event(b, EventKill, ago(20*time.Minute), "ak47", 1, 0) event(b, "b2", EventKill, ago(20*time.Minute), "ak47", 1, a, 0)
if ok, data, err := st.State(ctx); err != nil || !ok || !data || !st.HasEvents() { 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()) t.Fatalf("State with data: %v, %v, %v (events %v)", ok, data, err, st.HasEvents())
@ -194,13 +196,13 @@ func TestPlaytimeDB(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(leaders) != 1 || leaders[0].Name != "Alpha" || leaders[0].T != 1 || leaders[0].CT != 0.5 || leaders[0].Kills != 10 || leaders[0].Deaths != 5 { if len(leaders) != 1 || leaders[0].Name != "Alpha" || leaders[0].T != 1 || leaders[0].CT != 0.5 || leaders[0].Kills != 1 || leaders[0].Deaths != 2 {
t.Fatalf("leaders this week %+v", leaders) t.Fatalf("leaders this week %+v", leaders)
} }
if leaders, err = st.Leaders(ctx, nil, 10); err != nil { if leaders, err = st.Leaders(ctx, nil, 10); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 3 { if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 2 || leaders[0].Deaths != 1 {
t.Fatalf("leaders all time %+v", leaders) t.Fatalf("leaders all time %+v", leaders)
} }
@ -212,19 +214,23 @@ func TestPlaytimeDB(t *testing.T) {
for _, d := range p.Days { for _, d := range p.Days {
days += d.Hours days += d.Hours
} }
if p.T != 1 || p.CT != 0.5 || p.Spec != 0.5 || p.Stats.Kills != 10 || p.Stats.Rounds != 10 || days != 1.5 || len(p.Maps) != 1 { if p.T != 1 || p.CT != 0.5 || p.Spec != 0.5 || p.Stats == nil || *p.Stats != (Stats{Kills: 1, Deaths: 2}) || days != 1.5 || len(p.Maps) != 1 {
t.Fatalf("player %+v, %.1f h in days", p, days) t.Fatalf("player %+v, %.1f h in days", p, days)
} }
if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil { if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil {
t.Fatalf("unknown player: %v, %v", p, err) t.Fatalf("unknown player: %v, %v", p, err)
} }
ps, err := st.PlayerStaff(ctx, a, now) ps, err := st.PlayerStaff(ctx, a)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Alpha's knife kill was in a1; Bravo killed Alpha twice, but after a1 ended.
if r := ps.Recent; len(r) != 1 || r[0].Kills == nil || *r[0].Kills != 1 || r[0].Deaths == nil || *r[0].Deaths != 0 {
t.Fatalf("recent session kills and deaths %+v", ps.Recent)
}
if len(ps.Names) != 2 || ps.Names[0].Name != "Alpha" || len(ps.Recent) != 1 || ps.Recent[0].Seconds != 7200 || if len(ps.Names) != 2 || ps.Names[0].Name != "Alpha" || len(ps.Recent) != 1 || ps.Recent[0].Seconds != 7200 ||
ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" || ps.Ping == nil || *ps.Ping != 20 { ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" {
t.Fatalf("staff %+v", ps) t.Fatalf("staff %+v", ps)
} }

View file

@ -338,15 +338,15 @@ function ptDrawer(pt, online) {
<h3>Last 30 days</h3> <h3>Last 30 days</h3>
<div class="pt-spark" role="img" aria-label="Hours played each day">${p.days.map((d) => `<i class="${d.hours ? "" : "zero"}" style="height:${d.hours ? Math.max(6, (d.hours / maxDay) * 100) : 4}%" data-tip="${esc(`${ptDay(d.date)} · ${d.hours ? ptH(d.hours) : "didn't play"}`)}"></i>`).join("")}</div> <div class="pt-spark" role="img" aria-label="Hours played each day">${p.days.map((d) => `<i class="${d.hours ? "" : "zero"}" style="height:${d.hours ? Math.max(6, (d.hours / maxDay) * 100) : 4}%" data-tip="${esc(`${ptDay(d.date)} · ${d.hours ? ptH(d.hours) : "didn't play"}`)}"></i>`).join("")}</div>
</section> </section>
${st.kills + st.deaths > 0 ? `<section> ${st && st.kills + st.deaths > 0 ? `<section>
<h3>Stats</h3> <h3>Stats</h3>
<div class="pt-stats"> <div class="pt-stats">
<div><b>${ptKD(st.kills, st.deaths)}</b><small>K/D</small></div> <div><b>${ptKD(st.kills, st.deaths)}</b><small>K/D</small></div>
<div><b>${ptPct(st.headshots, st.kills)}%</b><small>Headshot kills</small></div> <div><b>${ptPct(st.headshots, st.kills)}%</b><small>Headshot kills</small></div>
<div><b>${st.rounds ? Math.round(st.damage / st.rounds) : "–"}</b><small>Damage per round</small></div> <div><b>${st.kills.toLocaleString()}</b><small>Kills</small></div>
<div><b>${st.mvps}</b><small>MVPs</small></div> <div><b>${st.deaths.toLocaleString()}</b><small>Deaths</small></div>
</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">Against other players only: bots don't count.</p>
</section>` : ""} </section>` : ""}
${p.events && Object.values(p.events).some((n) => n > 0) ? `<section> ${p.events && Object.values(p.events).some((n) => n > 0) ? `<section>
<h3>Records</h3> <h3>Records</h3>
@ -356,7 +356,6 @@ function ptDrawer(pt, online) {
<div><b>${p.events.knifeKills.toLocaleString()}</b><small>Knife kills</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><b>${p.events.zeusKills.toLocaleString()}</b><small>Zeus kills</small></div>
</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>` : ""} </section>` : ""}
${p.maps.length ? `<section> ${p.maps.length ? `<section>
<h3>Favourite maps</h3> <h3>Favourite maps</h3>
@ -381,17 +380,14 @@ function ptDrawer(pt, online) {
<h3>Recent sessions</h3> <h3>Recent sessions</h3>
<ul class="pt-sessions">${s.recent.map((x) => `<li> <ul class="pt-sessions">${s.recent.map((x) => `<li>
<div class="top"><b>${fmtDate(x.started)}</b><span>${ptDur(x.seconds)}</span></div> <div class="top"><b>${fmtDate(x.started)}</b><span>${ptDur(x.seconds)}</span></div>
<div class="muted">${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}${x.ping != null ? ` · ${x.ping} ms` : ""}</div> <div class="muted">${esc(x.map)}${x.endMap && x.endMap !== x.map ? ` → ${esc(x.endMap)}` : ""}${x.kills != null ? ` · ${x.kills}–${x.deaths}` : ""}</div>
<div>${reason(x)}</div> <div>${reason(x)}</div>
</li>`).join("")}</ul> </li>`).join("")}</ul>
</section>` : ""} </section>` : ""}
${s.country || s.ping != null ? `<section> ${s.country ? `<section>
<h3>Connection</h3> <h3>Location</h3>
<dl class="kv"> <p style="margin:0">${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</p>
${s.country ? `<dt>Location</dt><dd>${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</dd>` : ""} </section>${PT_DBIP}` : ""}`;
${s.ping != null ? `<dt>Average ping</dt><dd>${s.ping} ms <span class="muted">last 30 days</span></dd>` : ""}
</dl>
</section>${s.country ? PT_DBIP : ""}` : ""}`;
} }
/* ---------- Events ---------- */ /* ---------- Events ---------- */

View file

@ -36,7 +36,13 @@ html.drawer-open { overflow: hidden; }
html.drawer-open body { overflow: hidden; } html.drawer-open body { overflow: hidden; }
body { body {
background: var(--bg); /* The server header's 24px grid, across the whole page (fainter: the page is darker than a
panel), fixed so content scrolls over it. Panels are solid, so it shows between them. */
background:
repeating-linear-gradient(0deg, transparent 0 23px, rgba(255, 255, 255, 0.028) 23px 24px),
repeating-linear-gradient(90deg, transparent 0 23px, rgba(255, 255, 255, 0.028) 23px 24px),
var(--bg);
background-attachment: fixed;
color: var(--text); color: var(--text);
font: 400 15px/1.5 var(--body); font: 400 15px/1.5 var(--body);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

View file

@ -359,7 +359,7 @@ func (s *Server) playerPlaytime(r *http.Request, steamid string, staff bool) map
} }
out := map[string]any{"player": p} out := map[string]any{"player": p}
if staff { if staff {
extra, err := s.pt.PlayerStaff(ctx, n, now) extra, err := s.pt.PlayerStaff(ctx, n)
if err != nil { if err != nil {
slog.Warn("staff playtime for a player", "steamid", steamid, "err", err) slog.Warn("staff playtime for a player", "steamid", steamid, "err", err)
return out return out