Rich per-player stats + pick-your-metric leaderboard

- lib/player-stats.ts: single computePlayerStats() reads every
  world/stats/<uuid>.json plus world/advancements/<uuid>.json and joins
  with usercache.json. Returns a flat PlayerStats record per player:
  playtime, longest life, mob/player kills, deaths, K/D, damage dealt
  and taken (HP-scaled), blocks mined, items used / crafted / picked up
  (+ unique), distance (summed across all _one_cm stats), chest opens,
  nether/end trips, villager trades, fish caught, animals bred, and
  advancements (non-recipe) / recipes unlocked.
- New GET /api/players/stats (authed, 60s memo). Existing
  /api/players/playtime now returns a thin projection of the same
  computed data (shared cache key keeps both endpoints cheap).
- New components/Leaderboard.tsx with a metric select grouped into
  Time / Combat / World / Exploration / Progression / Economy (22
  metrics). Sorts descending, top 10 with "show all" toggle, smart
  number formatting (1.2k / 3.4M / HP / km). Replaces the old
  PlaytimeLeaderboard in the Players tab.
- PlayerDrawer upgraded: uses the full stats payload, shows small
  tiles for Kills / Deaths / K/D / Advs / Mined / Crafted / Distance
  alongside Playtime + Last seen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 06:37:50 -06:00
parent 9ccd4ca772
commit 3a69dc9243
7 changed files with 521 additions and 214 deletions

View file

@ -11,7 +11,7 @@ import { Separator } from "@/components/ui/separator";
import { PlayerAvatar } from "@/components/PlayerAvatar";
import { onAppEvent } from "@/lib/events";
import { formatHours, timeAgo } from "@/lib/time";
import type { PlaytimeEntry } from "@/components/PlaytimeLeaderboard";
import type { PlayerStats } from "@/lib/player-stats";
type PlayerData = {
ops: { name: string; uuid: string; level: number }[];
@ -47,16 +47,14 @@ export function PlayerDrawer() {
staleTime: 10_000,
});
const playtime = useQuery<PlaytimeEntry[]>({
queryKey: ["players-playtime"],
queryFn: () => fetch("/api/players/playtime").then((r) => r.json()),
const stats = useQuery<PlayerStats[]>({
queryKey: ["players-stats"],
queryFn: () => fetch("/api/players/stats").then((r) => r.json()),
enabled: authed && !!name,
staleTime: 60_000,
});
const myPlaytime = name
? playtime.data?.find((p) => p.name === name)
: undefined;
const mine = name ? stats.data?.find((p) => p.name === name) : undefined;
// Use existing analytics cache (last entry) to determine online now
const analytics = queryClient.getQueryData<MetricEntry[]>(["analytics", 6]);
@ -157,22 +155,30 @@ export function PlayerDrawer() {
</p>
) : (
<>
{myPlaytime && (
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md bg-muted p-3">
<p className="text-xs text-muted-foreground">Playtime</p>
<p className="text-lg font-bold tabular-nums">
{formatHours(myPlaytime.playtimeHours)}
</p>
{mine && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-3">
<Tile label="Playtime" value={formatHours(mine.playtimeHours)} />
<Tile
label="Last seen"
value={timeAgo(mine.lastPlayedMs)}
title={new Date(mine.lastPlayedMs).toLocaleString()}
/>
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-xs text-muted-foreground">Last seen</p>
<p
className="text-sm font-medium"
title={new Date(myPlaytime.lastPlayedMs).toLocaleString()}
>
{timeAgo(myPlaytime.lastPlayedMs)}
</p>
<div className="grid grid-cols-4 gap-2">
<Tile small label="Kills" value={mine.mobKills.toLocaleString()} />
<Tile small label="Deaths" value={mine.deaths.toLocaleString()} />
<Tile small label="K/D" value={mine.kdr.toFixed(2)} />
<Tile small label="Advs" value={mine.advancements.toLocaleString()} />
</div>
<div className="grid grid-cols-3 gap-2">
<Tile small label="Mined" value={fmtInt(mine.blocksMined)} />
<Tile small label="Crafted" value={fmtInt(mine.itemsCrafted)} />
<Tile
small
label="Distance"
value={`${mine.distanceKm.toFixed(1)} km`}
/>
</div>
</div>
)}
@ -259,7 +265,7 @@ export function PlayerDrawer() {
{players.data?.ops.find((p) => p.name === name)?.uuid ||
players.data?.whitelist.find((p) => p.name === name)?.uuid ||
players.data?.banned.find((p) => p.name === name)?.uuid ||
myPlaytime?.uuid ||
mine?.uuid ||
"—"}
</span>
</p>
@ -275,3 +281,32 @@ export function PlayerDrawer() {
</div>
);
}
function Tile({
label,
value,
title,
small = false,
}: {
label: string;
value: string;
title?: string;
small?: boolean;
}) {
return (
<div className="rounded-md bg-muted p-3" title={title}>
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={`${small ? "text-sm" : "text-lg"} font-bold tabular-nums truncate`}
>
{value}
</p>
</div>
);
}
function fmtInt(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 10_000) return `${(v / 1_000).toFixed(1)}k`;
return v.toLocaleString();
}