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
@@ -16,9 +16,11 @@ Node process; nginx reverse-proxies a subdomain to it.
- `/` — Overview: concurrent-player population graph (hand-rolled SVG, not - `/` — Overview: concurrent-player population graph (hand-rolled SVG, not
a charting library — scroll to zoom, drag to pan all the way back to a charting library — scroll to zoom, drag to pan all the way back to
2015-07-16, click any point for who was online then), a map-to-map 2015-07-16, click any point for who was online then), a map-to-map
population bar chart (same zoom/pan, pool of 150 maps), and a "recurring population chart (dumbbell-style: grey dot = population when the map
window" chart for questions like "who's online 6–15h every Tuesday" started, colored dot = population when it ended, green/red depending on
(pick days of week + time window + date range) whether it grew or shrank — same zoom/pan, pool of 150 maps), and a
"recurring window" chart for questions like "who's online 6–15h every
Tuesday" (pick days of week + time window + date range)
- `/players` — search players by current/previous name or SteamID (shows - `/players` — search players by current/previous name or SteamID (shows
which old name matched, if any), sortable by recent activity, name, or which old name matched, if any), sortable by recent activity, name, or
highest playtime highest playtime
@@ -27,9 +29,16 @@ Node process; nginx reverse-proxies a subdomain to it.
history (with the map(s) each session overlapped) history (with the map(s) each session overlapped)
- `/maps` — search maps by name - `/maps` — search maps by name
- `/maps/[mapname]` — every time period that map was played - `/maps/[mapname]` — every time period that map was played
- `/maps/period/[mapId]` — one specific occurrence: players present at any - `/maps/impact` — leaderboard ranking maps by how they tend to affect
point during it, votes cast (grouped by vote event, since a map could population (average start→end player-count change per occurrence,
theoretically see more than one), and prev/next map navigation average retention %, best/worst single session), toggleable between
impact and raw popularity (times played), rather than the plain
played-in-order list on `/maps`
- `/maps/period/[mapId]` — one specific occurrence: a zoomed-in population
timeline for just that session's window (padded a little on each side so
the transition in/out is visible), players present at any point during
it, votes cast (grouped by vote event, since a map could theoretically
see more than one), and prev/next map navigation
- `/countries` — every country with at least one player, sorted by player - `/countries` — every country with at least one player, sorted by player
count descending count descending
- `/countries/[code]` — players from that country, sortable by highest - `/countries/[code]` — players from that country, sortable by highest
@@ -37,7 +46,9 @@ Node process; nginx reverse-proxies a subdomain to it.
A `favicon.ico` in `app/` is picked up automatically by Next — no config needed. A `favicon.ico` in `app/` is picked up automatically by Next — no config needed.
Every feature from the original planning conversation is now built. Every feature from the original planning conversation is now built, including
the map-population-impact features (dumbbell chart, impact leaderboard,
per-session zoomed timeline) discussed most recently.
## Deploying on the Hetzner box ## Deploying on the Hetzner box
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { getMapPeriod } from '@/lib/queries';
import { parseDbDate } from '@/lib/dates';
import { computePopulationSeries } from '@/lib/populationSeries';
export async function GET(req: NextRequest) {
const mapIdParam = req.nextUrl.searchParams.get('mapId');
const mapId = Number(mapIdParam);
if (!Number.isInteger(mapId)) {
return NextResponse.json({ error: 'mapId query param is required' }, { status: 400 });
}
const period = await getMapPeriod(mapId);
if (!period) {
return NextResponse.json({ error: 'map session not found' }, { status: 404 });
}
const mapStart = parseDbDate(period.map_start_dt);
const mapEnd = period.map_end_dt ? parseDbDate(period.map_end_dt) : new Date();
// Pad a bit on either side of the session's own window so the chart
// shows the transition into and out of the map, not just a flat line
// bounded exactly at its edges. Capped relative to the session's own
// duration so a 10-minute map doesn't get a 10-minute pad that's as long
// as the map itself.
const durationMs = Math.max(1, mapEnd.getTime() - mapStart.getTime());
const padMs = Math.min(10 * 60_000, Math.max(1 * 60_000, durationMs * 0.25));
const rangeStart = new Date(mapStart.getTime() - padMs);
const now = new Date();
const rangeEnd = period.map_end_dt ? new Date(Math.min(mapEnd.getTime() + padMs, now.getTime())) : now;
// Smaller target than the general population chart (150) — map sessions
// are usually tens of minutes to a couple hours, and a coarser bucket
// would flatten exactly the kind of mid-session swing this view exists
// to surface.
const { bucketMinutes, points, mapBoundaries } = await computePopulationSeries(rangeStart, rangeEnd, {
targetPoints: 90,
});
return NextResponse.json({
bucketMinutes,
points,
mapBoundaries,
mapStart: mapStart.toISOString(),
mapEnd: period.map_end_dt ? mapEnd.toISOString() : null,
});
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { getMapImpactLeaderboard } from '@/lib/queries';
const PAGE_SIZE = 50;
export async function GET(req: NextRequest) {
const sortByParam = req.nextUrl.searchParams.get('sortBy');
const directionParam = req.nextUrl.searchParams.get('direction');
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
const sortBy = sortByParam === 'popularity' ? 'popularity' : 'impact';
// Impact defaults to ascending (worst/most population-harming first, the
// whole point of the feature); popularity defaults to descending (most
// played first). Either can be flipped explicitly via `direction`.
const direction = directionParam === 'asc' || directionParam === 'desc'
? directionParam
: sortBy === 'popularity' ? 'desc' : 'asc';
const maps = await getMapImpactLeaderboard(sortBy, direction, PAGE_SIZE, offset);
const hasMore = maps.length === PAGE_SIZE;
return NextResponse.json({ maps, hasMore });
}
@@ -1,27 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db'; import { computePopulationSeries } from '@/lib/populationSeries';
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;
}
// Picks a bucket size that keeps the number of points in a sane range
// (roughly 60-300 buckets) regardless of how wide the requested range is.
function pickBucketMinutes(rangeMinutes: number): number {
const target = 150;
const raw = Math.ceil(rangeMinutes / target);
const steps = [1, 5, 15, 30, 60, 120, 240, 360, 720, 1440]; // minutes
return steps.find((s) => s >= raw) ?? steps[steps.length - 1];
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams; const searchParams = req.nextUrl.searchParams;
@@ -39,95 +17,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'invalid or inverted start/end range' }, { status: 400 }); return NextResponse.json({ error: 'invalid or inverted start/end range' }, { status: 400 });
} }
// Convert to MySQL-native "YYYY-MM-DD HH:MM:SS" strings (in the DB's own const { bucketMinutes, points, mapBoundaries } = await computePopulationSeries(rangeStart, rangeEnd);
// 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 = pickBucketMinutes(rangeMinutes);
const points: { t: string; players: number; mapName: string | null }[] = [];
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 NextResponse.json({ bucketMinutes, points, mapBoundaries }); return NextResponse.json({ bucketMinutes, points, mapBoundaries });
} }
@@ -0,0 +1,25 @@
import Link from 'next/link';
import MapImpactLeaderboard from '@/components/MapImpactLeaderboard';
export default function MapsImpactPage() {
return (
<div className="space-y-6">
<Link href="/maps" className="text-sm text-ink-muted hover:text-accent transition-colors">
← Back to maps
</Link>
<div>
<h1 className="text-2xl text-ink mb-1">Map impact</h1>
<p className="text-sm text-ink-muted">
How each map tends to affect server population, not just how often it&apos;s played. Avg Δ is the
average change in concurrent players from when a map started to when it ended, across every time
it&apos;s been played — negative means the map tends to lose players, positive means it tends to
gain them. Retention is the average end-vs-start population as a percentage. Best/worst Δ are the
single best and single worst swings recorded for that map.
</p>
</div>
<MapImpactLeaderboard />
</div>
);
}
@@ -1,12 +1,24 @@
import Link from 'next/link';
import MapSearch from '@/components/MapSearch'; import MapSearch from '@/components/MapSearch';
export default function MapsPage() { export default function MapsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 className="text-2xl text-ink mb-1">Maps</h1> <h1 className="text-2xl text-ink mb-1">Maps</h1>
<p className="text-sm text-ink-muted">Search for a map to see when it was played.</p> <p className="text-sm text-ink-muted">Search for a map to see when it was played.</p>
</div> </div>
<Link
href="/maps/impact"
className="text-sm text-accent hover:underline flex items-center gap-1 shrink-0"
>
View maps by population impact
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
</div>
<MapSearch /> <MapSearch />
</div> </div>
); );
@@ -9,6 +9,7 @@ import {
import { attachAvatars } from '@/lib/steamApi'; import { attachAvatars } from '@/lib/steamApi';
import { diffMinutes } from '@/lib/dates'; import { diffMinutes } from '@/lib/dates';
import { formatMinutes, countryCodeToFlag } from '@/lib/steam'; import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
import MapSessionTimelineChart from '@/components/MapSessionTimelineChart';
export default async function MapPeriodDetailPage({ export default async function MapPeriodDetailPage({
params, params,
@@ -135,6 +136,8 @@ export default async function MapPeriodDetailPage({
</div> </div>
</div> </div>
<MapSessionTimelineChart mapId={mapId} />
{/* Players present + Map votes, side by side */} {/* Players present + Map votes, side by side */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="panel p-6"> <div className="panel p-6">
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { formatMinutes } from '@/lib/steam';
import { useInfiniteScroll } from '@/lib/useInfiniteScroll';
interface MapImpactRow {
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;
}
type SortBy = 'impact' | 'popularity';
type Direction = 'asc' | 'desc';
function ChangeBadge({ value }: { value: number | null }) {
if (value == null) return <span className="text-ink-faint">—</span>;
const rounded = Math.round(value * 10) / 10;
const color = rounded > 0 ? '#3FD37A' : rounded < 0 ? '#E0665C' : '#8A9691';
return (
<span className="mono" style={{ color }}>
{rounded > 0 ? '+' : ''}
{rounded}
</span>
);
}
export default function MapImpactLeaderboard() {
const [sortBy, setSortBy] = useState<SortBy>('impact');
const [direction, setDirection] = useState<Direction>('asc'); // impact default: worst (most population-harming) first
const [maps, setMaps] = useState<MapImpactRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const offsetRef = useRef(0);
const loadingMoreRef = useRef(false);
async function fetchPage(sBy: SortBy, dir: Direction, offset: number) {
const params = new URLSearchParams({ sortBy: sBy, direction: dir, offset: String(offset) });
const res = await fetch(`/api/maps/impact?${params.toString()}`);
return res.json() as Promise<{ maps: MapImpactRow[]; hasMore: boolean }>;
}
useEffect(() => {
setLoading(true);
offsetRef.current = 0;
fetchPage(sortBy, direction, 0).then((data) => {
setMaps(data.maps);
setHasMore(data.hasMore);
offsetRef.current = data.maps.length;
setLoading(false);
});
}, [sortBy, direction]);
async function loadMore() {
if (loadingMoreRef.current || !hasMore || loading) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const data = await fetchPage(sortBy, direction, offsetRef.current);
setMaps((prev) => [...prev, ...data.maps]);
setHasMore(data.hasMore);
offsetRef.current += data.maps.length;
setLoadingMore(false);
loadingMoreRef.current = false;
}
const sentinelRef = useInfiniteScroll(loadMore, !loading && hasMore);
function switchSort(next: SortBy) {
if (next === sortBy) return;
setSortBy(next);
// Each metric has its own sensible default direction.
setDirection(next === 'popularity' ? 'desc' : 'asc');
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex rounded-lg border border-base-border overflow-hidden text-sm">
<button
onClick={() => switchSort('impact')}
className={`px-3 py-2 transition-colors ${
sortBy === 'impact' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
}`}
>
Sort by impact
</button>
<button
onClick={() => switchSort('popularity')}
className={`px-3 py-2 transition-colors border-l border-base-border ${
sortBy === 'popularity' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
}`}
>
Sort by popularity
</button>
</div>
<button
onClick={() => setDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
className="text-xs text-ink-muted hover:text-accent transition-colors underline"
>
{sortBy === 'impact'
? direction === 'asc'
? 'Showing worst impact first'
: 'Showing best impact first'
: direction === 'desc'
? 'Showing most played first'
: 'Showing least played first'}
{' '}— flip
</button>
</div>
<div className="panel row-divide">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
<div>Map</div>
<div className="text-right w-16">Played</div>
<div className="text-right w-24">Avg Δ</div>
<div className="text-right w-20">Retention</div>
<div className="text-right w-28">Best / worst Δ</div>
</div>
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
) : maps.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No completed map sessions recorded yet.</div>
) : (
maps.map((m, i) => (
<Link
key={m.map_name}
href={`/maps/${encodeURIComponent(m.map_name)}`}
className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 items-center px-4 py-3 hover:bg-base transition-colors text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-ink-faint text-xs w-6 text-right shrink-0">{i + 1}</span>
<div className="min-w-0">
<div className="text-ink truncate">{m.map_name}</div>
<div className="text-xs text-ink-faint">{formatMinutes(m.total_minutes)} total</div>
</div>
</div>
<div className="mono text-ink-muted text-right w-16">{m.times_played}×</div>
<div className="text-right w-24">
<ChangeBadge value={m.avg_net_change} />
</div>
<div className="mono text-ink-muted text-right w-20">
{m.avg_retention_pct == null ? '—' : `${Math.round(m.avg_retention_pct)}%`}
</div>
<div className="text-right w-28 text-xs">
<ChangeBadge value={m.best_session_change} /> <span className="text-ink-faint">/</span>{' '}
<ChangeBadge value={m.worst_session_change} />
</div>
</Link>
))
)}
{!loading && maps.length > 0 && (
<div ref={sentinelRef} className="p-4 text-center">
{loadingMore ? (
<span className="text-xs text-ink-faint font-mono">loading more…</span>
) : !hasMore ? (
<span className="text-xs text-ink-faint">— end of list —</span>
) : null}
</div>
)}
</div>
</div>
);
}
@@ -11,6 +11,8 @@ interface MapPoint {
map_start_dt: string; map_start_dt: string;
map_end_dt: string | null; map_end_dt: string | null;
total_players: number; total_players: number;
start_players: number;
end_players: number;
} }
interface PlayerChip { interface PlayerChip {
@@ -120,22 +122,22 @@ export default function MapPopulationChart() {
const visiblePoints = points.slice(viewStartIdx, viewEndIdx); const visiblePoints = points.slice(viewStartIdx, viewEndIdx);
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT); const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM; const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const maxValue = Math.max(1, ...visiblePoints.map((p) => p.total_players)); // Scale is driven by the dumbbell endpoints (start/end concurrent
// population), not total_players — total_players is cumulative reach
// across the whole session and would otherwise dwarf the actual
// population swings the chart exists to show.
const maxValue = Math.max(1, ...visiblePoints.flatMap((p) => [p.start_players, p.end_players]));
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5; const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0; const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
const barWidth = Math.max(2, slotWidth * 0.6);
function barX(i: number) { function barX(i: number) {
return PAD_LEFT + i * slotWidth + (slotWidth - barWidth) / 2; return PAD_LEFT + i * slotWidth + slotWidth / 2;
} }
function barY(value: number) { function barY(value: number) {
const h = (value / axisMax) * plotHeight; const h = (value / axisMax) * plotHeight;
return PAD_TOP + (plotHeight - h); return PAD_TOP + (plotHeight - h);
} }
function barHeight(value: number) {
return Math.max(value > 0 ? 2 : 0, (value / axisMax) * plotHeight);
}
function indexForX(x: number): number { function indexForX(x: number): number {
if (slotWidth === 0) return 0; if (slotWidth === 0) return 0;
return Math.max(0, Math.min(visiblePoints.length - 1, Math.floor((x - PAD_LEFT) / slotWidth))); return Math.max(0, Math.min(visiblePoints.length - 1, Math.floor((x - PAD_LEFT) / slotWidth)));
@@ -236,6 +238,11 @@ export default function MapPopulationChart() {
{visiblePoints.length} of {points.length} {visiblePoints.length} of {points.length}
{reachedStart ? '' : '+'} maps {reachedStart ? '' : '+'} maps
</h2> </h2>
<p className="text-xs text-ink-faint mt-1">
Each line shows a map&apos;s population at the moment it started (grey dot) versus when it ended
(green = grew, red = shrank) — so a decline shows up on the map where it actually happened,
not the one that started low right after.
</p>
</div> </div>
<div className="flex items-center justify-between gap-4 mb-2"> <div className="flex items-center justify-between gap-4 mb-2">
@@ -244,7 +251,7 @@ export default function MapPopulationChart() {
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" /> <path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
</svg> </svg>
Scroll to zoom, drag to pan (all the way back to 2015), click a bar for who was online Scroll to zoom, drag to pan (all the way back to 2015), click a line for who was online
{fetchingMore && <span className="text-ink-faint">— loading more…</span>} {fetchingMore && <span className="text-ink-faint">— loading more…</span>}
</div> </div>
{isZoomed && ( {isZoomed && (
@@ -287,13 +294,6 @@ export default function MapPopulationChart() {
} }
}} }}
> >
<defs>
<linearGradient id="mapBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#5EE596" />
<stop offset="100%" stopColor="#2A9C5C" />
</linearGradient>
</defs>
{yTicks.map((v) => { {yTicks.map((v) => {
const y = barY(v); const y = barY(v);
return ( return (
@@ -306,19 +306,31 @@ export default function MapPopulationChart() {
); );
})} })}
{visiblePoints.map((p, i) => ( {visiblePoints.map((p, i) => {
<rect const x = barX(i);
key={p.map_id} const yStart = barY(p.start_players);
x={barX(i)} const yEnd = barY(p.end_players);
y={barY(p.total_players)} const grew = p.end_players > p.start_players;
width={barWidth} const shrank = p.end_players < p.start_players;
height={barHeight(p.total_players)} const endColor = grew ? '#3FD37A' : shrank ? '#E0665C' : '#8A9691';
rx={3} const active = hoverIndex === i;
fill="url(#mapBarGradient)" return (
fillOpacity={hoverIndex === i ? 1 : 0.8} <g key={p.map_id} opacity={active ? 1 : 0.85}>
style={hoverIndex === i ? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' } : undefined} <line
x1={x}
y1={yStart}
x2={x}
y2={yEnd}
stroke={endColor}
strokeWidth={active ? 2.5 : 1.75}
strokeLinecap="round"
style={active ? { filter: 'drop-shadow(0 0 5px rgba(63, 211, 122, 0.45))' } : undefined}
/> />
))} <circle cx={x} cy={yStart} r={active ? 4 : 3} fill="#8A9691" />
<circle cx={x} cy={yEnd} r={active ? 5 : 3.5} fill={endColor} />
</g>
);
})}
<line <line
x1={PAD_LEFT} x1={PAD_LEFT}
@@ -333,8 +345,12 @@ export default function MapPopulationChart() {
<div <div
className="absolute pointer-events-none" className="absolute pointer-events-none"
style={{ style={{
left: Math.min(width - 200, Math.max(0, barX(hoverIndex) - 60)), left: Math.min(width - 210, Math.max(0, barX(hoverIndex) - 60)),
top: barY(visiblePoints[hoverIndex].total_players) - 70, top:
Math.min(
barY(visiblePoints[hoverIndex].start_players),
barY(visiblePoints[hoverIndex].end_players),
) - 92,
background: '#121715', background: '#121715',
border: '1px solid #1F2723', border: '1px solid #1F2723',
borderRadius: 6, borderRadius: 6,
@@ -347,8 +363,21 @@ export default function MapPopulationChart() {
<div style={{ color: '#8A9691' }}> <div style={{ color: '#8A9691' }}>
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()} {new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
</div> </div>
<div style={{ color: '#3FD37A' }}> <div style={{ color: '#E4EBE7', marginTop: 2 }}>
{visiblePoints[hoverIndex].total_players} total players during this session {visiblePoints[hoverIndex].start_players} → {visiblePoints[hoverIndex].end_players} players
{(() => {
const delta = visiblePoints[hoverIndex].end_players - visiblePoints[hoverIndex].start_players;
const color = delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691';
return (
<span style={{ color, marginLeft: 6 }}>
({delta > 0 ? '+' : ''}
{delta})
</span>
);
})()}
</div>
<div style={{ color: '#8A9691', marginTop: 2 }}>
{visiblePoints[hoverIndex].total_players} distinct players during this session (reach)
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,231 @@
'use client';
import { useEffect, useState } from 'react';
import { useContainerWidth } from '@/lib/useContainerWidth';
interface Point {
t: string;
tEpoch: number;
players: number;
mapName: string | null;
}
const HEIGHT = 220;
const PAD_LEFT = 32;
const PAD_RIGHT = 8;
const PAD_TOP = 10;
const PAD_BOTTOM = 26;
export default function MapSessionTimelineChart({ mapId }: { mapId: number }) {
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
const [points, setPoints] = useState<Point[]>([]);
const [mapStart, setMapStart] = useState<number | null>(null);
const [mapEnd, setMapEnd] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hoverX, setHoverX] = useState<number | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/map-population/timeline?mapId=${mapId}`)
.then(async (res) => {
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
return res.json();
})
.then((data) => {
const pts: Point[] = (data.points as any[]).map((p: any) => ({ ...p, tEpoch: new Date(p.t).getTime() }));
setPoints(pts);
setMapStart(data.mapStart ? new Date(data.mapStart).getTime() : null);
setMapEnd(data.mapEnd ? new Date(data.mapEnd).getTime() : null);
})
.catch((e) => setError(e.message ?? 'something went wrong'))
.finally(() => setLoading(false));
}, [mapId]);
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
const minEpoch = points[0]?.tEpoch ?? 0;
const maxEpoch = points[points.length - 1]?.tEpoch ?? 1;
const span = Math.max(1, maxEpoch - minEpoch);
const maxValue = Math.max(1, ...points.map((p) => p.players));
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
function xForEpoch(epoch: number): number {
return PAD_LEFT + ((epoch - minEpoch) / span) * plotWidth;
}
function yForValue(v: number): number {
return PAD_TOP + (1 - v / axisMax) * plotHeight;
}
function epochForX(x: number): number {
if (plotWidth === 0) return minEpoch;
return minEpoch + ((x - PAD_LEFT) / plotWidth) * span;
}
function findNearestPoint(x: number): Point | null {
if (points.length === 0) return null;
const target = epochForX(x);
let nearest = points[0];
let best = Math.abs(points[0].tEpoch - target);
for (const p of points) {
const d = Math.abs(p.tEpoch - target);
if (d < best) {
best = d;
nearest = p;
}
}
return nearest;
}
const linePath = points
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xForEpoch(p.tEpoch).toFixed(1)} ${yForValue(p.players).toFixed(1)}`)
.join(' ');
const baselineY = PAD_TOP + plotHeight;
const areaPath =
points.length > 0
? `${linePath} L ${xForEpoch(points[points.length - 1].tEpoch).toFixed(1)} ${baselineY} L ${xForEpoch(points[0].tEpoch).toFixed(1)} ${baselineY} Z`
: '';
const hoverPoint = hoverX != null ? findNearestPoint(hoverX) : null;
// The two endpoints this chart exists to contrast: concurrent population
// right at map start vs. right at map end (or "now" if still running) —
// same definition as the dumbbell chart, found by nearest bucket to each
// boundary rather than a separate query.
const startPoint = mapStart != null ? findNearestPoint(xForEpoch(mapStart)) : null;
const endPoint = mapEnd != null ? findNearestPoint(xForEpoch(mapEnd)) : points[points.length - 1] ?? null;
const delta = startPoint && endPoint ? endPoint.players - startPoint.players : null;
const deltaColor = delta == null ? '#8A9691' : delta > 0 ? '#3FD37A' : delta < 0 ? '#E0665C' : '#8A9691';
const yTicks = [0, Math.round(axisMax / 2), axisMax];
return (
<div className="panel p-6">
<div className="mb-4">
<div className="stat-label mb-1">This session, zoomed in</div>
<h2 className="text-lg text-ink">Population during this map</h2>
{startPoint && endPoint && (
<p className="text-sm text-ink-muted mt-1">
Started with <span className="text-ink">{startPoint.players}</span> players, ended with{' '}
<span className="text-ink">{endPoint.players}</span>
<span style={{ color: deltaColor }}>
{' '}
({delta != null && delta > 0 ? '+' : ''}
{delta})
</span>
</p>
)}
</div>
{error && <div className="text-sm text-red-400 mb-4">{error}</div>}
<div ref={containerRef} className="w-full relative" style={{ height: HEIGHT }}>
{loading ? (
<div className="h-full flex items-center justify-center text-ink-faint text-sm font-mono">loading…</div>
) : points.length === 0 ? (
<div className="h-full flex items-center justify-center text-ink-muted text-sm">No data for this session.</div>
) : (
<svg
width={width}
height={HEIGHT}
style={{ cursor: 'crosshair' }}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setHoverX(e.clientX - rect.left);
}}
onMouseLeave={() => setHoverX(null)}
>
<defs>
<linearGradient id="sessionAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3FD37A" stopOpacity="0.28" />
<stop offset="100%" stopColor="#3FD37A" stopOpacity="0" />
</linearGradient>
</defs>
{yTicks.map((v) => {
const y = yForValue(v);
return (
<g key={v}>
<line x1={PAD_LEFT} y1={y} x2={width - PAD_RIGHT} y2={y} stroke="#2A332E" />
<text x={PAD_LEFT - 6} y={y + 3} textAnchor="end" fontSize={11} fill="#8A9691">
{v}
</text>
</g>
);
})}
{/* map-session boundaries */}
{mapStart != null && mapStart >= minEpoch && mapStart <= maxEpoch && (
<line
x1={xForEpoch(mapStart)}
y1={PAD_TOP}
x2={xForEpoch(mapStart)}
y2={PAD_TOP + plotHeight}
stroke="#8A9691"
strokeDasharray="3 3"
/>
)}
{mapEnd != null && mapEnd >= minEpoch && mapEnd <= maxEpoch && (
<line
x1={xForEpoch(mapEnd)}
y1={PAD_TOP}
x2={xForEpoch(mapEnd)}
y2={PAD_TOP + plotHeight}
stroke="#8A9691"
strokeDasharray="3 3"
/>
)}
<text x={xForEpoch(minEpoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="start" fontSize={10} fill="#8A9691">
{new Date(minEpoch).toLocaleTimeString()}
</text>
<text x={xForEpoch(maxEpoch)} y={HEIGHT - PAD_BOTTOM + 16} textAnchor="end" fontSize={10} fill="#8A9691">
{new Date(maxEpoch).toLocaleTimeString()}
</text>
<line x1={PAD_LEFT} y1={PAD_TOP + plotHeight} x2={width - PAD_RIGHT} y2={PAD_TOP + plotHeight} stroke="#4C5652" />
<path d={areaPath} fill="url(#sessionAreaGradient)" stroke="none" />
<path
d={linePath}
fill="none"
stroke="#3FD37A"
strokeWidth={2}
strokeLinejoin="round"
style={{ filter: 'drop-shadow(0 0 5px rgba(63, 211, 122, 0.45))' }}
/>
{startPoint && (
<circle cx={xForEpoch(startPoint.tEpoch)} cy={yForValue(startPoint.players)} r={4} fill="#8A9691" stroke="#0B0F0E" strokeWidth={1.5} />
)}
{endPoint && (
<circle cx={xForEpoch(endPoint.tEpoch)} cy={yForValue(endPoint.players)} r={4} fill={deltaColor} stroke="#0B0F0E" strokeWidth={1.5} />
)}
{hoverPoint && (
<circle cx={xForEpoch(hoverPoint.tEpoch)} cy={yForValue(hoverPoint.players)} r={4} fill="#3FD37A" stroke="#0B0F0E" strokeWidth={1.5} />
)}
</svg>
)}
{hoverPoint && hoverX != null && !loading && (
<div
className="absolute pointer-events-none"
style={{
left: Math.min(Math.max(width, 200) - 170, Math.max(0, hoverX + 12)),
top: Math.max(0, yForValue(hoverPoint.players) - 56),
background: '#121715',
border: '1px solid #1F2723',
borderRadius: 6,
fontSize: 12,
padding: '8px 10px',
whiteSpace: 'nowrap',
}}
>
<div style={{ color: '#8A9691' }}>{new Date(hoverPoint.t).toLocaleTimeString()}</div>
<div style={{ color: '#3FD37A', marginTop: 2 }}>{hoverPoint.players} players</div>
</div>
)}
</div>
</div>
);
}
@@ -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_start_dt: string;
map_end_dt: string | null; map_end_dt: string | null;
total_players: number; 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[]> { export async function getMapPopulationSeries(limit = 40, before?: string): Promise<MapPopulationPoint[]> {
// Total DISTINCT players who were connected at any point during each map's // One data point per map, fetched most-recent-first (for the LIMIT), then
// full session (not just at its start) — one data point per map. Fetched // the caller should reverse it back to chronological order for display.
// 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 // `before` (a DB-formatted datetime string) lets the caller page further
// back into history than the initial batch, for pan-to-load-more charts. // back into history than the initial batch, for pan-to-load-more charts.
const rows = await query<MapPopulationPoint>( 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 (SELECT COUNT(DISTINCT s.steamid) FROM playtime_display_sessions s
WHERE s.session_start_dt < COALESCE(${NEXT_MAP_START('m')}, NOW()) 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) 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 FROM playtime_display_map_history m
WHERE ${REAL_MAP_PERIOD} WHERE ${REAL_MAP_PERIOD}
${before ? 'AND m.map_start_dt < ?' : ''} ${before ? 'AND m.map_start_dt < ?' : ''}
@@ -126,6 +140,14 @@ export async function getMapPopulationSeries(limit = 40, before?: string): Promi
return rows.reverse(); 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 { export interface CountrySummary {
current_country: string | null; // null represents the "Unknown" bucket current_country: string | null; // null represents the "Unknown" bucket
player_count: number; player_count: number;
File diff suppressed because it is too large Load Diff