mc-dashboard/components/Leaderboard.tsx
hurkicorgi 3a69dc9243 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>
2026-04-13 06:37:50 -06:00

221 lines
7.7 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PlayerAvatar } from "@/components/PlayerAvatar";
import { formatHours, timeAgo } from "@/lib/time";
import type { PlayerStats } from "@/lib/player-stats";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type MetricKey =
| "playtimeHours"
| "distanceKm"
| "mobKills"
| "playerKills"
| "deaths"
| "kdr"
| "damageDealt"
| "damageTaken"
| "blocksMined"
| "itemsUsed"
| "itemsCrafted"
| "itemsPickedUp"
| "uniqueItemsPickedUp"
| "advancements"
| "recipesUnlocked"
| "chestOpens"
| "netherPortalEnters"
| "endPortalEnters"
| "villagerTrades"
| "fishCaught"
| "animalsBred"
| "longestLifeHours";
type Metric = {
key: MetricKey;
label: string;
group: string;
format: (v: number) => string;
suffix?: string;
};
const METRICS: Metric[] = [
{ key: "playtimeHours", label: "Playtime", group: "Time", format: (v) => formatHours(v) },
{ key: "longestLifeHours", label: "Longest life", group: "Time", format: (v) => formatHours(v) },
{ key: "mobKills", label: "Mob kills", group: "Combat", format: fmtInt },
{ key: "playerKills", label: "Player kills", group: "Combat", format: fmtInt },
{ key: "deaths", label: "Deaths", group: "Combat", format: fmtInt },
{ key: "kdr", label: "K/D ratio", group: "Combat", format: (v) => v.toFixed(2) },
{ key: "damageDealt", label: "Damage dealt", group: "Combat", format: fmtInt, suffix: " HP" },
{ key: "damageTaken", label: "Damage taken", group: "Combat", format: fmtInt, suffix: " HP" },
{ key: "blocksMined", label: "Blocks mined", group: "World", format: fmtInt },
{ key: "itemsUsed", label: "Items used / placed", group: "World", format: fmtInt },
{ key: "itemsCrafted", label: "Items crafted", group: "World", format: fmtInt },
{ key: "itemsPickedUp", label: "Items picked up", group: "World", format: fmtInt },
{ key: "uniqueItemsPickedUp", label: "Unique items seen", group: "World", format: fmtInt },
{ key: "distanceKm", label: "Distance traveled", group: "Exploration", format: (v) => v.toFixed(1), suffix: " km" },
{ key: "chestOpens", label: "Chests opened", group: "Exploration", format: fmtInt },
{ key: "netherPortalEnters", label: "Nether trips", group: "Exploration", format: fmtInt },
{ key: "endPortalEnters", label: "End trips", group: "Exploration", format: fmtInt },
{ key: "advancements", label: "Advancements", group: "Progression", format: fmtInt },
{ key: "recipesUnlocked", label: "Recipes unlocked", group: "Progression", format: fmtInt },
{ key: "villagerTrades", label: "Villager trades", group: "Economy", format: fmtInt },
{ key: "fishCaught", label: "Fish caught", group: "Economy", format: fmtInt },
{ key: "animalsBred", label: "Animals bred", group: "Economy", format: fmtInt },
];
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();
}
const TOP_N = 10;
export function Leaderboard() {
const [metricKey, setMetricKey] = useState<MetricKey>("playtimeHours");
const [showAll, setShowAll] = useState(false);
const { data = [], isLoading, isError } = useQuery<PlayerStats[]>({
queryKey: ["players-stats"],
queryFn: () => fetch("/api/players/stats").then((r) => r.json()),
staleTime: 60_000,
refetchInterval: 5 * 60_000,
});
const metric = METRICS.find((m) => m.key === metricKey)!;
const sorted = useMemo(() => {
return [...data].sort(
(a, b) => (b[metricKey] as number) - (a[metricKey] as number)
);
}, [data, metricKey]);
const visible = showAll ? sorted : sorted.slice(0, TOP_N);
const groups = useMemo(() => {
const g = new Map<string, Metric[]>();
for (const m of METRICS) {
if (!g.has(m.group)) g.set(m.group, []);
g.get(m.group)!.push(m);
}
return g;
}, []);
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<CardTitle className="text-base">Leaderboard</CardTitle>
<CardDescription>
Ranked by{" "}
<span className="text-foreground font-medium">{metric.label}</span>
</CardDescription>
</div>
<select
value={metricKey}
onChange={(e) => {
setMetricKey(e.target.value as MetricKey);
setShowAll(false);
}}
aria-label="Ranking metric"
className="h-9 rounded-md border border-input bg-muted px-2 text-sm max-w-full"
>
{Array.from(groups.entries()).map(([group, metrics]) => (
<optgroup key={group} label={group}>
{metrics.map((m) => (
<option key={m.key} value={m.key}>
{m.label}
</option>
))}
</optgroup>
))}
</select>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<ul className="space-y-1">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="flex items-center gap-3 px-3 py-2">
<Skeleton className="h-6 w-6 rounded" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-14 ml-auto" />
</li>
))}
</ul>
) : isError ? (
<p className="text-sm text-red-300 text-center py-4">
Failed to load stats.
</p>
) : sorted.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No player stats yet.
</p>
) : (
<>
<ol className="space-y-1">
{visible.map((p, i) => {
const raw = p[metricKey] as number;
const display = `${metric.format(raw)}${metric.suffix || ""}`;
return (
<li
key={p.uuid}
className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50"
>
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 text-center shrink-0">
{i + 1}
</span>
<PlayerAvatar name={p.name} size={24} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.name}</p>
<p
className="text-xs text-muted-foreground"
title={new Date(p.lastPlayedMs).toLocaleString()}
>
last seen {timeAgo(p.lastPlayedMs)}
</p>
</div>
<Badge
variant="secondary"
className="text-xs px-1.5 py-0 tabular-nums shrink-0"
>
{display}
</Badge>
</li>
);
})}
</ol>
{sorted.length > TOP_N && (
<div className="text-center mt-3">
<Button
size="sm"
variant="ghost"
onClick={() => setShowAll((v) => !v)}
className="text-xs"
>
{showAll ? "Show top 10" : `Show all ${sorted.length}`}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}