// 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: '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";
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 `${avatar(p.steamid, p.name)}
${esc(p.name || "Unknown")}${p.rank ? rankTag(p.rank) : ""}${esc(p.steamid)}
`;
}
function ptBar(value, max, label, fill) {
return ``;
}
// 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 `${seg("t", t)}${seg("ct", ct)}${seg("spec", spec)}
- T ${ptH(t)}
- CT ${ptH(ct)}
- Spectating ${ptH(spec)}
`;
}
/* ---------- 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${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 ? `
| # | Player | Time played | T / CT | K/D |
${d.leaders.map((p, i) => `
| ${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)} |
`).join("")}
` : 'Nobody has played on a team in this time.
'}
`;
}
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 `
Busiest hours
Players on T or CT, averaged over the last 30 days, in your time zone.
${ptColumns(vals, { note: o.byHour[best] ? `Busiest at ${ptPad(best)}:00, with ${o.byHour[best]} players on average.` : "" })}
`;
}
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 `
Players per day
Different players who joined each day, last 30 days.
${ptColumns(vals, { fill: "var(--ct)", whole: true })}
`;
}
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
Average players on T or CT by day and hour, last 4 weeks, in your time zone.
| ${o.heat[0].map((_, h) => `${h % 3 === 0 ? ptPad(h) : ""} | `).join("")}
${o.heat.map((row, wd) => `| ${PT_WEEKDAYS[wd]} | ${row.map((v, h) => ` | `).join("")}
`).join("")}
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 ? `
| Map | Hours played | Players |
${o.maps.map((m) => `| ${esc(m.map)} | ${ptBar(m.hours, max, m.hours.toLocaleString(), "var(--t)")} | ${m.players} |
`).join("")}
` : '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 `
${segmented("ptRecords", d.recordsPeriod, PT_PERIODS)}
${PT_RECORDS.map(([key, label]) => `
${label}
${r[key].length ? `
${r[key].map((p) => `- ${esc(p.name || p.steamid)}${p.count.toLocaleString()}
`).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 ? `
| Player | Online for | Team | Ping | From |
${a.online.map((p) => `
| ${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'} |
`).join("")}
${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 ? `
| Staff | On T or CT | Spectating | Last seen |
${a.staff.map((s) => `
| ${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'} |
`).join("")}
` : '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 ? `
| Reason | Disconnects | Stayed |
${a.reasons.map((r) => `| ${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. |
`).join("")}
` : '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 ? `
| Country | Players | Hours |
${a.countries.map((c) => `| ${esc(c.name || c.iso)} ${esc(c.iso)} | ${ptBar(c.players, max, c.players, "var(--ct)")} | ${Math.round(c.hours).toLocaleString()} |
`).join("")}
${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: 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 = `
Playtime
${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."}
${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
${p.days.map((d) => ``).join("")}
${st.kills + st.deaths > 0 ? `
Stats
${ptKD(st.kills, st.deaths)}K/D
${ptPct(st.headshots, st.kills)}%Headshot kills
${st.rounds ? Math.round(st.damage / st.rounds) : "–"}Damage per round
${st.mvps}MVPs
${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("")}
` : ""}`;
}
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.names.map((n) => `- ${esc(n.name)} ×${n.times}${fmtDate(n.firstSeen)} – ${fmtDate(n.lastSeen)}
`).join("")}
` : ""}
${s.recent.length ? `
Recent sessions
${s.recent.map((x) => `-
${fmtDate(x.started)}${ptDur(x.seconds)}
${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` : ""}
${reason(x)}
`).join("")}
` : ""}
${s.country || s.ping != null ? `
Connection
${s.country ? `- Location
- ${s.city ? `${esc(s.city)}, ` : ""}${esc(s.countryName || s.country)}
` : ""}
${s.ping != null ? `- Average ping
- ${s.ping} ms last 30 days
` : ""}
${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; });