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);
}
@@ -0,0 +1,147 @@
import { query } from '@/lib/db';
import { parseDbDate } from '@/lib/dates';
import { formatDbDateTime } from '@/lib/timezone';
interface SessionRow {
session_start_dt: string;
session_end_dt: string | null;
}
interface MapRow {
map_id: number;
map_name: string;
map_start_dt: string;
}
export interface PopulationSeriesPoint {
t: string;
players: number;
mapName: string | null;
}
export interface PopulationMapBoundary {
mapId: number;
mapName: string;
t: string;
}
export interface PopulationSeriesResult {
bucketMinutes: number;
points: PopulationSeriesPoint[];
mapBoundaries: PopulationMapBoundary[];
}
// Picks a bucket size that keeps the number of points in a sane range
// (roughly 60-300 buckets by default) regardless of how wide the requested
// range is. `targetPoints` can be lowered for a zoomed-in view that wants
// finer resolution over a short span (e.g. a single map's session).
export function pickBucketMinutes(rangeMinutes: number, targetPoints = 150): number {
const raw = Math.ceil(rangeMinutes / targetPoints);
const steps = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]; // minutes
return steps.find((s) => s >= raw) ?? steps[steps.length - 1];
}
/**
* Computes a bucketed concurrent-player time series between two instants,
* plus the map-change boundaries that fall inside that window. Shared by
* the general population-over-time chart and the per-session zoomed
* timeline (which calls this with a narrow range and a smaller
* targetPoints so a ~30-90 minute map session still gets fine buckets).
*/
export async function computePopulationSeries(
rangeStart: Date,
rangeEnd: Date,
opts: { bucketMinutes?: number; targetPoints?: number } = {},
): Promise<PopulationSeriesResult> {
// Convert to MySQL-native "YYYY-MM-DD HH:MM:SS" strings (in the DB's own
// timezone) rather than passing raw ISO-Z strings as query parameters —
// MySQL DATETIME columns don't reliably parse the T/Z/milliseconds ISO
// format, which was silently dropping/mismatching rows in this fetch.
const formattedEnd = formatDbDateTime(rangeEnd);
const formattedStart = formatDbDateTime(rangeStart);
// Generous safety margin so the map fetch below doesn't scan the entire
// history table as it grows over months/years — 24h is far longer than
// any realistic single map duration, so this can never miss the map that
// was actually active at rangeStart.
const formattedLookback = formatDbDateTime(new Date(rangeStart.getTime() - 24 * 60 * 60 * 1000));
const [sessionRows, mapRows] = await Promise.all([
query<SessionRow>(
`SELECT session_start_dt, session_end_dt
FROM playtime_display_sessions
WHERE session_start_dt < ?
AND (session_end_dt IS NULL OR session_end_dt > ?)`,
[formattedEnd, formattedStart],
),
// map_end_dt was dropped from the schema (redundant with, and
// occasionally out of sync with, the next row's map_start_dt). Filter
// tickrate-restart artifacts (<2min) using the gap to the next map;
// the currently-running map (no next row yet) always passes.
query<MapRow>(
`SELECT m.map_id, m.map_name, m.map_start_dt
FROM playtime_display_map_history m
WHERE m.map_start_dt < ?
AND m.map_start_dt > ?
AND (
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt) IS NULL
OR TIMESTAMPDIFF(
MINUTE, m.map_start_dt,
(SELECT MIN(m2.map_start_dt) FROM playtime_display_map_history m2 WHERE m2.map_start_dt > m.map_start_dt)
) >= 2
)
ORDER BY m.map_start_dt`,
[formattedEnd, formattedLookback],
),
]);
const sessions = sessionRows.map((r) => ({
start: parseDbDate(r.session_start_dt),
end: r.session_end_dt ? parseDbDate(r.session_end_dt) : new Date(), // still-open session heartbeat, see plugin notes
}));
const maps = mapRows.map((r) => ({
mapId: r.map_id,
mapName: r.map_name,
start: parseDbDate(r.map_start_dt),
}));
// `maps` is sorted ascending by start_dt (from the ORDER BY in the query
// above). Deliberately does NOT check each row's own end time — a map
// whose map_end_dt never got closed (e.g. OnMapEnd not firing for a
// specific transition, due to a crash/restart/admin changelevel) would
// otherwise look "still running" forever and permanently shadow every
// real map that came after it. Instead: whichever map most recently
// started by time t is what was playing then, regardless of whether its
// own end timestamp was ever reliably recorded.
function mapNameAt(t: Date): string | null {
let result: string | null = null;
for (const m of maps) {
if (m.start <= t) {
result = m.mapName;
} else {
break;
}
}
return result;
}
const rangeMinutes = (rangeEnd.getTime() - rangeStart.getTime()) / 60000;
const bucketMinutes = opts.bucketMinutes ?? pickBucketMinutes(rangeMinutes, opts.targetPoints);
const points: PopulationSeriesPoint[] = [];
for (let t = rangeStart.getTime(); t < rangeEnd.getTime(); t += bucketMinutes * 60000) {
const bucketStart = new Date(t);
const bucketEnd = new Date(t + bucketMinutes * 60000);
const count = sessions.filter((s) => s.start < bucketEnd && s.end > bucketStart).length;
points.push({ t: bucketStart.toISOString(), players: count, mapName: mapNameAt(bucketStart) });
}
// Map-change boundaries within the visible range, for drawing vertical
// separator lines on the chart — only the ones whose start actually
// falls inside [rangeStart, rangeEnd).
const mapBoundaries = maps
.filter((m) => m.start >= rangeStart && m.start < rangeEnd)
.map((m) => ({ mapId: m.mapId, mapName: m.mapName, t: m.start.toISOString() }));
return { bucketMinutes, points, mapBoundaries };
}
@@ -101,13 +101,19 @@ export interface MapPopulationPoint {
map_start_dt: string;
map_end_dt: string | null;
total_players: number;
// Population at the instant the map started, and at the instant it ended
// (or "now" for the still-running map) — the two endpoints of the
// dumbbell. Kept separate from total_players (which is reach — everyone
// who was ever connected at some point during the map, regardless of
// when) so the chart can show *when* a decline actually happened instead
// of implying it was caused by whichever map came right after.
start_players: number;
end_players: number;
}
export async function getMapPopulationSeries(limit = 40, before?: string): Promise<MapPopulationPoint[]> {
// Total DISTINCT players who were connected at any point during each map's
// full session (not just at its start) — one data point per map. Fetched
// most-recent-first (for the LIMIT), then the caller should reverse it
// back to chronological order for display.
// One data point per map, fetched most-recent-first (for the LIMIT), then
// the caller should reverse it back to chronological order for display.
// `before` (a DB-formatted datetime string) lets the caller page further
// back into history than the initial batch, for pan-to-load-more charts.
const rows = await query<MapPopulationPoint>(
@@ -115,7 +121,15 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW())
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
) AS total_players
) AS total_players,
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt <= m.map_start_dt
AND (s.session_end_dt IS NULL OR s.session_end_dt > m.map_start_dt)
) AS start_players,
(SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt <= COALESCE(${NEXT_MAP_START('m')}, NOW())
AND (s.session_end_dt IS NULL OR s.session_end_dt > COALESCE(${NEXT_MAP_START('m')}, NOW()))
) AS end_players
FROM playtime_display_map_history m
WHERE ${REAL_MAP_PERIOD}
${before ? 'AND m.map_start_dt < ?' : ''}
@@ -126,6 +140,14 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi
return rows.reverse();
}
// getMapImpactLeaderboard moved to lib/mapImpact.ts — the SQL version here
// used per-row correlated subqueries (effectively O(map_rows * sessions))
// which was fine on a small dataset but became extremely slow at real
// production size. Re-exported from here so existing imports don't need to
// change. See lib/mapImpact.ts for the full explanation.
export { getMapImpactLeaderboard } from '@/lib/mapImpact';
export type { MapImpactSummary } from '@/lib/mapImpact';
export interface CountrySummary {
current_country: string | null; // null represents the "Unknown" bucket
player_count: number;