added infinite scrolling to frontend and displaying vote information

This commit is contained in:
jenz
2026-08-25 21:34:45 +02:00
parent ab481d6d65
commit 0634fde335
7 changed files with 240 additions and 25 deletions
@@ -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 });
}
@@ -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<PlayerRow>(
`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<PlayerRow>(
`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 });
}
@@ -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<number, { vote_time: string; result: string | null; choices: typeof votes }>();
// 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<string, VoteTallyRow>();
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 (
<div className="space-y-6">
@@ -104,7 +139,7 @@ export default async function MapPeriodDetailPage({
<div className="grid md:grid-cols-2 gap-6">
<div className="panel p-6">
<div className="stat-label mb-3">
Players present ({players.length})
Connected during this session ({players.length})
</div>
{players.length === 0 ? (
<div className="text-sm text-ink-muted">No recorded players during this period.</div>
@@ -150,9 +185,39 @@ export default async function MapPeriodDetailPage({
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-ink-faint mono">
{new Date(event.vote_time.replace(' ', 'T')).toLocaleString()}
{' · '}
{event.totalWeightedVotes} vote{event.totalWeightedVotes === 1 ? '' : 's'} cast
{event.totalWeightedVotes !== event.choices.length && (
<span className="text-ink-faint"> ({event.choices.length} voter{event.choices.length === 1 ? '' : 's'})</span>
)}
</div>
<div className="text-sm text-accent">Result: {event.result ?? '—'}</div>
</div>
{/* Per-map tally, highest first */}
<div className="space-y-1 mb-4">
{event.tally.map((t) => {
const pct = event.totalWeightedVotes > 0 ? (t.weightedVotes / event.tally[0].weightedVotes) * 100 : 0;
return (
<div key={t.map} className="flex items-center gap-2 text-xs">
<span className="text-ink w-40 truncate shrink-0">{t.map}</span>
<div className="flex-1 h-3 bg-base rounded overflow-hidden border border-base-border">
<div
className="h-full rounded-sm"
style={{
width: `${Math.max(pct, 3)}%`,
background: 'linear-gradient(90deg, #2A9C5C, #5EE596)',
}}
/>
</div>
<span className="text-ink-muted mono w-20 text-right shrink-0">
{t.weightedVotes} vote{t.weightedVotes === 1 ? '' : 's'}
</span>
</div>
);
})}
</div>
<div className="row-divide">
{event.choices.map((c, i) => (
<Link