From 0634fde3352d028a5968c6a5fe93d3066b110360 Mon Sep 17 00:00:00 2001 From: jenz Date: Tue, 25 Aug 2026 21:34:45 +0200 Subject: [PATCH] added infinite scrolling to frontend and displaying vote information --- .../playtime-frontend/app/api/maps/route.ts | 10 ++- .../app/api/players/route.ts | 27 ++++--- .../app/maps/period/[mapId]/page.tsx | 73 ++++++++++++++++++- .../components/MapSearch.tsx | 56 +++++++++++++- .../components/PlayerSearch.tsx | 61 +++++++++++++++- .../playtime-frontend/lib/queries.ts | 6 +- .../lib/useInfiniteScroll.ts | 32 ++++++++ 7 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 discord_verificiation/playtime-frontend/lib/useInfiniteScroll.ts diff --git a/discord_verificiation/playtime-frontend/app/api/maps/route.ts b/discord_verificiation/playtime-frontend/app/api/maps/route.ts index cd36d6d..2b1be3b 100644 --- a/discord_verificiation/playtime-frontend/app/api/maps/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/maps/route.ts @@ -1,8 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { searchMapNames } from '@/lib/queries'; +const PAGE_SIZE = 100; + export async function GET(req: NextRequest) { const q = req.nextUrl.searchParams.get('q')?.trim() ?? ''; - const maps = await searchMapNames(q); - return NextResponse.json({ maps }); + const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0); + + const maps = await searchMapNames(q, PAGE_SIZE, offset); + const hasMore = maps.length === PAGE_SIZE; + + return NextResponse.json({ maps, hasMore }); } diff --git a/discord_verificiation/playtime-frontend/app/api/players/route.ts b/discord_verificiation/playtime-frontend/app/api/players/route.ts index 2a1054e..493bc9d 100644 --- a/discord_verificiation/playtime-frontend/app/api/players/route.ts +++ b/discord_verificiation/playtime-frontend/app/api/players/route.ts @@ -11,6 +11,8 @@ interface PlayerRow { last_connected: string | null; } +const PAGE_SIZE = 100; + const TOTAL_MINUTES_SUBQUERY = ` (SELECT COALESCE(s.session_end_playtime_minutes, s.session_start_playtime_minutes) FROM playtime_display_sessions s @@ -28,28 +30,31 @@ const LAST_CONNECTED_SUBQUERY = ` export async function GET(req: NextRequest) { const q = req.nextUrl.searchParams.get('q')?.trim() ?? ''; const sort = req.nextUrl.searchParams.get('sort') ?? 'recent'; + const offset = Math.max(0, Number(req.nextUrl.searchParams.get('offset')) || 0); + // Tie-break on steamid too — without a fully deterministic ORDER BY, + // paging with LIMIT/OFFSET can show duplicates or skip rows whenever two + // players tie on the primary sort key (e.g. same last_seen timestamp). const orderBy = sort === 'playtime' - ? 'total_minutes IS NULL, total_minutes DESC' + ? 'total_minutes IS NULL, total_minutes DESC, p.steamid' : sort === 'name' - ? 'p.current_name' - : 'p.last_seen DESC'; + ? 'p.current_name, p.steamid' + : 'p.last_seen DESC, p.steamid'; let rows: PlayerRow[]; if (!q) { - // No search term: just list players, ordered per the chosen sort. rows = await query( `SELECT p.steamid, p.current_name, p.current_country, NULL AS matched_name, ${TOTAL_MINUTES_SUBQUERY}, ${LAST_CONNECTED_SUBQUERY} FROM playtime_display_players p ORDER BY ${orderBy} - LIMIT 50`, + LIMIT ? OFFSET ?`, + [PAGE_SIZE, offset], ); } else { - // Search matches steamid directly, or current/previous names. const like = `%${q}%`; rows = await query( `SELECT p.steamid, p.current_name, p.current_country, h.player_name AS matched_name, @@ -66,11 +71,15 @@ export async function GET(req: NextRequest) { ) GROUP BY p.steamid ORDER BY ${orderBy} - LIMIT 50`, - [like, like, like, like], + LIMIT ? OFFSET ?`, + [like, like, like, like, PAGE_SIZE, offset], ); } const players = await attachAvatars(rows); - return NextResponse.json({ players }); + // A full page came back, so there might be more; a short page means we've + // hit the end. Simple and correct without needing a separate COUNT query. + const hasMore = rows.length === PAGE_SIZE; + + return NextResponse.json({ players, hasMore }); } diff --git a/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx b/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx index 283dfd1..fc3922b 100644 --- a/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx +++ b/discord_verificiation/playtime-frontend/app/maps/period/[mapId]/page.tsx @@ -35,14 +35,49 @@ export default async function MapPeriodDetailPage({ const [players, votes] = await Promise.all([attachAvatars(playersRaw), attachAvatars(votesRaw)]); // Group vote rows by their vote event (a map can theoretically have more - // than one vote called during its runtime). - const voteEvents = new Map(); + // than one vote called during its runtime), and tally each map's total + // (weighted) votes within that event, descending. "Total votes cast" is + // computed from the same weighted sum as the tally below it, so the two + // numbers always agree — counting a weighted vote as 1 instead of its + // actual weight is exactly what caused them to disagree before. + interface VoteTallyRow { + map: string; + weightedVotes: number; + voterCount: number; + } + const voteEvents = new Map< + number, + { + vote_time: string; + result: string | null; + choices: typeof votes; + tally: VoteTallyRow[]; + totalWeightedVotes: number; + } + >(); for (const v of votes) { if (!voteEvents.has(v.vote_id)) { - voteEvents.set(v.vote_id, { vote_time: v.vote_time, result: v.result, choices: [] }); + voteEvents.set(v.vote_id, { + vote_time: v.vote_time, + result: v.result, + choices: [], + tally: [], + totalWeightedVotes: 0, + }); } voteEvents.get(v.vote_id)!.choices.push(v); } + for (const event of voteEvents.values()) { + const byMap = new Map(); + for (const c of event.choices) { + const row = byMap.get(c.vote_choice) ?? { map: c.vote_choice, weightedVotes: 0, voterCount: 0 }; + row.weightedVotes += c.vote_weight; + row.voterCount += 1; + byMap.set(c.vote_choice, row); + } + event.tally = [...byMap.values()].sort((a, b) => b.weightedVotes - a.weightedVotes); + event.totalWeightedVotes = event.tally.reduce((sum, row) => sum + row.weightedVotes, 0); + } return (
@@ -104,7 +139,7 @@ export default async function MapPeriodDetailPage({
- Players present ({players.length}) + Connected during this session ({players.length})
{players.length === 0 ? (
No recorded players during this period.
@@ -150,9 +185,39 @@ export default async function MapPeriodDetailPage({
{new Date(event.vote_time.replace(' ', 'T')).toLocaleString()} + {' · '} + {event.totalWeightedVotes} vote{event.totalWeightedVotes === 1 ? '' : 's'} cast + {event.totalWeightedVotes !== event.choices.length && ( + ({event.choices.length} voter{event.choices.length === 1 ? '' : 's'}) + )}
Result: {event.result ?? '—'}
+ + {/* Per-map tally, highest first */} +
+ {event.tally.map((t) => { + const pct = event.totalWeightedVotes > 0 ? (t.weightedVotes / event.tally[0].weightedVotes) * 100 : 0; + return ( +
+ {t.map} +
+
+
+ + {t.weightedVotes} vote{t.weightedVotes === 1 ? '' : 's'} + +
+ ); + })} +
+
{event.choices.map((c, i) => ( ([]); 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(null); + + async function fetchPage(searchQ: string, offset: number) { + const params = new URLSearchParams({ q: searchQ, offset: String(offset) }); + const res = await fetch(`/api/maps?${params.toString()}`); + return res.json() as Promise<{ maps: MapSummary[]; hasMore: boolean }>; + } useEffect(() => { const handle = setTimeout(async () => { setLoading(true); - const res = await fetch(`/api/maps?q=${encodeURIComponent(q)}`); - const data = await res.json(); + offsetRef.current = 0; + const data = await fetchPage(q, 0); setMaps(data.maps); + setHasMore(data.hasMore); + offsetRef.current = data.maps.length; setLoading(false); }, 250); return () => clearTimeout(handle); }, [q]); + async function loadMore() { + if (loadingMoreRef.current || !hasMore || loading) return; + loadingMoreRef.current = true; + setLoadingMore(true); + const data = await fetchPage(q, offsetRef.current); + setMaps((prev) => [...prev, ...data.maps]); + setHasMore(data.hasMore); + offsetRef.current += data.maps.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, q]); + return (
)) )} + + {!loading && maps.length > 0 && ( +
+ {loadingMore ? ( + loading more… + ) : !hasMore ? ( + — end of list — + ) : null} +
+ )}
); diff --git a/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx index 8c8fbaa..8c76b31 100644 --- a/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx +++ b/discord_verificiation/playtime-frontend/components/PlayerSearch.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { countryCodeToFlag, formatMinutes } from '@/lib/steam'; import { formatRelativeTime } from '@/lib/dates'; @@ -20,20 +20,62 @@ export default function PlayerSearch() { const [sort, setSort] = useState<'recent' | 'name' | 'playtime'>('recent'); const [players, setPlayers] = useState([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const offsetRef = useRef(0); + const loadingMoreRef = useRef(false); // guards against the observer firing twice for one fetch + const sentinelRef = useRef(null); + + async function fetchPage(searchQ: string, searchSort: string, offset: number) { + const params = new URLSearchParams({ q: searchQ, sort: searchSort, offset: String(offset) }); + const res = await fetch(`/api/players?${params.toString()}`); + return res.json() as Promise<{ players: Player[]; hasMore: boolean }>; + } + + // Search or sort changed: reset and load page 1 fresh, replacing the list. useEffect(() => { const handle = setTimeout(async () => { setLoading(true); - const params = new URLSearchParams({ q, sort }); - const res = await fetch(`/api/players?${params.toString()}`); - const data = await res.json(); + offsetRef.current = 0; + const data = await fetchPage(q, sort, 0); setPlayers(data.players); + setHasMore(data.hasMore); + offsetRef.current = data.players.length; setLoading(false); }, 250); // debounce so we're not hitting the DB on every keystroke return () => clearTimeout(handle); }, [q, sort]); + async function loadMore() { + if (loadingMoreRef.current || !hasMore || loading) return; + loadingMoreRef.current = true; + setLoadingMore(true); + const data = await fetchPage(q, sort, offsetRef.current); + setPlayers((prev) => [...prev, ...data.players]); + setHasMore(data.hasMore); + offsetRef.current += data.players.length; + setLoadingMore(false); + loadingMoreRef.current = false; + } + + // Fetch the next page once the sentinel at the bottom of the list scrolls + // into view, rather than waiting for an explicit "load more" click. + useEffect(() => { + const el = sentinelRef.current; + if (!el) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) loadMore(); + }, + { rootMargin: '400px' }, // start fetching a bit before it's actually visible + ); + observer.observe(el); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasMore, loading, q, sort]); + return (
@@ -94,6 +136,17 @@ export default function PlayerSearch() { )) )} + + {/* Sentinel — fetches the next page once this scrolls into view */} + {!loading && players.length > 0 && ( +
+ {loadingMore ? ( + loading more… + ) : !hasMore ? ( + — end of list — + ) : null} +
+ )}
); diff --git a/discord_verificiation/playtime-frontend/lib/queries.ts b/discord_verificiation/playtime-frontend/lib/queries.ts index 921e960..a520836 100644 --- a/discord_verificiation/playtime-frontend/lib/queries.ts +++ b/discord_verificiation/playtime-frontend/lib/queries.ts @@ -239,7 +239,7 @@ export interface MapVoteRow { vote_weight: number; } -export async function searchMapNames(q: string, limit = 50): Promise { +export async function searchMapNames(q: string, limit = 50, offset = 0): Promise { const like = `%${q}%`; return query( `SELECT m.map_name, COUNT(*) AS times_played, MAX(m.map_start_dt) AS last_played, @@ -249,8 +249,8 @@ export async function searchMapNames(q: string, limit = 50): Promise void, enabled: boolean) { + const sentinelRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + const el = sentinelRef.current; + if (!el) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) onIntersect(); + }, + { rootMargin: '400px' }, // start loading a bit before the sentinel is actually on screen + ); + + observer.observe(el); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, onIntersect]); + + return sentinelRef; +}