diff --git a/discord_verificiation/playtime-frontend/app/api/population/recurring/aggregate/route.ts b/discord_verificiation/playtime-frontend/app/api/population/recurring/aggregate/route.ts new file mode 100644 index 0000000..425b694 --- /dev/null +++ b/discord_verificiation/playtime-frontend/app/api/population/recurring/aggregate/route.ts @@ -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; + } + const byPlayer = new Map(); + + // 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( + `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, + }); +} diff --git a/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx index 8c76b31..4e8f5d8 100644 --- a/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx +++ b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx @@ -103,13 +103,14 @@ export default function PlayerSearch() { ) : players.length === 0 ? (
No players found.
) : ( - players.map((p) => ( + players.map((p, i) => (
+ {i + 1} {/* eslint-disable-next-line @next/next/no-img-element */} {/* time axis */} -
+
{windowStartLabel} {windowEndLabel}
- {players.map((p) => ( + {players.map((p, i) => (
+ {i + 1} ([]); + const [aggregateDates, setAggregateDates] = useState(0); + const [loadingAggregate, setLoadingAggregate] = useState(false); + const [aggregateTruncated, setAggregateTruncated] = useState(false); + function toggleDay(d: number) { setSelectedDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort())); } @@ -66,28 +83,49 @@ export default function RecurringWindowChart() { return; } setLoading(true); + setLoadingAggregate(true); setError(null); setSelected(null); setHasSearched(true); - try { - const params = new URLSearchParams({ - days: selectedDays.join(','), - startTime, - endTime, - start: rangeStart, - end: rangeEnd, - }); - const res = await fetch(`/api/population/recurring?${params.toString()}`); - if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load'); - const data = await res.json(); - setPoints(data.points ?? []); - setTruncated(!!data.truncated); - } catch (e: any) { - setError(e.message ?? 'something went wrong'); - setPoints([]); - } finally { - setLoading(false); - } + const params = new URLSearchParams({ + days: selectedDays.join(','), + startTime, + endTime, + start: rangeStart, + end: rangeEnd, + }); + + const summaryPromise = (async () => { + try { + const res = await fetch(`/api/population/recurring?${params.toString()}`); + if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load'); + const data = await res.json(); + setPoints(data.points ?? []); + setTruncated(!!data.truncated); + } catch (e: any) { + setError(e.message ?? 'something went wrong'); + setPoints([]); + } finally { + setLoading(false); + } + })(); + + const aggregatePromise = (async () => { + try { + const res = await fetch(`/api/population/recurring/aggregate?${params.toString()}`); + const data = await res.json(); + setAggregatePlayers(data.players ?? []); + setAggregateDates(data.totalMatchingDates ?? 0); + setAggregateTruncated(!!data.truncatedPlayers); + } catch { + setAggregatePlayers([]); + setAggregateDates(0); + } finally { + setLoadingAggregate(false); + } + })(); + + await Promise.all([summaryPromise, aggregatePromise]); } async function loadPlayersFor(point: RecurringPoint) { @@ -278,19 +316,91 @@ export default function RecurringWindowChart() { )} - {selected && ( -
-
- {new Date(`${selected.date}T00:00:00Z`).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })}, {startTime}–{endTime} — {selectedPlayers.length} player - {selectedPlayers.length === 1 ? '' : 's'}, sorted by time spent in the window + {hasSearched && !loading && points.length > 0 && ( +
+ {/* Overall — combined across every matching day */} +
+
+ Overall — combined across all {aggregateDates} matching day{aggregateDates === 1 ? '' : 's'}, {startTime}–{endTime} +
+

+ Each player's time spent inside the window, summed across every matching date. +

+ + {loadingAggregate ? ( +
loading…
+ ) : aggregatePlayers.length === 0 ? ( +
No players recorded across these dates.
+ ) : ( + <> +
+ {aggregatePlayers.map((p, i) => ( + +
+ {i + 1} + {/* eslint-disable-next-line @next/next/no-img-element */} + + {p.country !== undefined && ( + + {countryCodeToFlag(p.country)} + + )} + {p.name} +
+
+ + {p.daysPresent} of {aggregateDates} day{aggregateDates === 1 ? '' : 's'} + + {formatMinutes(p.totalMinutes)} +
+ + ))} +
+ {aggregateTruncated && ( +
+ Showing the top {aggregatePlayers.length} players by time — narrow the search for a complete list. +
+ )} + + )} +
+ + {/* One specific day — populated once a bar is clicked, visible (empty) before that */} +
+ {selected ? ( + <> +
+ {new Date(`${selected.date}T00:00:00Z`).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })}, {startTime}–{endTime} — {selectedPlayers.length} player + {selectedPlayers.length === 1 ? '' : 's'}, sorted by time spent in the window +
+ + + ) : ( +
+
+ + + + + Click any green bar above to see who was online — and exactly when — on that specific day. +
+
+ )}
-
)}