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
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue