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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user