// Playtime from PlaytimeHistory, shared by the public site and the staff panel: the Playtime page // and the Playtime sections of a player's drawer. On the staff panel, staff with @css/generic // (me.playtimeStaff) also get the staff-only figures; the public site shows everyone the same. // Needs common.js. const PT_TZ = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } })(); const PT_PERIODS = [["7d", "Last 7 days"], ["30d", "Last 30 days"], ["all", "All time"]]; const PT_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const PT_TEAM = { 0: 'Unassigned', 1: 'Spectating', 2: 'T', 3: 'CT' }; const PT_DBIP = '

Locations from IP Geolocation by DB-IP (CC BY 4.0). The plugin never stores IP addresses.

'; // The CS2 disconnect reasons players hit most, in words. Anything else shows its own name. const PT_REASONS = { NETWORK_DISCONNECT_DISCONNECT_BY_USER: "Left the game", NETWORK_DISCONNECT_TIMEDOUT: "Timed out", NETWORK_DISCONNECT_KICKED: "Kicked", NETWORK_DISCONNECT_STEAM_LOGOFF: "Steam logged off", NETWORK_DISCONNECT_LOOPDEACTIVATE: "Game closed", NETWORK_DISCONNECT_LOOPSHUTDOWN: "Game closed", NETWORK_DISCONNECT_EXITING: "Game closed", NETWORK_DISCONNECT_REJECT_BANNED: "Banned", NETWORK_DISCONNECT_KICKBANADDED: "Banned", NETWORK_DISCONNECT_STEAM_BANNED: "Banned", NETWORK_DISCONNECT_SHUTDOWN: "Server shut down", }; 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"; // a as a percentage of b. Works for hours under 1 too; nothing of nothing is 0. const ptPct = (a, b) => (b > 0 ? Math.round((a / b) * 100) : 0); // "–" until there's a kill or death to go on (session stats are saved when a session ends). const ptKD = (k, d) => (k + d > 0 ? (k / Math.max(d, 1)).toFixed(2) : "–"); const ptPad = (h) => String(h).padStart(2, "0"); const ptDur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${ptPad(Math.floor((s % 3600) / 60))}m` : `${Math.max(1, Math.round(s / 60))}m`); const ptDay = (iso, opts = { weekday: "short", day: "numeric", month: "short" }) => new Date(`${iso}T12:00:00`).toLocaleDateString(undefined, opts); function ptWho(p) { return `
${avatar(p.steamid, p.name)}
${esc(p.name || "Unknown")}${p.rank ? rankTag(p.rank) : ""}${esc(p.steamid)}
`; } function ptBar(value, max, label, fill) { return `
${label}
`; } // A clean gridline step for values up to max: about three lines, from 0.1 up. function ptStep(max, least = 0.1) { for (const s of [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500]) if (s >= least && max / s <= 4) return s; return 1000; } // A column chart: values [{v, tip, tick}], gridlines on clean steps. function ptColumns(values, { note = "", fill = "", whole = false }) { const top = Math.max(0, ...values.map((x) => x.v)); const step = ptStep(top, whole ? 1 : 0.1); const max = Math.max(step, Math.ceil(top / step - 1e-9) * step); const grid = []; for (let i = 1; i * step <= max + 1e-9; i++) { const g = Math.round(i * step * 10) / 10; grid.push(`
${g}
`); } return `
${grid.join("")}${values.map((x) => `
`).join("")}
${values.map((x) => `${x.tick ?? ""}`).join("")}
${note ? `

${note}

` : ""}
`; } function ptSplit(t, ct, spec) { const total = t + ct + spec; const seg = (cls, v) => (v > 0 ? `` : ""); return ` `; } /* ---------- Page: public panels ---------- */ function ptTiles(s) { const delta = (a, b) => (b ? `${a >= b ? "+" : ""}${ptPct(a - b, b)}% on the 7 days before` : 'Nothing the 7 days before'); return `
Players, last 7 days${s.players}${delta(s.players, s.playersPrev)}
Hours played, last 7 days${s.hours < 10 ? s.hours.toFixed(1) : Math.round(s.hours).toLocaleString()}${delta(s.hours, s.hoursPrev)}
Most online at once${s.peak}${s.peakAt ? fmtDate(s.peakAt) : "No count yet"}
New players, last 7 days${s.newPlayers}First time on the server
`; } function ptLeaders(d) { const max = Math.max(0.1, ...d.leaders.map((p) => p.t + p.ct)); return `

Top players

Time on T or CT. Spectating doesn't count.

${segmented("ptPeriod", d.period, PT_PERIODS)}
${d.leaders.length ? `
${d.leaders.map((p, i) => ``).join("")}
#PlayerTime playedT / CTK/D
${i + 1} ${ptWho(p)} ${ptBar(p.t + p.ct, max, ptH(p.t + p.ct))} ${p.t.toFixed(1)} / ${p.ct.toFixed(1)} ${ptKD(p.kills, p.deaths)}
` : '
Nobody has played on a team in this time.
'}
`; } // The span an average covers: "Last 30 days." (or 4 weeks), or "Since 4 Oct." while the // recording is younger than that. n is the number of days, today included. function ptSpan(n, full) { if (n >= full) return full === 28 ? "Last 4 weeks." : "Last 30 days."; const from = new Date(); from.setDate(from.getDate() - Math.max(0, (n || 1) - 1)); return `Since ${from.toLocaleDateString(undefined, { day: "numeric", month: "short" })}.`; } function ptBusiest(o) { const players = (v) => `${v} ${v === 1 ? "player" : "players"}`; const vals = o.byHour.map((v, h) => ({ v, tick: h % 3 === 0 ? ptPad(h) : "", tip: `${ptPad(h)}:00 · ${players(v)} a day` })); const best = o.byHour.indexOf(Math.max(...o.byHour)); return `

Busiest hours

Players on T or CT in each hour of the day, on average. Bots don't count. ${ptSpan(o.byHourDays, 30)}

${ptColumns(vals, { note: o.byHour[best] ? `Busiest at ${ptPad(best)}:00, with ${players(o.byHour[best])} a day on average.` : "No one has played on a team yet." })}
`; } function ptDaily(o) { const vals = o.days.map((d, i) => ({ v: d.players || 0, tick: i % 5 === 0 ? ptDay(d.date, { day: "numeric" }) : "", tip: `${ptDay(d.date)} · ${d.players || 0} players` })); const best = o.days.reduce((b, d) => ((d.players || 0) > (b?.players || 0) ? d : b), null); return `

Players per day

Different players who joined each day, last 30 days.

${ptColumns(vals, { fill: "var(--ct)", whole: true, note: best ? `Busiest day: ${ptDay(best.date)}, with ${best.players} ${best.players === 1 ? "player" : "players"}.` : "No one has joined yet." })}
`; } function ptHeatmap(o) { const max = Math.max(0.1, ...o.heat.flat()); const step = (v) => (v <= 0.05 ? 0 : Math.min(5, Math.ceil((v / max) * 5))); return `

When people play

Players on T or CT by day and hour, on average. ${ptSpan(o.heatDays, 28)}

${o.heat[0].map((_, h) => ``).join("")}${o.heat.map((row, wd) => `${row.map((v, h) => ``).join("")}`).join("")}
${h % 3 === 0 ? ptPad(h) : ""}
${PT_WEEKDAYS[wd]}
Fewer ${[0, 1, 2, 3, 4, 5].map((n) => ``).join("")} More
`; } function ptMapsPanel(o) { const max = Math.max(0.1, ...o.maps.map((m) => m.hours)); return `

Maps

Hours played on each map, last 30 days.

${o.maps.length ? `
${o.maps.map((m) => ``).join("")}
MapHours playedPlayers
${esc(m.map)}${ptBar(m.hours, max, m.hours.toLocaleString(), "var(--t)")}${m.players}
` : '
No maps played in the last 30 days.
'}
`; } 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)}
${d.records ? `
${ptRecords(d)}
` : ""}
${ptBusiest(o)}${ptDaily(o)}
${ptHeatmap(o)}${ptMapsPanel(o)}`; } /* ---------- Page: staff panels ---------- */ function ptOnline(a) { return `

On the server now

How long each player has been on, their team and where they're connecting from.

${a.online.length ? `
${a.online.map((p) => ``).join("")}
PlayerOnline forTeamPingFrom
${ptWho(p)} ${p.since ? ptDur((Date.now() - new Date(p.since)) / 1000) : 'Not recorded'} ${PT_TEAM[p.team] || ""}${p.alive === false ? ' dead' : ""} ${p.ping > 100 ? `${p.ping}` : `${p.ping}`} ${p.country ? `${p.city ? `${esc(p.city)}, ` : ""}${esc(p.country)}` : 'Unknown'}
${PT_DBIP}` : '
Nobody is on the server.
'}
`; } function ptActivity(a) { const max = Math.max(0.1, ...a.staff.map((s) => s.hours)); return `

Staff activity

Time each staff member spent on the server.

${segmented("ptStaffPeriod", a.period, PT_PERIODS.slice(0, 2))}
${a.staff.length ? `
${a.staff.map((s) => ``).join("")}
StaffOn T or CTSpectatingLast seen
${ptWho(s)} ${s.hours ? ptBar(s.hours, max, ptH(s.hours), "var(--green)") : 'Not on a team'} ${s.spec ? ptH(s.spec) : "–"} ${s.online ? 'Online now' : s.lastSeen ? fmtAgo(s.lastSeen) : 'Never'}
` : '
No staff yet.
'}
`; } function ptReasons(a) { const total = a.reasons.reduce((n, r) => n + r.count, 0); const max = Math.max(1, ...a.reasons.map((r) => r.count)); return `

Why people leave

Disconnect reasons, last 30 days. Lots of timeouts can mean a network problem.

${a.reasons.length ? `
${a.reasons.map((r) => ``).join("")}
ReasonDisconnectsStayed
${esc(PT_REASONS[r.name] || r.name || "Unknown")}${PT_REASONS[r.name] ? `${esc(r.name)}` : ""} ${ptBar(r.count, max, `${r.count} · ${ptPct(r.count, total)}%`, "var(--amber)")}${ptDur(r.avgSeconds)} avg.
` : '
No disconnects in the last 30 days.
'}
`; } function ptCountries(a) { const max = Math.max(1, ...a.countries.map((c) => c.players)); return `

Where players are from

Different players per country, all time.

${a.countries.length ? `
${a.countries.map((c) => ``).join("")}
CountryPlayersHours
${esc(c.name || c.iso)} ${esc(c.iso)}${ptBar(c.players, max, c.players, "var(--ct)")}${Math.round(c.hours).toLocaleString()}
${PT_DBIP}` : '
No locations recorded yet.
'}
`; } function ptRecorder(a) { const r = a.recorder; if (!r) return ""; const e = a.endKinds; const plugin = { loaded: `Loaded ${esc(r.version)}`, missing: 'Not loaded', unknown: 'Unknown the game server isn\'t answering' }[r.plugin] || ""; return `

Recorder

PlaytimeHistory on the game server (css_pt_status), and how sessions ended in the last 30 days. Only Root sees this.

Plugin
${plugin}
${r.lines.length ? `
Status
    ${r.lines.map((l) => `
  • ${esc(l)}
  • `).join("")}
` : ""}
Sessions ended
${e.disconnect.toLocaleString()} by disconnect, ${e.mapChange.toLocaleString()} by map change, ${e.unload} by plugin unload
Crashes
${e.stale ? `${e.stale} sessions closed at their last heartbeat` : "None"}
`; } function ptStaffPanels(a) { return `
${ptOnline(a)}
${ptActivity(a)}
${ptReasons(a)}${ptCountries(a)}${ptRecorder(a)}
`; } /* ---------- Page ---------- */ // ptPage draws the Playtime page into #view: with staff (the staff panel, for staff who may see // them), the staff panels first, then the public ones. isCurrent says whether the user is still on // the page when data arrives. async function ptPage(me, isCurrent, staffPanels = false) { const v = $("#view"); const staff = staffPanels && !!me?.playtimeStaff; v.innerHTML = `

Playtime

${staff ? `
${loading()}

${me.playtime === "on" ? "On the public site" : ""}

` : ""}
${me?.playtime === "on" ? loading() : ""}
`; if (me?.playtime !== "on") { $("#pt-public").innerHTML = '
PlaytimeHistory hasn\'t recorded anything yet. Playtime appears on the public site once it has.
'; } const jobs = []; if (staff) { jobs.push(api(`/api/admin/playtime${qs({ period: ptState.staffPeriod })}`).then((a) => { if (isCurrent()) $("#pt-staff").innerHTML = ptStaffPanels(a); }).catch((err) => { if (isCurrent()) $("#pt-staff").innerHTML = errorBox(err); })); } if (me?.playtime === "on") { 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); })); } await Promise.all(jobs); } // Switching a period redraws only that panel. async function ptSwitch(key, value) { if (key === "ptPeriod" || key === "ptRecords") { ptState[key === "ptPeriod" ? "period" : "records"] = value; try { 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; try { const a = await api(`/api/admin/playtime${qs({ period: value })}`); const box = $("#pt-activity"); if (box) box.innerHTML = ptActivity(a); } catch (err) { toast(err.message); } } } /* ---------- Player drawer ---------- */ // ptDrawer is the Playtime sections of a player's drawer, from the drawer API's "playtime". function ptDrawer(pt, online) { if (!pt) return ""; const p = pt.player; let out; if (!p) { out = `

Playtime

PlaytimeHistory hasn't recorded this player.

`; } else { const played = p.t + p.ct; const maxDay = Math.max(0.1, ...p.days.map((d) => d.hours || 0)); const maxMap = Math.max(0.1, ...p.maps.map((m) => m.hours)); const st = p.stats; out = `

Playtime

Time played
${ptH(played)} on T or CT
First seen
${fmtDate(p.firstSeen)}
Last seen
${online ? 'On the server now' : fmtAgo(p.lastSeen)}
Visits
${p.sessions.toLocaleString()} ${p.sessions === 1 ? "session" : "sessions"}
${played + p.spec > 0 ? ptSplit(p.t, p.ct, p.spec) : ""} ${p.alive + p.dead > 0 ? `

Alive for ${ptPct(p.alive, p.alive + p.dead)}% of their time on a team.

` : ""}

Last 30 days

${st && st.kills + st.deaths > 0 ? `

Stats

${ptKD(st.kills, st.deaths)}K/D
${ptPct(st.headshots, st.kills)}%Headshot kills
${st.kills.toLocaleString()}Kills
${st.deaths.toLocaleString()}Deaths

Against other players only: bots don't count.

` : ""} ${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.maps.length ? `

Favourite maps

${p.maps.map((m) => `
${esc(m.map)}${ptBar(m.hours, maxMap, ptH(m.hours), "var(--t)")}
`).join("")}
` : ""}`; } const s = pt.staff; if (!s) return out; const reason = (x) => { if (x.endKind === 0) return 'On now'; if (x.endKind === 2) return "Map change"; if (x.endKind === 3) return "Plugin unloaded"; if (x.endKind === 4) return 'Server crashed'; return esc(PT_REASONS[x.reason] || x.reason || "Left"); }; return `${out} ${s.names.length ? `

Names used

` : ""} ${s.recent.length ? `

Recent sessions

` : ""} ${s.country ? `

Location

${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}

${PT_DBIP}` : ""}`; } /* ---------- Events ---------- */ document.addEventListener("click", (e) => { 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); }); // One tooltip for every [data-tip] mark, on hover and on keyboard focus. const ptTip = document.createElement("div"); ptTip.id = "pt-tip"; ptTip.hidden = true; document.body.append(ptTip); function ptShowTip(el, x, y) { ptTip.textContent = el.dataset.tip; ptTip.hidden = false; const r = ptTip.getBoundingClientRect(); ptTip.style.left = `${Math.min(window.innerWidth - r.width - 8, Math.max(8, x - r.width / 2))}px`; ptTip.style.top = `${Math.max(8, y - r.height - 12)}px`; } document.addEventListener("mousemove", (e) => { const el = e.target.closest?.("[data-tip]"); if (el) ptShowTip(el, e.clientX, e.clientY); else ptTip.hidden = true; }); document.addEventListener("focusin", (e) => { const el = e.target.closest?.("[data-tip]"); if (!el) return; const r = el.getBoundingClientRect(); ptShowTip(el, r.left + r.width / 2, r.top); }); document.addEventListener("focusout", () => { ptTip.hidden = true; });