updating the playtime frontend with claude only code to replace the one graph with a dumb-bell graph, having each individual map show its players at beginning versus end and also the overall impact of maps + their popularity

This commit is contained in:
jenz
2026-10-02 14:43:45 +02:00
parent 6019e6854d
commit 8a76454784
14 changed files with 3300 additions and 159 deletions
@@ -0,0 +1,174 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { formatMinutes } from '@/lib/steam';
import { useInfiniteScroll } from '@/lib/useInfiniteScroll';
interface MapImpactRow {
map_name: string;
times_played: number;
total_minutes: number;
last_played: string;
avg_net_change: number | null;
avg_retention_pct: number | null;
best_session_change: number | null;
worst_session_change: number | null;
}
type SortBy = 'impact' | 'popularity';
type Direction = 'asc' | 'desc';
function ChangeBadge({ value }: { value: number | null }) {
if (value == null) return <span className="text-ink-faint">—</span>;
const rounded = Math.round(value * 10) / 10;
const color = rounded > 0 ? '#3FD37A' : rounded < 0 ? '#E0665C' : '#8A9691';
return (
<span className="mono" style={{ color }}>
{rounded > 0 ? '+' : ''}
{rounded}
</span>
);
}
export default function MapImpactLeaderboard() {
const [sortBy, setSortBy] = useState<SortBy>('impact');
const [direction, setDirection] = useState<Direction>('asc'); // impact default: worst (most population-harming) first
const [maps, setMaps] = useState<MapImpactRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const offsetRef = useRef(0);
const loadingMoreRef = useRef(false);
async function fetchPage(sBy: SortBy, dir: Direction, offset: number) {
const params = new URLSearchParams({ sortBy: sBy, direction: dir, offset: String(offset) });
const res = await fetch(`/api/maps/impact?${params.toString()}`);
return res.json() as Promise<{ maps: MapImpactRow[]; hasMore: boolean }>;
}
useEffect(() => {
setLoading(true);
offsetRef.current = 0;
fetchPage(sortBy, direction, 0).then((data) => {
setMaps(data.maps);
setHasMore(data.hasMore);
offsetRef.current = data.maps.length;
setLoading(false);
});
}, [sortBy, direction]);
async function loadMore() {
if (loadingMoreRef.current || !hasMore || loading) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const data = await fetchPage(sortBy, direction, offsetRef.current);
setMaps((prev) => [...prev, ...data.maps]);
setHasMore(data.hasMore);
offsetRef.current += data.maps.length;
setLoadingMore(false);
loadingMoreRef.current = false;
}
const sentinelRef = useInfiniteScroll(loadMore, !loading && hasMore);
function switchSort(next: SortBy) {
if (next === sortBy) return;
setSortBy(next);
// Each metric has its own sensible default direction.
setDirection(next === 'popularity' ? 'desc' : 'asc');
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex rounded-lg border border-base-border overflow-hidden text-sm">
<button
onClick={() => switchSort('impact')}
className={`px-3 py-2 transition-colors ${
sortBy === 'impact' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
}`}
>
Sort by impact
</button>
<button
onClick={() => switchSort('popularity')}
className={`px-3 py-2 transition-colors border-l border-base-border ${
sortBy === 'popularity' ? 'bg-accent-dim text-accent' : 'text-ink-muted hover:text-ink'
}`}
>
Sort by popularity
</button>
</div>
<button
onClick={() => setDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
className="text-xs text-ink-muted hover:text-accent transition-colors underline"
>
{sortBy === 'impact'
? direction === 'asc'
? 'Showing worst impact first'
: 'Showing best impact first'
: direction === 'desc'
? 'Showing most played first'
: 'Showing least played first'}
{' '}— flip
</button>
</div>
<div className="panel row-divide">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 px-4 py-2 text-xs text-ink-faint uppercase tracking-wide">
<div>Map</div>
<div className="text-right w-16">Played</div>
<div className="text-right w-24">Avg Δ</div>
<div className="text-right w-20">Retention</div>
<div className="text-right w-28">Best / worst Δ</div>
</div>
{loading ? (
<div className="p-4 text-sm text-ink-faint font-mono">loading…</div>
) : maps.length === 0 ? (
<div className="p-4 text-sm text-ink-muted">No completed map sessions recorded yet.</div>
) : (
maps.map((m, i) => (
<Link
key={m.map_name}
href={`/maps/${encodeURIComponent(m.map_name)}`}
className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-4 items-center px-4 py-3 hover:bg-base transition-colors text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-ink-faint text-xs w-6 text-right shrink-0">{i + 1}</span>
<div className="min-w-0">
<div className="text-ink truncate">{m.map_name}</div>
<div className="text-xs text-ink-faint">{formatMinutes(m.total_minutes)} total</div>
</div>
</div>
<div className="mono text-ink-muted text-right w-16">{m.times_played}×</div>
<div className="text-right w-24">
<ChangeBadge value={m.avg_net_change} />
</div>
<div className="mono text-ink-muted text-right w-20">
{m.avg_retention_pct == null ? '—' : `${Math.round(m.avg_retention_pct)}%`}
</div>
<div className="text-right w-28 text-xs">
<ChangeBadge value={m.best_session_change} /> <span className="text-ink-faint">/</span>{' '}
<ChangeBadge value={m.worst_session_change} />
</div>
</Link>
))
)}
{!loading && maps.length > 0 && (
<div ref={sentinelRef} className="p-4 text-center">
{loadingMore ? (
<span className="text-xs text-ink-faint font-mono">loading more…</span>
) : !hasMore ? (
<span className="text-xs text-ink-faint">— end of list —</span>
) : null}
</div>
)}
</div>
</div>
);
}