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 { // 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( `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( `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 }; }