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:
parent
9ccd4ca772
commit
3a69dc9243
7 changed files with 521 additions and 214 deletions
221
components/Leaderboard.tsx
Normal file
221
components/Leaderboard.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue