added number sorting to graphs and updated the graph for repeated days showcase
This commit is contained in:
+132
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -103,13 +103,14 @@ export default function PlayerSearch() {
|
|||||||
) : players.length === 0 ? (
|
) : players.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-ink-muted">No players found.</div>
|
<div className="p-4 text-sm text-ink-muted">No players found.</div>
|
||||||
) : (
|
) : (
|
||||||
players.map((p) => (
|
players.map((p, i) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.steamid}
|
key={p.steamid}
|
||||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||||
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
className="flex items-center justify-between px-4 py-3 hover:bg-base transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-ink-faint text-xs w-8 text-right shrink-0">{i + 1}</span>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={p.avatarUrl}
|
src={p.avatarUrl}
|
||||||
|
|||||||
@@ -40,14 +40,15 @@ export default function PlayerTimelineChart({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* time axis */}
|
{/* time axis */}
|
||||||
<div className="flex justify-between text-xs text-ink-faint font-mono mb-2 pl-[172px]">
|
<div className="flex justify-between text-xs text-ink-faint font-mono mb-2 pl-[196px]">
|
||||||
<span>{windowStartLabel}</span>
|
<span>{windowStartLabel}</span>
|
||||||
<span>{windowEndLabel}</span>
|
<span>{windowEndLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{players.map((p) => (
|
{players.map((p, i) => (
|
||||||
<div key={p.steamid} className="flex items-center gap-3">
|
<div key={p.steamid} className="flex items-center gap-3">
|
||||||
|
<span className="text-ink-faint text-xs w-5 text-right shrink-0">{i + 1}</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/players/${encodeURIComponent(p.steamid)}`}
|
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||||
className="flex items-center gap-2 w-[150px] shrink-0 hover:text-accent transition-colors"
|
className="flex items-center gap-2 w-[150px] shrink-0 hover:text-accent transition-colors"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import PlayerTimelineChart from './PlayerTimelineChart';
|
import PlayerTimelineChart from './PlayerTimelineChart';
|
||||||
|
import { formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
||||||
|
|
||||||
interface RecurringPoint {
|
interface RecurringPoint {
|
||||||
date: string; // "2026-07-01"
|
date: string; // "2026-07-01"
|
||||||
@@ -17,10 +19,20 @@ interface TimelinePlayer {
|
|||||||
steamid: string;
|
steamid: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
country?: string | null;
|
||||||
totalMinutes: number;
|
totalMinutes: number;
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AggregatePlayer {
|
||||||
|
steamid: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
country?: string | null;
|
||||||
|
totalMinutes: number;
|
||||||
|
daysPresent: number;
|
||||||
|
}
|
||||||
|
|
||||||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
const HEIGHT = 220;
|
const HEIGHT = 220;
|
||||||
const PAD_LEFT = 32;
|
const PAD_LEFT = 32;
|
||||||
@@ -56,6 +68,11 @@ export default function RecurringWindowChart() {
|
|||||||
const [windowMinutes, setWindowMinutes] = useState(0);
|
const [windowMinutes, setWindowMinutes] = useState(0);
|
||||||
const [loadingPlayers, setLoadingPlayers] = useState(false);
|
const [loadingPlayers, setLoadingPlayers] = useState(false);
|
||||||
|
|
||||||
|
const [aggregatePlayers, setAggregatePlayers] = useState<AggregatePlayer[]>([]);
|
||||||
|
const [aggregateDates, setAggregateDates] = useState(0);
|
||||||
|
const [loadingAggregate, setLoadingAggregate] = useState(false);
|
||||||
|
const [aggregateTruncated, setAggregateTruncated] = useState(false);
|
||||||
|
|
||||||
function toggleDay(d: number) {
|
function toggleDay(d: number) {
|
||||||
setSelectedDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort()));
|
setSelectedDays((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d].sort()));
|
||||||
}
|
}
|
||||||
@@ -66,28 +83,49 @@ export default function RecurringWindowChart() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadingAggregate(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
try {
|
const params = new URLSearchParams({
|
||||||
const params = new URLSearchParams({
|
days: selectedDays.join(','),
|
||||||
days: selectedDays.join(','),
|
startTime,
|
||||||
startTime,
|
endTime,
|
||||||
endTime,
|
start: rangeStart,
|
||||||
start: rangeStart,
|
end: rangeEnd,
|
||||||
end: rangeEnd,
|
});
|
||||||
});
|
|
||||||
const res = await fetch(`/api/population/recurring?${params.toString()}`);
|
const summaryPromise = (async () => {
|
||||||
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch(`/api/population/recurring?${params.toString()}`);
|
||||||
setPoints(data.points ?? []);
|
if (!res.ok) throw new Error((await res.json()).error ?? 'failed to load');
|
||||||
setTruncated(!!data.truncated);
|
const data = await res.json();
|
||||||
} catch (e: any) {
|
setPoints(data.points ?? []);
|
||||||
setError(e.message ?? 'something went wrong');
|
setTruncated(!!data.truncated);
|
||||||
setPoints([]);
|
} catch (e: any) {
|
||||||
} finally {
|
setError(e.message ?? 'something went wrong');
|
||||||
setLoading(false);
|
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) {
|
async function loadPlayersFor(point: RecurringPoint) {
|
||||||
@@ -278,19 +316,91 @@ export default function RecurringWindowChart() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected && (
|
{hasSearched && !loading && points.length > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t-2 border-base-divider">
|
<div className="mt-6 pt-6 border-t-2 border-base-divider grid md:grid-cols-2 gap-6">
|
||||||
<div className="stat-label mb-3">
|
{/* Overall — combined across every matching day */}
|
||||||
{new Date(`${selected.date}T00:00:00Z`).toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })}, {startTime}–{endTime} — {selectedPlayers.length} player
|
<div>
|
||||||
{selectedPlayers.length === 1 ? '' : 's'}, sorted by time spent in the window
|
<div className="stat-label mb-1">
|
||||||
|
Overall — combined across all {aggregateDates} matching day{aggregateDates === 1 ? '' : 's'}, {startTime}–{endTime}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-muted mb-3">
|
||||||
|
Each player's time spent inside the window, summed across every matching date.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loadingAggregate ? (
|
||||||
|
<div className="text-sm text-ink-faint font-mono py-4">loading…</div>
|
||||||
|
) : aggregatePlayers.length === 0 ? (
|
||||||
|
<div className="text-sm text-ink-muted py-4">No players recorded across these dates.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="row-divide">
|
||||||
|
{aggregatePlayers.map((p, i) => (
|
||||||
|
<Link
|
||||||
|
key={p.steamid}
|
||||||
|
href={`/players/${encodeURIComponent(p.steamid)}`}
|
||||||
|
className="flex items-center justify-between py-2.5 text-sm hover:bg-base transition-colors px-2 -mx-2 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="text-ink-faint text-xs w-5 text-right shrink-0">{i + 1}</span>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={p.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-7 h-7 rounded-full object-cover ring-1 ring-base-border shrink-0"
|
||||||
|
/>
|
||||||
|
{p.country !== undefined && (
|
||||||
|
<span className="text-xs shrink-0" aria-hidden>
|
||||||
|
{countryCodeToFlag(p.country)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-ink truncate">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
|
<span className="text-ink-faint text-xs mono">
|
||||||
|
{p.daysPresent} of {aggregateDates} day{aggregateDates === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink mono text-xs w-16 text-right">{formatMinutes(p.totalMinutes)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{aggregateTruncated && (
|
||||||
|
<div className="text-xs text-ink-faint mt-2">
|
||||||
|
Showing the top {aggregatePlayers.length} players by time — narrow the search for a complete list.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* One specific day — populated once a bar is clicked, visible (empty) before that */}
|
||||||
|
<div>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div className="stat-label mb-3">
|
||||||
|
{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
|
||||||
|
</div>
|
||||||
|
<PlayerTimelineChart
|
||||||
|
players={selectedPlayers}
|
||||||
|
windowMinutes={windowMinutes || 1}
|
||||||
|
windowStartLabel={startTime}
|
||||||
|
windowEndLabel={endTime}
|
||||||
|
loading={loadingPlayers}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-[200px] border border-dashed border-base-border rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-accent">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" className="shrink-0">
|
||||||
|
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
</svg>
|
||||||
|
<span>Click any green bar above to see who was online — and exactly when — on that specific day.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PlayerTimelineChart
|
|
||||||
players={selectedPlayers}
|
|
||||||
windowMinutes={windowMinutes || 1}
|
|
||||||
windowStartLabel={startTime}
|
|
||||||
windowEndLabel={endTime}
|
|
||||||
loading={loadingPlayers}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user