loading gradually player sessions instead all at once
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSessions } from '@/lib/queries';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ steamid: string }> }) {
|
||||||
|
const { steamid: rawSteamid } = await params;
|
||||||
|
const steamid = decodeURIComponent(rawSteamid);
|
||||||
|
const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0);
|
||||||
|
|
||||||
|
const sessions = await getSessions(steamid, PAGE_SIZE, offset);
|
||||||
|
const hasMore = sessions.length === PAGE_SIZE;
|
||||||
|
|
||||||
|
return NextResponse.json({ sessions, hasMore });
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getPlayer, getNameHistory, getSessions, getTotalPlaytimeMinutes } from '@/lib/queries';
|
import { getPlayer, getNameHistory, getTotalPlaytimeMinutes } from '@/lib/queries';
|
||||||
import { steam2ToSteam64, steamProfileUrl, formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
import { steam2ToSteam64, steamProfileUrl, formatMinutes, countryCodeToFlag } from '@/lib/steam';
|
||||||
import { getSteamSummary, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
import { getSteamSummary, DEFAULT_AVATAR_URL } from '@/lib/steamApi';
|
||||||
import { getRaceTimerSummary } from '@/lib/racetimer';
|
import { getRaceTimerSummary } from '@/lib/racetimer';
|
||||||
|
import PlayerSessionsList from '@/components/PlayerSessionsList';
|
||||||
|
|
||||||
export default async function PlayerDetailPage({
|
export default async function PlayerDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -21,9 +22,14 @@ export default async function PlayerDetailPage({
|
|||||||
const steamId64 = steam2ToSteam64(steamid);
|
const steamId64 = steam2ToSteam64(steamid);
|
||||||
const profileUrl = steamProfileUrl(steamid);
|
const profileUrl = steamProfileUrl(steamid);
|
||||||
|
|
||||||
const [nameHistory, sessions, totalMinutes, steamSummary, raceTimer] = await Promise.all([
|
// Sessions are deliberately NOT fetched here — that query's per-row
|
||||||
|
// maps_played subquery gets slower as a player accumulates history, and
|
||||||
|
// was making this page take 20-30+ seconds for long-tenured players
|
||||||
|
// regardless of whether anyone scrolled far enough to see old sessions.
|
||||||
|
// PlayerSessionsList below fetches its own first page client-side, so
|
||||||
|
// everything else on this page renders immediately.
|
||||||
|
const [nameHistory, totalMinutes, steamSummary, raceTimer] = await Promise.all([
|
||||||
getNameHistory(steamid),
|
getNameHistory(steamid),
|
||||||
getSessions(steamid),
|
|
||||||
getTotalPlaytimeMinutes(steamid),
|
getTotalPlaytimeMinutes(steamid),
|
||||||
steamId64 ? getSteamSummary(steamId64) : Promise.resolve(null),
|
steamId64 ? getSteamSummary(steamId64) : Promise.resolve(null),
|
||||||
getRaceTimerSummary(steamid),
|
getRaceTimerSummary(steamid),
|
||||||
@@ -120,35 +126,7 @@ export default async function PlayerDetailPage({
|
|||||||
{/* Sessions */}
|
{/* Sessions */}
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
<div className="stat-label mb-3">Play sessions</div>
|
<div className="stat-label mb-3">Play sessions</div>
|
||||||
{sessions.length === 0 ? (
|
<PlayerSessionsList steamid={steamid} />
|
||||||
<div className="text-sm text-ink-muted">No recorded sessions yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="row-divide">
|
|
||||||
<div className="grid grid-cols-[1fr_1fr_100px] gap-4 pb-2 text-xs text-ink-faint uppercase tracking-wide">
|
|
||||||
<div>Started</div>
|
|
||||||
<div>Map(s)</div>
|
|
||||||
<div className="text-right">Playtime</div>
|
|
||||||
</div>
|
|
||||||
{sessions.map((s) => (
|
|
||||||
<div key={s.session_id} className="grid grid-cols-[1fr_1fr_100px] gap-4 py-3 text-sm items-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-ink">
|
|
||||||
{new Date(s.session_start_dt.replace(' ', 'T')).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-ink-faint">
|
|
||||||
{s.session_end_dt
|
|
||||||
? `until ${new Date(s.session_end_dt.replace(' ', 'T')).toLocaleTimeString()}`
|
|
||||||
: 'still connected'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-ink-muted">{s.maps_played ?? '—'}</div>
|
|
||||||
<div className="text-right mono text-ink">
|
|
||||||
{formatMinutes(s.session_active_minutes)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { formatMinutes } from '@/lib/steam';
|
||||||
|
|
||||||
|
interface SessionEntry {
|
||||||
|
session_id: number;
|
||||||
|
session_start_dt: string;
|
||||||
|
session_end_dt: string | null;
|
||||||
|
session_active_minutes: number | null;
|
||||||
|
maps_played: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerSessionsList({ steamid }: { steamid: string }) {
|
||||||
|
const [sessions, setSessions] = useState<SessionEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
async function fetchPage(offset: number) {
|
||||||
|
const res = await fetch(`/api/players/${encodeURIComponent(steamid)}/sessions?offset=${offset}`);
|
||||||
|
return res.json() as Promise<{ sessions: SessionEntry[]; hasMore: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
offsetRef.current = 0;
|
||||||
|
const data = await fetchPage(0);
|
||||||
|
setSessions(data.sessions);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current = data.sessions.length;
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [steamid]);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMoreRef.current || !hasMore || loading) return;
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
setLoadingMore(true);
|
||||||
|
const data = await fetchPage(offsetRef.current);
|
||||||
|
setSessions((prev) => [...prev, ...data.sessions]);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
offsetRef.current += data.sessions.length;
|
||||||
|
setLoadingMore(false);
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) loadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px' },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasMore, loading, steamid]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-sm text-ink-faint font-mono">loading…</div>;
|
||||||
|
}
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return <div className="text-sm text-ink-muted">No recorded sessions yet.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row-divide">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_100px] gap-4 pb-2 text-xs text-ink-faint uppercase tracking-wide">
|
||||||
|
<div>Started</div>
|
||||||
|
<div>Map(s)</div>
|
||||||
|
<div className="text-right">Playtime</div>
|
||||||
|
</div>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<div key={s.session_id} className="grid grid-cols-[1fr_1fr_100px] gap-4 py-3 text-sm items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-ink">{new Date(s.session_start_dt.replace(' ', 'T')).toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-ink-faint">
|
||||||
|
{s.session_end_dt
|
||||||
|
? `until ${new Date(s.session_end_dt.replace(' ', 'T')).toLocaleTimeString()}`
|
||||||
|
: 'still connected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-ink-muted">{s.maps_played ?? '—'}</div>
|
||||||
|
<div className="text-right mono text-ink">{formatMinutes(s.session_active_minutes)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} className="pt-3 text-center">
|
||||||
|
{loadingMore ? (
|
||||||
|
<span className="text-xs text-ink-faint font-mono">loading more…</span>
|
||||||
|
) : !hasMore ? (
|
||||||
|
<span className="text-xs text-ink-faint">— oldest session reached —</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,11 +62,18 @@ export async function getNameHistory(steamid: string): Promise<NameHistoryEntry[
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSessions(steamid: string, limit = 100): Promise<SessionEntry[]> {
|
export async function getSessions(steamid: string, limit = 10, offset = 0): Promise<SessionEntry[]> {
|
||||||
// The maps_played subquery finds every map_history period that overlaps
|
// The maps_played subquery finds every map_history period that overlaps
|
||||||
// this session's [start, end] window (same overlap logic used elsewhere
|
// this session's [start, end] window (same overlap logic used elsewhere
|
||||||
// in the app), and concatenates the names. A session can span more than
|
// in the app), and concatenates the names. A session can span more than
|
||||||
// one map if the player stayed connected through a map change.
|
// one map if the player stayed connected through a map change.
|
||||||
|
//
|
||||||
|
// This subquery runs once per returned row, and gets slower as
|
||||||
|
// map_history grows — which is exactly why this is paginated at a small
|
||||||
|
// page size (10) rather than fetching a large batch at once. A profile
|
||||||
|
// with thousands of historical sessions was taking 20-30+ seconds to
|
||||||
|
// load because this ran against a much larger LIMIT on every visit,
|
||||||
|
// regardless of whether anyone ever scrolled down to see older sessions.
|
||||||
return query<SessionEntry>(
|
return query<SessionEntry>(
|
||||||
`SELECT
|
`SELECT
|
||||||
s.session_id,
|
s.session_id,
|
||||||
@@ -83,8 +90,8 @@ export async function getSessions(steamid: string, limit = 100): Promise<Session
|
|||||||
FROM playtime_display_sessions s
|
FROM playtime_display_sessions s
|
||||||
WHERE s.steamid = ?
|
WHERE s.steamid = ?
|
||||||
ORDER BY s.session_start_dt DESC
|
ORDER BY s.session_start_dt DESC
|
||||||
LIMIT ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[steamid, limit],
|
[steamid, limit, offset],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user