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,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 { 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;
}
// 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];
}
import { computePopulationSeries } from '@/lib/populationSeries';
export async function GET(req: NextRequest) {
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 });
}
// 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 = 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() }));
const { bucketMinutes, points, mapBoundaries } = await computePopulationSeries(rangeStart, rangeEnd);
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,11 +1,23 @@
import Link from 'next/link';
import MapSearch from '@/components/MapSearch';
export default function MapsPage() {
return (
<div className="space-y-6">
<div>
<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>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<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>
</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 />
</div>
@@ -9,6 +9,7 @@ import {
import { attachAvatars } from '@/lib/steamApi';
import { diffMinutes } from '@/lib/dates';
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
import MapSessionTimelineChart from '@/components/MapSessionTimelineChart';
export default async function MapPeriodDetailPage({
params,
@@ -135,6 +136,8 @@ export default async function MapPeriodDetailPage({
</div>
</div>
<MapSessionTimelineChart mapId={mapId} />
{/* Players present + Map votes, side by side */}
<div className="grid md:grid-cols-2 gap-6">
<div className="panel p-6">