updating the playtime frontend with claude only code to replace the one graph with a dumb-bell graph, having each individual map show its players at beginning versus end and also the overall impact of maps + their popularity

This commit is contained in:
jenz
2026-10-02 14:43:45 +02:00
parent 6019e6854d
commit 8a76454784
14 changed files with 3300 additions and 159 deletions
@@ -0,0 +1,248 @@
import { query } from '@/lib/db';
import { parseDbDate } from '@/lib/dates';
// --- Why this exists ---------------------------------------------------
//
// The original implementation of this leaderboard did everything in SQL:
// for every map_history row it ran correlated subqueries to (a) find the
// next map's start time (a MIN(...) scan over the whole table) and (b)
// count DISTINCT steamids with a session overlapping a specific instant (a
// scan over the whole sessions table) — twice per row (once for the
// occurrence's start, once for its end). That's roughly
// O(maps_rows * (maps_rows + sessions_rows)) of work done by MySQL on every
// request, which is what made /maps/impact extremely slow once both tables
// grew to real production size.
//
// This version fetches the two tables' raw columns once (cheap — no joins,
// no correlated subqueries) and does the equivalent computation in Node
// with a sweep-line: sort all session start/end instants once, then
// answering "how many sessions were active at instant t" is a binary
// search instead of a full table scan. That turns the whole computation
// into roughly O((maps + sessions) * log(sessions)), done once, and it's
// then cached (see getMapImpactLeaderboard below) so most requests don't
// even pay that.
interface RawMapRow {
map_id: number;
map_name: string;
map_start_dt: string;
}
interface RawSessionRow {
session_start_dt: string;
session_end_dt: string | null;
}
export interface MapImpactSummary {
map_name: string;
times_played: number;
total_minutes: number;
last_played: string;
avg_net_change: number | null;
avg_retention_pct: number | null;
best_session_change: number | null;
worst_session_change: number | null;
}
// Binary search: count of values in a sorted ascending array that are <= target.
function countLessOrEqual(sorted: number[], target: number): number {
let lo = 0;
let hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (sorted[mid] <= target) lo = mid + 1;
else hi = mid;
}
return lo;
}
async function computeMapImpactStats(): Promise<MapImpactSummary[]> {
const [mapRows, sessionRows] = await Promise.all([
query<RawMapRow>(
`SELECT map_id, map_name, map_start_dt
FROM playtime_display_map_history
ORDER BY map_start_dt ASC`,
),
query<RawSessionRow>(
`SELECT session_start_dt, session_end_dt
FROM playtime_display_sessions`,
),
]);
// Sweep-line population lookup: a session is "active at instant t" iff
// session_start_dt <= t AND (session_end_dt IS NULL OR session_end_dt > t)
// — same semantics the old SQL used. count(starts <= t) counts every
// session that had begun by t; subtracting count(ends <= t) removes
// exactly those that have also already ended by t, leaving the active
// count. Sessions with a NULL end never appear in `ends`, so they count
// as active forever once started — matching "IS NULL" in the original.
const starts: number[] = [];
const ends: number[] = [];
for (const s of sessionRows) {
starts.push(parseDbDate(s.session_start_dt).getTime());
if (s.session_end_dt) ends.push(parseDbDate(s.session_end_dt).getTime());
}
starts.sort((a, b) => a - b);
ends.sort((a, b) => a - b);
function populationAt(t: number): number {
return countLessOrEqual(starts, t) - countLessOrEqual(ends, t);
}
// Tickrate changes sometimes require a very quick map restart, producing
// a second map_history row for the same map seconds later — treat
// anything under 2 minutes as that artifact rather than a real play
// (same threshold/semantics as REAL_MAP_PERIOD elsewhere in lib/queries.ts).
const MIN_REAL_PERIOD_MS = 2 * 60_000;
const perMap = new Map<
string,
{
times_played: number;
total_minutes: number;
last_played: number;
netChangeSum: number;
netChangeCount: number;
retentionSum: number;
retentionCount: number;
best: number;
worst: number;
}
>();
for (let i = 0; i < mapRows.length; i++) {
const m = mapRows[i];
const next = mapRows[i + 1]; // next row chronologically, unfiltered — matches NEXT_MAP_START's definition
if (!next) continue; // currently-running map: no known end yet, excluded from impact calc
const startMs = parseDbDate(m.map_start_dt).getTime();
const nextStartMs = parseDbDate(next.map_start_dt).getTime();
const durationMs = nextStartMs - startMs;
if (durationMs < MIN_REAL_PERIOD_MS) continue; // tickrate-restart artifact, not a real occurrence
const startPlayers = populationAt(startMs);
const endPlayers = populationAt(nextStartMs);
const netChange = endPlayers - startPlayers;
const retentionPct = startPlayers > 0 ? (endPlayers / startPlayers) * 100 : null;
let agg = perMap.get(m.map_name);
if (!agg) {
agg = {
times_played: 0,
total_minutes: 0,
last_played: 0,
netChangeSum: 0,
netChangeCount: 0,
retentionSum: 0,
retentionCount: 0,
best: -Infinity,
worst: Infinity,
};
perMap.set(m.map_name, agg);
}
agg.times_played += 1;
agg.total_minutes += durationMs / 60000;
if (startMs > agg.last_played) agg.last_played = startMs;
agg.netChangeSum += netChange;
agg.netChangeCount += 1;
if (retentionPct != null) {
agg.retentionSum += retentionPct;
agg.retentionCount += 1;
}
if (netChange > agg.best) agg.best = netChange;
if (netChange < agg.worst) agg.worst = netChange;
}
const result: MapImpactSummary[] = [];
for (const [map_name, agg] of perMap) {
result.push({
map_name,
times_played: agg.times_played,
total_minutes: Math.round(agg.total_minutes),
last_played: new Date(agg.last_played).toISOString(),
avg_net_change: agg.netChangeCount > 0 ? agg.netChangeSum / agg.netChangeCount : null,
avg_retention_pct: agg.retentionCount > 0 ? agg.retentionSum / agg.retentionCount : null,
best_session_change: agg.netChangeCount > 0 ? agg.best : null,
worst_session_change: agg.netChangeCount > 0 ? agg.worst : null,
});
}
return result;
}
// --- Cache ---------------------------------------------------------------
//
// Recomputing is a couple of DB round trips plus an O(n log n) sweep — fast
// compared to the old query, but there's no reason to redo it on every
// single page view. Kept alive for 30 minutes, with stale-while-revalidate:
// a stale cache is served immediately (never emptied) while a background
// refresh runs, and only swapped in once the new data is actually ready.
// A single in-flight recompute is shared by any requests that arrive while
// it's running, rather than each kicking off its own.
const CACHE_TTL_MS = 30 * 60 * 1000;
let cachedData: MapImpactSummary[] | null = null;
let cachedAt = 0;
let pending: Promise<MapImpactSummary[]> | null = null;
function refresh(): Promise<MapImpactSummary[]> {
if (!pending) {
pending = computeMapImpactStats()
.then((data) => {
cachedData = data;
cachedAt = Date.now();
return data;
})
.finally(() => {
pending = null;
});
}
return pending;
}
async function getMapImpactStatsCached(): Promise<MapImpactSummary[]> {
const isStale = !cachedData || Date.now() - cachedAt > CACHE_TTL_MS;
if (!cachedData) {
// Nothing to serve yet (first request since process start) — must wait.
return refresh();
}
if (isStale) {
// Serve what we have right now; let the refresh happen in the
// background so this (and any concurrent) request doesn't have to
// wait on it. The cache is never cleared before the new data exists.
refresh();
}
return cachedData;
}
/**
* Ranks maps by how they tend to affect server population, rather than
* just how often they're played. For every completed occurrence of a map
* (excluding the one currently running, since its "end" isn't known yet),
* net_change = population-at-map-end minus population-at-map-start, and
* retention_pct = end/start as a percentage (null when the map started
* with 0 players). Those per-occurrence numbers are averaged (and
* min/max'd) per map name — see computeMapImpactStats above.
*
* `sortBy` picks which story the leaderboard tells: 'impact' (average net
* change — the maps that tend to grow or shrink the population) or
* 'popularity' (times played — pure frequency, unrelated to impact). The
* underlying per-map stats are cached (see above); this just sorts/slices
* them per request, which is cheap even for hundreds of maps.
*/
export async function getMapImpactLeaderboard(
sortBy: 'impact' | 'popularity',
direction: 'asc' | 'desc',
limit = 50,
offset = 0,
): Promise<MapImpactSummary[]> {
const all = await getMapImpactStatsCached();
const sign = direction === 'asc' ? 1 : -1;
const sorted = [...all].sort((a, b) => {
const av = sortBy === 'popularity' ? a.times_played : a.avg_net_change ?? 0;
const bv = sortBy === 'popularity' ? b.times_played : b.avg_net_change ?? 0;
if (av !== bv) return sign * (av - bv);
return b.times_played - a.times_played; // stable tiebreaker, matches old ORDER BY ..., times_played DESC
});
return sorted.slice(offset, offset + limit);
}