diff --git a/README.md b/README.md index af92d90..011311a 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,12 @@ SAW_PT_DSN=pt_web:@tcp(: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; diff --git a/internal/playtime/playtime.go b/internal/playtime/playtime.go index 9889ec4..301f8da 100644 --- a/internal/playtime/playtime.go +++ b/internal/playtime/playtime.go @@ -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 { diff --git a/internal/playtime/playtime_db_test.go b/internal/playtime/playtime_db_test.go index b8e26e3..016f375 100644 --- a/internal/playtime/playtime_db_test.go +++ b/internal/playtime/playtime_db_test.go @@ -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) diff --git a/internal/playtime/testdata/schema.sql b/internal/playtime/testdata/schema.sql index 84af829..cfd653e 100644 --- a/internal/playtime/testdata/schema.sql +++ b/internal/playtime/testdata/schema.sql @@ -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; diff --git a/internal/web/assets/common.js b/internal/web/assets/common.js index c4a99ac..f84cd16 100644 --- a/internal/web/assets/common.js +++ b/internal/web/assets/common.js @@ -157,6 +157,12 @@ function errorBox(err) { return `
${esc(err.message)}
`; 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 = `
${esc(msg)}
`; diff --git a/internal/web/assets/playtime.js b/internal/web/assets/playtime.js index d2925cb..badac54 100644 --- a/internal/web/assets/playtime.js +++ b/internal/web/assets/playtime.js @@ -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) { `; } +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 `
+
+

Records

${since}

+ ${segmented("ptRecords", d.recordsPeriod, PT_PERIODS)} +
+
${PT_RECORDS.map(([key, label]) => `
+

${label}

+ ${r[key].length ? `
    ${r[key].map((p) => `
  1. ${esc(p.name || p.steamid)}${p.count.toLocaleString()}
  2. `).join("")}
` : '

None yet.

'} +
`).join("")}
+
`; +} + function ptPublicPanels(d) { const o = d.overview; - return `${ptTiles(o.summary)}
${ptLeaders(d)}
+ return `${ptTiles(o.summary)}
${ptLeaders(d)}
${d.records ? `
${ptRecords(d)}
` : ""}
${ptBusiest(o)}${ptDaily(o)}
${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) {

${st.kills.toLocaleString()} kills, ${st.deaths.toLocaleString()} deaths and ${st.assists.toLocaleString()} assists over ${st.rounds.toLocaleString()} rounds.

` : ""} + ${p.events && Object.values(p.events).some((n) => n > 0) ? `
+

Records

+
+
${p.events.plants.toLocaleString()}Bomb plants
+
${p.events.defuses.toLocaleString()}Bomb defuses
+
${p.events.knifeKills.toLocaleString()}Knife kills
+
${p.events.zeusKills.toLocaleString()}Zeus kills
+
+

${p.events.headshots.toLocaleString()} headshot kills. Counted as they happen, not in warmup or on bots.

+
` : ""} ${p.maps.length ? `

Favourite maps

${p.maps.map((m) => `
${esc(m.map)}${ptBar(m.hours, maxMap, ptH(m.hours), "var(--t)")}
`).join("")}
@@ -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); diff --git a/internal/web/assets/public.js b/internal/web/assets/public.js index 518d39d..fb41c60 100644 --- a/internal/web/assets/public.js +++ b/internal/web/assets/public.js @@ -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. diff --git a/internal/web/assets/styles.css b/internal/web/assets/styles.css index 7586e05..4debd0a 100644 --- a/internal/web/assets/styles.css +++ b/internal/web/assets/styles.css @@ -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); } diff --git a/internal/web/playtime.go b/internal/web/playtime.go index 46f2c46..c7091d2 100644 --- a/internal/web/playtime.go +++ b/internal/web/playtime.go @@ -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 {