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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user