simpleadmin-web/internal/web/assets/playtime.js
Astra 7004945d49 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.
2026-10-04 21:37:42 +01:00

420 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Playtime from PlaytimeHistory, shared by the public site and the staff panel: the Playtime page
// and the Playtime sections of a player's drawer. Staff with @css/generic (me.playtimeStaff) also
// get the staff-only figures, on either site. 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: '<span class="faint">Unassigned</span>', 1: '<span class="muted">Spectating</span>', 2: '<span class="team t">T</span>', 3: '<span class="team ct">CT</span>' };
const PT_DBIP = '<p class="pt-credit">Locations from <a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> (CC BY 4.0). The plugin never stores IP addresses.</p>';
// 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";
const ptPct = (a, b) => Math.round((a / Math.max(b, 1)) * 100);
const ptKD = (k, d) => (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 `<div class="who pt-who">${avatar(p.steamid, p.name)}<div><b>${esc(p.name || "Unknown")}${p.rank ? rankTag(p.rank) : ""}</b><span class="sid">${esc(p.steamid)}</span></div></div>`;
}
function ptBar(value, max, label, fill) {
return `<div class="pt-hbar"><div class="track"><div class="fill" style="width:${ptPct(value, max)}%${fill ? `;--fill:${fill}` : ""}"></div></div><span>${label}</span></div>`;
}
// 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(`<div class="grid" style="bottom:${(g / max) * 100}%"><span>${g}</span></div>`);
}
return `<div class="pt-cols">
<div class="plot">${grid.join("")}${values.map((x) => `<div class="bar" tabindex="0" data-tip="${esc(x.tip)}"><i style="height:${x.v ? Math.max(1, (x.v / max) * 100) : 0}%${fill ? `;background:${fill}` : ""}"></i></div>`).join("")}</div>
<div class="ticks">${values.map((x) => `<span>${x.tick ?? ""}</span>`).join("")}</div>
${note ? `<p class="note">${note}</p>` : ""}
</div>`;
}
function ptSplit(t, ct, spec) {
const total = t + ct + spec;
const seg = (cls, v) => (v > 0 ? `<i class="${cls}" style="flex:${v}" data-tip="${esc(`${ptH(v)} (${ptPct(v, total)}%)`)}"></i>` : "");
return `<div class="pt-split" role="img" aria-label="T ${ptH(t)}, CT ${ptH(ct)}, spectating ${ptH(spec)}">${seg("t", t)}${seg("ct", ct)}${seg("spec", spec)}</div>
<ul class="pt-legend">
<li><i style="--c:var(--t)"></i>T <span>${ptH(t)}</span></li>
<li><i style="--c:var(--ct)"></i>CT <span>${ptH(ct)}</span></li>
<li><i style="--c:var(--spec)"></i>Spectating <span>${ptH(spec)}</span></li>
</ul>`;
}
/* ---------- Page: public panels ---------- */
function ptTiles(s) {
const delta = (a, b) => (b ? `<span class="delta ${a >= b ? "up" : ""}">${a >= b ? "+" : ""}${ptPct(a - b, b)}% on the 7 days before</span>` : '<span class="delta">Nothing the 7 days before</span>');
return `<div class="pt-tiles">
<div class="pt-tile"><small>Players, last 7 days</small><b>${s.players}</b>${delta(s.players, s.playersPrev)}</div>
<div class="pt-tile"><small>Hours played, last 7 days</small><b>${Math.round(s.hours).toLocaleString()}</b>${delta(s.hours, s.hoursPrev)}</div>
<div class="pt-tile"><small>Most online at once</small><b>${s.peak}</b><span class="delta">${s.peakAt ? fmtDate(s.peakAt) : "No count yet"}</span></div>
<div class="pt-tile"><small>New players, last 7 days</small><b>${s.newPlayers}</b><span class="delta">First time on the server</span></div>
</div>`;
}
function ptLeaders(d) {
const max = Math.max(0.1, ...d.leaders.map((p) => p.t + p.ct));
return `<section class="panel">
<div class="panel-head">
<div><h2>Top players</h2><p>Time on T or CT. Spectating doesn't count.</p></div>
${segmented("ptPeriod", d.period, PT_PERIODS)}
</div>
${d.leaders.length ? `<div class="table-wrap"><table>
<thead><tr><th style="width:3em">#</th><th>Player</th><th>Time played</th><th class="num hide-sm">T / CT</th><th class="num">K/D</th></tr></thead>
<tbody>${d.leaders.map((p, i) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><span class="pt-place">${i + 1}</span></td>
<td>${ptWho(p)}</td>
<td>${ptBar(p.t + p.ct, max, ptH(p.t + p.ct))}</td>
<td class="num hide-sm muted">${p.t.toFixed(1)} / ${p.ct.toFixed(1)}</td>
<td class="num">${ptKD(p.kills, p.deaths)}</td>
</tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">Nobody has played on a team in this time.</div>'}
</section>`;
}
function ptBusiest(o) {
const vals = o.byHour.map((v, h) => ({ v, tick: h % 3 === 0 ? ptPad(h) : "", tip: `${ptPad(h)}:00 · ${v} players on average` }));
const best = o.byHour.indexOf(Math.max(...o.byHour));
return `<section class="panel">
<div class="panel-head"><div><h2>Busiest hours</h2><p>Players on T or CT, averaged over the last 30 days, in your time zone.</p></div></div>
${ptColumns(vals, { note: o.byHour[best] ? `Busiest at <b>${ptPad(best)}:00</b>, with ${o.byHour[best]} players on average.` : "" })}
</section>`;
}
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` }));
return `<section class="panel">
<div class="panel-head"><div><h2>Players per day</h2><p>Different players who joined each day, last 30 days.</p></div></div>
${ptColumns(vals, { fill: "var(--ct)", whole: true })}
</section>`;
}
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 `<section class="panel">
<div class="panel-head"><div><h2>When people play</h2><p>Average players on T or CT by day and hour, last 4 weeks, in your time zone.</p></div></div>
<div class="pt-heat"><table>
<thead><tr><th></th>${o.heat[0].map((_, h) => `<th>${h % 3 === 0 ? ptPad(h) : ""}</th>`).join("")}</tr></thead>
<tbody>${o.heat.map((row, wd) => `<tr><th scope="row">${PT_WEEKDAYS[wd]}</th>${row.map((v, h) => `<td><i style="background:var(--heat-${step(v)})" data-tip="${PT_WEEKDAYS[wd]} ${ptPad(h)}:00 · ${v} players"></i></td>`).join("")}</tr>`).join("")}</tbody>
</table>
<div class="scale">Fewer ${[0, 1, 2, 3, 4, 5].map((n) => `<i style="background:var(--heat-${n})"></i>`).join("")} More</div></div>
</section>`;
}
function ptMapsPanel(o) {
const max = Math.max(0.1, ...o.maps.map((m) => m.hours));
return `<section class="panel">
<div class="panel-head"><div><h2>Maps</h2><p>Hours played on each map, last 30 days.</p></div></div>
${o.maps.length ? `<div class="table-wrap"><table>
<thead><tr><th>Map</th><th>Hours played</th><th class="num">Players</th></tr></thead>
<tbody>${o.maps.map((m) => `<tr><td><b>${esc(m.map)}</b></td><td>${ptBar(m.hours, max, m.hours.toLocaleString(), "var(--t)")}</td><td class="num muted">${m.players}</td></tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">No maps played in the last 30 days.</div>'}
</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>${d.records ? `<div id="pt-records">${ptRecords(d)}</div>` : ""}
<div class="pt-grid">${ptBusiest(o)}${ptDaily(o)}</div>${ptHeatmap(o)}${ptMapsPanel(o)}`;
}
/* ---------- Page: staff panels ---------- */
function ptOnline(a) {
return `<section class="panel">
<div class="panel-head"><div><h2>On the server now</h2><p>How long each player has been on, their team and where they're connecting from.</p></div></div>
${a.online.length ? `<div class="table-wrap"><table>
<thead><tr><th>Player</th><th>Online for</th><th>Team</th><th class="num">Ping</th><th class="hide-sm">From</th></tr></thead>
<tbody>${a.online.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td>${ptWho(p)}</td>
<td>${p.since ? ptDur((Date.now() - new Date(p.since)) / 1000) : '<span class="faint">Not recorded</span>'}</td>
<td>${PT_TEAM[p.team] || ""}${p.alive === false ? ' <span class="faint">dead</span>' : ""}</td>
<td class="num">${p.ping > 100 ? `<span class="tag amber">${p.ping}</span>` : `<span class="muted">${p.ping}</span>`}</td>
<td class="hide-sm">${p.country ? `${p.city ? `${esc(p.city)}, ` : ""}${esc(p.country)}` : '<span class="faint">Unknown</span>'}</td>
</tr>`).join("")}</tbody>
</table></div>${PT_DBIP}` : '<div class="empty">Nobody is on the server.</div>'}
</section>`;
}
function ptActivity(a) {
const max = Math.max(0.1, ...a.staff.map((s) => s.hours));
return `<section class="panel">
<div class="panel-head"><div><h2>Staff activity</h2><p>Time each staff member spent on the server.</p></div>
${segmented("ptStaffPeriod", a.period, PT_PERIODS.slice(0, 2))}</div>
${a.staff.length ? `<div class="table-wrap"><table>
<thead><tr><th>Staff</th><th>On T or CT</th><th class="num hide-sm">Spectating</th><th>Last seen</th></tr></thead>
<tbody>${a.staff.map((s) => `<tr class="clickable" data-player="${esc(s.steamid)}">
<td>${ptWho(s)}</td>
<td>${s.hours ? ptBar(s.hours, max, ptH(s.hours), "var(--green)") : '<span class="tag amber">Not on a team</span>'}</td>
<td class="num hide-sm muted">${s.spec ? ptH(s.spec) : "–"}</td>
<td>${s.online ? '<span class="dot"></span>Online now' : s.lastSeen ? fmtAgo(s.lastSeen) : '<span class="faint">Never</span>'}</td>
</tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">No staff yet.</div>'}
</section>`;
}
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 `<section class="panel">
<div class="panel-head"><div><h2>Why people leave</h2><p>Disconnect reasons, last 30 days. Lots of timeouts can mean a network problem.</p></div></div>
${a.reasons.length ? `<div class="table-wrap"><table>
<thead><tr><th>Reason</th><th>Disconnects</th><th class="num hide-sm">Stayed</th></tr></thead>
<tbody>${a.reasons.map((r) => `<tr><td><b>${esc(PT_REASONS[r.name] || r.name || "Unknown")}</b>${PT_REASONS[r.name] ? `<span class="sid" style="display:block">${esc(r.name)}</span>` : ""}</td>
<td>${ptBar(r.count, max, `${r.count} · ${ptPct(r.count, total)}%`, "var(--amber)")}</td><td class="num hide-sm muted">${ptDur(r.avgSeconds)} avg.</td></tr>`).join("")}</tbody>
</table></div>` : '<div class="empty">No disconnects in the last 30 days.</div>'}
</section>`;
}
function ptCountries(a) {
const max = Math.max(1, ...a.countries.map((c) => c.players));
return `<section class="panel">
<div class="panel-head"><div><h2>Where players are from</h2><p>Different players per country, all time.</p></div></div>
${a.countries.length ? `<div class="table-wrap"><table>
<thead><tr><th>Country</th><th>Players</th><th class="num hide-sm">Hours</th></tr></thead>
<tbody>${a.countries.map((c) => `<tr><td><b>${esc(c.name || c.iso)}</b> <span class="faint">${esc(c.iso)}</span></td><td>${ptBar(c.players, max, c.players, "var(--ct)")}</td><td class="num hide-sm muted">${Math.round(c.hours).toLocaleString()}</td></tr>`).join("")}</tbody>
</table></div>${PT_DBIP}` : '<div class="empty">No locations recorded yet.</div>'}
</section>`;
}
function ptRecorder(a) {
const r = a.recorder;
if (!r) return "";
const e = a.endKinds;
const plugin = { loaded: `<span class="tag green">Loaded</span> <span class="muted">${esc(r.version)}</span>`, missing: '<span class="tag red">Not loaded</span>', unknown: '<span class="tag">Unknown</span> <span class="muted">the game server isn\'t answering</span>' }[r.plugin] || "";
return `<section class="panel">
<div class="panel-head"><div><h2>Recorder</h2><p>PlaytimeHistory on the game server (<code>css_pt_status</code>), and how sessions ended in the last 30 days. Only Root sees this.</p></div></div>
<div class="panel-body"><dl class="kv">
<dt>Plugin</dt><dd>${plugin}</dd>
${r.lines.length ? `<dt>Status</dt><dd><ul class="pt-lines">${r.lines.map((l) => `<li>${esc(l)}</li>`).join("")}</ul></dd>` : ""}
<dt>Sessions ended</dt><dd>${e.disconnect.toLocaleString()} by disconnect, ${e.mapChange.toLocaleString()} by map change, ${e.unload} by plugin unload</dd>
<dt>Crashes</dt><dd>${e.stale ? `<span class="tag amber">${e.stale} sessions</span> closed at their last heartbeat` : "None"}</dd>
</dl></div>
</section>`;
}
function ptStaffPanels(a) {
return `<div class="stack pt-stack">${ptOnline(a)}<div id="pt-activity">${ptActivity(a)}</div>${ptReasons(a)}${ptCountries(a)}${ptRecorder(a)}</div>`;
}
/* ---------- Page ---------- */
// ptPage draws the Playtime page into #view: the staff panels first when this viewer may see them,
// then the public ones. isCurrent says whether the user is still on the page when data arrives.
async function ptPage(me, isCurrent) {
const v = $("#view");
const staff = !!me?.playtimeStaff;
v.innerHTML = `
<div class="page-head"><div><h1>Playtime</h1><p class="sub">${staff
? "Who's on, staff activity and where players come from, then everything players see on the public site."
: "Who plays the most, and when the server is busiest. Click a player for their stats."}</p></div></div>
${staff ? `<div id="pt-staff">${loading()}</div><h2 class="crew-sep">${me.playtime === "on" ? "On the public site" : ""}</h2>` : ""}
<div id="pt-public" class="stack pt-stack">${me?.playtime === "on" ? loading() : ""}</div>`;
if (me?.playtime !== "on") {
$("#pt-public").innerHTML = '<div class="empty">PlaytimeHistory hasn\'t recorded anything yet. Playtime appears on the public site once it has.</div>';
}
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 = `<section><h3>Playtime</h3><p class="muted" style="margin:0">PlaytimeHistory hasn't recorded this player.</p></section>`;
} 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 = `<section>
<h3>Playtime</h3>
<dl class="kv" style="margin-bottom:14px">
<dt>Time played</dt><dd><b>${ptH(played)}</b> on T or CT</dd>
<dt>First seen</dt><dd>${fmtDate(p.firstSeen)}</dd>
<dt>Last seen</dt><dd>${online ? '<span class="dot"></span>On the server now' : fmtAgo(p.lastSeen)}</dd>
<dt>Visits</dt><dd>${p.sessions.toLocaleString()} ${p.sessions === 1 ? "session" : "sessions"}</dd>
</dl>
${played + p.spec > 0 ? ptSplit(p.t, p.ct, p.spec) : ""}
${p.alive + p.dead > 0 ? `<p class="muted" style="margin:10px 0 0;font-size:14px">Alive for ${ptPct(p.alive, p.alive + p.dead)}% of their time on a team.</p>` : ""}
</section>
<section>
<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>
</section>
${st.kills + st.deaths > 0 ? `<section>
<h3>Stats</h3>
<div class="pt-stats">
<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>${st.rounds ? Math.round(st.damage / st.rounds) : "–"}</b><small>Damage per round</small></div>
<div><b>${st.mvps}</b><small>MVPs</small></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>
</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>
</section>` : ""}`;
}
const s = pt.staff;
if (!s) return out;
const reason = (x) => {
if (x.endKind === 0) return '<span class="tag green">On now</span>';
if (x.endKind === 2) return "Map change";
if (x.endKind === 3) return "Plugin unloaded";
if (x.endKind === 4) return '<span class="tag amber">Server crashed</span>';
return esc(PT_REASONS[x.reason] || x.reason || "Left");
};
return `${out}
${s.names.length ? `<section>
<h3>Names used</h3>
<ul class="pt-names">${s.names.map((n) => `<li><span><b>${esc(n.name)}</b> <span class="faint">×${n.times}</span></span><span class="muted">${fmtDate(n.firstSeen)} – ${fmtDate(n.lastSeen)}</span></li>`).join("")}</ul>
</section>` : ""}
${s.recent.length ? `<section>
<h3>Recent sessions</h3>
<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="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>${reason(x)}</div>
</li>`).join("")}</ul>
</section>` : ""}
${s.country || s.ping != null ? `<section>
<h3>Connection</h3>
<dl class="kv">
${s.country ? `<dt>Location</dt><dd>${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}</dd>` : ""}
${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 ---------- */
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; });