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