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.
This commit is contained in:
parent
6d0fc6e463
commit
7004945d49
9 changed files with 296 additions and 27 deletions
|
|
@ -157,6 +157,12 @@ function errorBox(err) { return `<div class="empty">${esc(err.message)}</div>`;
|
|||
|
||||
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 = `<div class="toast">${esc(msg)}</div>`;
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</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>
|
||||
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)}`;
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
|||
</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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue