added number sorting to graphs and updated the graph for repeated days showcase

This commit is contained in:
jenz
2026-08-26 09:18:49 +02:00
parent 0634fde335
commit ed02682ef4
4 changed files with 278 additions and 34 deletions
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { attachAvatars } from '@/lib/steamApi';
import { parseDbDateTime } from '@/lib/timezone';
function pad(n: number) {
return String(n).padStart(2, '0');
}
const MAX_DATES = 120; // same safety cap as the summary endpoint
const MAX_RESULTS = 200; // bound the leaderboard response size
interface SessionRow {
steamid: string;
current_name: string;
current_country: string | null;
session_start_dt: string;
session_end_dt: string | null;
}
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams;
const daysParam = sp.get('days');
const startTime = sp.get('startTime');
const endTime = sp.get('endTime');
const startDate = sp.get('start');
const endDate = sp.get('end');
if (!daysParam || !startTime || !endTime || !startDate || !endDate) {
return NextResponse.json({ error: 'days, startTime, endTime, start, end are all required' }, { status: 400 });
}
const days = daysParam.split(',').map(Number).filter((d) => d >= 0 && d <= 6);
if (days.length === 0) {
return NextResponse.json({ error: 'no valid days selected' }, { status: 400 });
}
if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime) || startTime >= endTime) {
return NextResponse.json({ error: 'startTime must be before endTime, both as HH:MM' }, { status: 400 });
}
const [startY, startM, startD] = startDate.split('-').map(Number);
const [endY, endM, endD] = endDate.split('-').map(Number);
if (!startY || !startM || !startD || !endY || !endM || !endD) {
return NextResponse.json({ error: 'invalid start/end date' }, { status: 400 });
}
// Same UTC-anchored calendar-date enumeration as the summary endpoint —
// see that route for why this avoids timezone ambiguity.
const cursor = new Date(Date.UTC(startY, startM - 1, startD));
const endCursor = new Date(Date.UTC(endY, endM - 1, endD));
if (cursor > endCursor) {
return NextResponse.json({ error: 'start date must be before end date' }, { status: 400 });
}
const matchingDates: string[] = [];
while (cursor <= endCursor && matchingDates.length < MAX_DATES) {
if (days.includes(cursor.getUTCDay())) {
matchingDates.push(`${cursor.getUTCFullYear()}-${pad(cursor.getUTCMonth() + 1)}-${pad(cursor.getUTCDate())}`);
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const truncated = matchingDates.length >= MAX_DATES && cursor <= endCursor;
interface PlayerAgg {
name: string;
country: string | null;
totalMinutes: number;
daysPresent: Set<string>;
}
const byPlayer = new Map<string, PlayerAgg>();
// One query per matching date, run concurrently — same pattern as the
// summary endpoint. Each date's rows are folded into the shared map
// synchronously once that date's query resolves, so despite running
// concurrently there's no race: JS's single-threaded event loop means
// each date's aggregation loop runs to completion without another date's
// callback interleaving mid-loop.
await Promise.all(
matchingDates.map(async (dateStr) => {
const windowStartStr = `${dateStr} ${startTime}:00`;
const windowEndStr = `${dateStr} ${endTime}:00`;
const windowStartMs = parseDbDateTime(windowStartStr).getTime();
const windowEndMs = parseDbDateTime(windowEndStr).getTime();
const rows = await query<SessionRow>(
`SELECT s.steamid, COALESCE(p.current_name, s.player_name) AS current_name, p.current_country,
s.session_start_dt, s.session_end_dt
FROM playtime_display_sessions s
LEFT JOIN playtime_display_players p ON p.steamid = s.steamid
WHERE s.session_start_dt < ?
AND (s.session_end_dt IS NULL OR s.session_end_dt > ?)`,
[windowEndStr, windowStartStr],
);
for (const r of rows) {
const sStart = parseDbDateTime(r.session_start_dt).getTime();
const sEnd = r.session_end_dt ? parseDbDateTime(r.session_end_dt).getTime() : Date.now();
const clippedStart = Math.max(sStart, windowStartMs);
const clippedEnd = Math.min(sEnd, windowEndMs);
if (clippedEnd <= clippedStart) continue;
const minutes = Math.round((clippedEnd - clippedStart) / 60000);
if (!byPlayer.has(r.steamid)) {
byPlayer.set(r.steamid, { name: r.current_name, country: r.current_country, totalMinutes: 0, daysPresent: new Set() });
}
const agg = byPlayer.get(r.steamid)!;
agg.totalMinutes += minutes;
agg.daysPresent.add(dateStr);
}
}),
);
const unenriched = [...byPlayer.entries()]
.map(([steamid, agg]) => ({
steamid,
name: agg.name,
country: agg.country,
totalMinutes: agg.totalMinutes,
daysPresent: agg.daysPresent.size,
}))
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.slice(0, MAX_RESULTS);
const players = await attachAvatars(unenriched);
return NextResponse.json({
players,
totalMatchingDates: matchingDates.length,
truncatedDates: truncated,
truncatedPlayers: byPlayer.size > MAX_RESULTS,
});
}