initial commit of the paytime session frontend

This commit is contained in:
jenz
2026-08-22 14:53:27 +02:00
parent 4ad037bd13
commit 9e8e8378a2
43 changed files with 2936 additions and 0 deletions
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlayersPresentDuringMap } from '@/lib/queries';
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { steam2ToSteam64 } from '@/lib/steam';
export async function GET(req: NextRequest) {
const mapIdParam = req.nextUrl.searchParams.get('mapId');
const mapId = Number(mapIdParam);
if (!mapIdParam || !Number.isInteger(mapId)) {
return NextResponse.json({ error: 'mapId query param is required' }, { status: 400 });
}
const players = await getPlayersPresentDuringMap(mapId);
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
const result = players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { steamid: p.steamid, name: p.current_name, avatarUrl };
});
return NextResponse.json({ players: result });
}
@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { getMapPopulationSeries } from '@/lib/queries';
export async function GET(req: NextRequest) {
const limitParam = req.nextUrl.searchParams.get('limit');
const limit = Math.min(Math.max(Number(limitParam) || 40, 1), 200);
const points = await getMapPopulationSeries(limit);
return NextResponse.json({ points });
}
@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { searchMapNames } from '@/lib/queries';
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
const maps = await searchMapNames(q);
return NextResponse.json({ maps });
}
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
interface PlayerRow {
steamid: string;
current_name: string;
current_country: string | null;
matched_name: string | null;
total_minutes: number | null;
}
const TOTAL_MINUTES_SUBQUERY = `
(SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes)
FROM playtime_display_sessions s
WHERE s.steamid = p.steamid
ORDER BY s.session_id DESC
LIMIT 1) AS total_minutes
`;
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim() ?? '';
const sort = req.nextUrl.searchParams.get('sort') ?? 'recent';
const orderBy =
sort === 'playtime'
? 'total_minutes IS NULL, total_minutes DESC'
: sort === 'name'
? 'p.current_name'
: 'p.last_seen DESC';
// No search term: just list players, ordered per the chosen sort.
if (!q) {
const rows = await query<PlayerRow>(
`SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name,
${TOTAL_MINUTES_SUBQUERY}
FROM playtime_display_players p
ORDER BY ${orderBy}
LIMIT 50`,
);
return NextResponse.json({ players: rows });
}
// Search matches steamid directly, or current/previous names.
const like = `%${q}%`;
const rows = await query<PlayerRow>(
`SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name,
${TOTAL_MINUTES_SUBQUERY}
FROM playtime_display_players p
LEFT JOIN playtime_display_name_history h
ON h.steamid = p.steamid AND h.player_name LIKE ?
WHERE p.steamid LIKE ?
OR p.current_name LIKE ?
OR EXISTS (
SELECT 1 FROM playtime_display_name_history h2
WHERE h2.steamid = p.steamid AND h2.player_name LIKE ?
)
GROUP BY p.steamid
ORDER BY ${orderBy}
LIMIT 50`,
[like, like, like, like],
);
return NextResponse.json({ players: rows });
}
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { getCurrentPlayers } from '@/lib/queries';
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { steam2ToSteam64 } from '@/lib/steam';
export async function GET() {
const players = await getCurrentPlayers(3);
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
const result = players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { steamid: p.steamid, name: p.current_name, avatarUrl };
});
return NextResponse.json({ players: result, count: result.length });
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlayersAtTime } from '@/lib/queries';
import { getSteamSummariesBatch, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
import { steam2ToSteam64 } from '@/lib/steam';
import { formatDbDateTime } from '@/lib/timezone';
export async function GET(req: NextRequest) {
const at = req.nextUrl.searchParams.get('at');
if (!at) {
return NextResponse.json({ error: 'at query param is required' }, { status: 400 });
}
const atDate = new Date(at);
if (Number.isNaN(atDate.getTime())) {
return NextResponse.json({ error: 'invalid at timestamp' }, { status: 400 });
}
const players = await getPlayersAtTime(formatDbDateTime(atDate));
const steam64ByPlayer = new Map<string, string>();
for (const p of players) {
const id64 = steam2ToSteam64(p.steamid);
if (id64) steam64ByPlayer.set(p.steamid, id64);
}
const avatars = await getSteamSummariesBatch([...steam64ByPlayer.values()]);
const result = players.map((p) => {
const id64 = steam64ByPlayer.get(p.steamid);
const avatarUrl = (id64 && avatars.get(id64)?.avatarUrl) || DEFAULT_AVATAR_URL;
return { steamid: p.steamid, name: p.current_name, avatarUrl };
});
return NextResponse.json({ players: result });
}
@@ -0,0 +1,133 @@
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];
}
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams;
const startParam = searchParams.get('start');
const endParam = searchParams.get('end');
if (!startParam || !endParam) {
return NextResponse.json({ error: 'start and end query params are required (ISO datetimes)' }, { status: 400 });
}
const rangeStart = new Date(startParam);
const rangeEnd = new Date(endParam);
if (Number.isNaN(rangeStart.getTime()) || Number.isNaN(rangeEnd.getTime()) || rangeStart >= rangeEnd) {
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() }));
return NextResponse.json({ bucketMinutes, points, mapBoundaries });
}