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:
Astra 2026-10-04 21:37:42 +01:00
parent 6d0fc6e463
commit 7004945d49
9 changed files with 296 additions and 27 deletions

View file

@ -165,9 +165,12 @@ SAW_PT_DSN=pt_web:<password>@tcp(<db-host>:3306)/playtime_history
and runs normally either way.
- **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
the 7 before, the busiest hours, players per day, a day-by-hour heatmap and maps. Player pages
get time played with the T/CT/spectating split, the last 30 days, K/D, headshots, damage per
round, MVPs and favourite maps.
the 7 before, the busiest hours, players per day, a day-by-hour heatmap and maps. With
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
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 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;

View file

@ -7,8 +7,10 @@ import (
"context"
"database/sql"
"fmt"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/go-sql-driver/mysql"
@ -22,6 +24,20 @@ const (
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).
const (
EndOpen = 0
@ -36,7 +52,8 @@ var tables = []string{"pt_players", "pt_sessions", "pt_segments", "pt_nicknames"
type Store struct {
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.
@ -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() }
// 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.
func (s *Store) and(col string, args []any) (string, []any) {
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.
// 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) {
var n int
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name IN (?, ?, ?, ?, ?)`,
tables[0], tables[1], tables[2], tables[3], tables[4]).Scan(&n)
var n, events int
err = s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT IF(table_name = 'pt_events', NULL, table_name)),
COUNT(DISTINCT IF(table_name = 'pt_events', table_name, NULL))
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) {
return false, false, err
}
s.events.Store(events == 1)
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)
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()
}
// 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) {
args := make([]any, len(ids))
for i, id := range ids {
@ -423,6 +527,15 @@ func ParseID(steamid string) (uint64, error) {
// ---------------------------------------------------------------- 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 {
Kills int `json:"kills"`
Deaths int `json:"deaths"`
@ -444,7 +557,8 @@ type Player struct {
Alive float64 `json:"alive"`
Dead float64 `json:"dead"`
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"`
}
@ -470,6 +584,17 @@ func (s *Store) Player(ctx context.Context, steamid uint64, now time.Time, loc *
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)
buckets, err := s.hours(ctx, start, steamid)
if err != nil {

View file

@ -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')`,
ago(24*time.Hour), ago(48*time.Hour), ago(24*time.Hour))
if ok, data, err := st.State(ctx); err != nil || !ok || !data {
t.Fatalf("State with data: %v, %v, %v", ok, data, err)
// 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.
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.
@ -228,6 +243,32 @@ func TestPlaytimeDB(t *testing.T) {
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))
if err != nil {
t.Fatal(err)

View file

@ -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.
-- 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,
TIMESTAMPDIFF(SECOND, piece_start, LEAST(ended_at, hour_start + INTERVAL 1 HOUR)) AS seconds
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;

View file

@ -157,6 +157,12 @@ function errorBox(err) { return `<div class="empty">${esc(err.message)}</div>`;
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) {
const root = $("#toast-root");
root.innerHTML = `<div class="toast">${esc(msg)}</div>`;

View file

@ -22,7 +22,12 @@ const PT_REASONS = {
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 ptPct = (a, b) => Math.round((a / Math.max(b, 1)) * 100);
@ -146,9 +151,24 @@ function ptMapsPanel(o) {
</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) {
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)}`;
}
@ -254,7 +274,7 @@ async function ptPage(me, isCurrent) {
}).catch((err) => { if (isCurrent()) $("#pt-staff").innerHTML = errorBox(err); }));
}
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);
}).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.
async function ptSwitch(key, value) {
if (key === "ptPeriod") {
ptState.period = value;
if (key === "ptPeriod" || key === "ptRecords") {
ptState[key === "ptPeriod" ? "period" : "records"] = value;
try {
const d = await api(`/api/public/playtime${qs({ period: value, tz: PT_TZ })}`);
const box = $("#pt-leaders");
if (box) box.innerHTML = ptLeaders(d);
const d = await api(`/api/public/playtime${qs({ period: ptState.period, records: ptState.records, tz: PT_TZ })}`);
const box = key === "ptPeriod" ? $("#pt-leaders") : $("#pt-records");
if (box) box.innerHTML = key === "ptPeriod" ? ptLeaders(d) : ptRecords(d);
} catch (err) { toast(err.message); }
} else if (key === "ptStaffPeriod") {
ptState.staffPeriod = value;
@ -319,6 +339,16 @@ function ptDrawer(pt, online) {
</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>
</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>
<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>
@ -358,7 +388,7 @@ function ptDrawer(pt, online) {
/* ---------- Events ---------- */
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;
seg.parentElement.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg)));
ptSwitch(seg.parentElement.dataset.seg, seg.dataset.value);

View file

@ -4,11 +4,11 @@
const PAGES = [
{ 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.
{ 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.

View file

@ -27,7 +27,13 @@
* { 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 {
background: var(--bg);
@ -710,6 +716,7 @@ td.num, th.num { text-align: right; white-space: nowrap; }
}
.drawer {
overscroll-behavior: contain;
position: fixed;
top: 0;
right: 0;
@ -1159,6 +1166,14 @@ tr:nth-child(2) .pt-place { color: #c9ced8; }
tr:nth-child(3) .pt-place { color: #d08a52; }
.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 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); }

View file

@ -122,8 +122,13 @@ func viewerZone(r *http.Request) *time.Location {
// periodSince reads ?period=7d|30d|all; all is nil.
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()
switch p := r.URL.Query().Get("period"); {
switch {
case p == "30d":
t := now.Add(-30 * 24 * time.Hour)
return p, &t
@ -167,7 +172,18 @@ func (s *Server) publicPlaytime(w http.ResponseWriter, r *http.Request) {
for _, l := range leaders {
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 {