Busiest hours and the heatmap averaged over 30 days (4 weeks) even when recording began yesterday; they now use only the days since it began, and say so. Bars capped their scale at 1, so a leader with 0.5 h got half a bar. The hours tile keeps a decimal under 10, and K/D shows - with no kills or deaths yet.
429 lines
24 KiB
JavaScript
429 lines
24 KiB
JavaScript
// 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";
|
||
// 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 `<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>${s.hours < 10 ? s.hours.toFixed(1) : 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>`;
|
||
}
|
||
|
||
// "the last 30 days", or how long the recording's been going when that's shorter.
|
||
function ptDaysText(n) {
|
||
if (n >= 30) return "the last 30 days";
|
||
if (n >= 28) return "the last 4 weeks";
|
||
return n > 1 ? `the ${n} days since recording began` : "today, the first day recorded";
|
||
}
|
||
|
||
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 (bots don't count), averaged over ${ptDaysText(o.byHourDays)}, 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, over ${ptDaysText(o.heatDays)}, 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; });
|