From 9ccd4ca772e41f5a59298906cdfbed414fd8be05 Mon Sep 17 00:00:00 2001 From: hurkicorgi Date: Mon, 13 Apr 2026 06:24:13 -0600 Subject: [PATCH] Per-player playtime tracking + sparkline hover tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New GET /api/players/playtime (authed): reads each vanilla stats file under /home/minecraft/server/world/stats and maps uuid→name via usercache.json. Returns playtime in ticks and hours, plus file mtime as "last seen". Memoized 60s via lib/cache#memoAsync. - New PlaytimeLeaderboard card in the Players tab: - Rank, avatar (click → PlayerDrawer), relative last-seen, hours. - Top 10 by default with toggle to show all; combined hours badge. - PlayerDrawer surfaces the player's total hours + last-seen using the same cached playtime query, plus fills in UUID if the player is not in ops/whitelist/banned. - lib/time.ts: formatHours() — minutes under 1h, "H.Hh" under a day, "Dd Hh" above. - Analytics sparklines gain hover/touch tooltips: - Timestamps threaded through from each MetricEntry. - SVG overlay captures onMouseMove/onTouchMove, computes nearest index by x, draws a dashed guide line + focus dot. - Floating tooltip shows HH:MM + formatted value with unit; edge clamping keeps it in-frame at the extremes. - Per-chart formatValue so RAM shows GB, CPU 0 decimals, Players integer, TPS 1 decimal. Keeps the peak-player marker intact. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/players/playtime/route.ts | 88 ++++++++++++++ components/Analytics.tsx | 186 +++++++++++++++++++++++------ components/PlayerDrawer.tsx | 34 ++++++ components/PlayerManager.tsx | 4 + components/PlaytimeLeaderboard.tsx | 126 +++++++++++++++++++ lib/time.ts | 13 ++ 6 files changed, 413 insertions(+), 38 deletions(-) create mode 100644 app/api/players/playtime/route.ts create mode 100644 components/PlaytimeLeaderboard.tsx diff --git a/app/api/players/playtime/route.ts b/app/api/players/playtime/route.ts new file mode 100644 index 0000000..8462fcd --- /dev/null +++ b/app/api/players/playtime/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { readdirSync, readFileSync, statSync } from "fs"; +import { join } from "path"; +import { auth } from "@/lib/auth"; +import { memoAsync } from "@/lib/cache"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const STATS_DIR = "/home/minecraft/server/world/stats"; +const USERCACHE = "/home/minecraft/server/usercache.json"; +const TICKS_PER_HOUR = 20 * 60 * 60; + +type Entry = { + uuid: string; + name: string; + playtimeTicks: number; + playtimeHours: number; + lastPlayedMs: number; +}; + +type UserCacheEntry = { name: string; uuid: string }; + +function loadNameMap(): Map { + try { + const arr = JSON.parse(readFileSync(USERCACHE, "utf8")) as UserCacheEntry[]; + return new Map(arr.map((e) => [e.uuid, e.name])); + } catch { + return new Map(); + } +} + +function computePlaytime(): Entry[] { + const names = loadNameMap(); + let files: string[]; + try { + files = readdirSync(STATS_DIR).filter((f) => f.endsWith(".json")); + } catch { + return []; + } + + const out: Entry[] = []; + for (const f of files) { + const uuid = f.replace(/\.json$/, ""); + const path = join(STATS_DIR, f); + try { + const data = JSON.parse(readFileSync(path, "utf8")) as { + stats?: { "minecraft:custom"?: Record }; + }; + const ticks = Number( + data.stats?.["minecraft:custom"]?.["minecraft:play_time"] || 0 + ); + const mtime = statSync(path).mtimeMs; + out.push({ + uuid, + name: names.get(uuid) || uuid.slice(0, 8), + playtimeTicks: ticks, + playtimeHours: Math.round((ticks / TICKS_PER_HOUR) * 100) / 100, + lastPlayedMs: mtime, + }); + } catch { + // Skip unreadable/corrupt stat files + } + } + out.sort((a, b) => b.playtimeTicks - a.playtimeTicks); + return out; +} + +export async function GET() { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + try { + const data = await memoAsync("players:playtime", 60_000, async () => + computePlaytime() + ); + return NextResponse.json(data, { + headers: { "Cache-Control": "private, max-age=60" }, + }); + } catch (e) { + return NextResponse.json( + { error: (e as Error).message }, + { status: 500 } + ); + } +} diff --git a/components/Analytics.tsx b/components/Analytics.tsx index 66c2a90..b878acb 100644 --- a/components/Analytics.tsx +++ b/components/Analytics.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; import { PlayerAvatar } from "@/components/PlayerAvatar"; @@ -67,6 +67,14 @@ function UptimeRing({ percent, size = 64 }: { percent: number; size?: number }) ); } +function formatTime(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} + function Sparkline({ data, color, @@ -77,6 +85,8 @@ function Sparkline({ currentValue, peakIndex, peakLabel, + timestamps, + formatValue, }: { data: number[]; color: string; @@ -87,7 +97,52 @@ function Sparkline({ currentValue: string; peakIndex?: number; peakLabel?: string; + timestamps?: string[]; + formatValue?: (v: number) => string; }) { + const svgRef = useRef(null); + const [hoverIdx, setHoverIdx] = useState(null); + + const coords = useMemo(() => { + if (data.length < 2) return [] as { x: number; y: number }[]; + const dataMax = max || Math.max(...data, 1); + const width = 300; + return data.map((v, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - (v / dataMax) * (height - 10) - 5; + return { x, y }; + }); + }, [data, max, height]); + + const { pathD, areaD, w, peakXY } = useMemo(() => { + const width = 300; + const pts = coords.map((c) => `${c.x},${c.y}`); + const p = pts.length ? `M${pts.join(" L")}` : ""; + const peak = + typeof peakIndex === "number" && peakIndex >= 0 && peakIndex < coords.length + ? coords[peakIndex] + : null; + return { + pathD: p, + areaD: p ? `${p} L${width},${height} L0,${height} Z` : "", + w: width, + peakXY: peak, + }; + }, [coords, height, peakIndex]); + + const locate = (clientX: number) => { + const svg = svgRef.current; + if (!svg || data.length < 2) return; + const rect = svg.getBoundingClientRect(); + if (rect.width <= 0) return; + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + setHoverIdx(Math.round(ratio * (data.length - 1))); + }; + + const defaultFormat = (v: number) => + Number.isInteger(v) ? String(v) : v.toFixed(1); + const fmt = formatValue || defaultFormat; + if (data.length < 2) { return (
@@ -106,27 +161,14 @@ function Sparkline({ ); } - const { pathD, areaD, w, peakXY } = useMemo(() => { - const dataMax = max || Math.max(...data, 1); - const width = 300; - const coords = data.map((v, i) => { - const x = (i / (data.length - 1)) * width; - const y = height - (v / dataMax) * (height - 10) - 5; - return { x, y }; - }); - const pts = coords.map((c) => `${c.x},${c.y}`); - const p = `M${pts.join(" L")}`; - const peak = - typeof peakIndex === "number" && peakIndex >= 0 && peakIndex < coords.length - ? coords[peakIndex] - : null; - return { - pathD: p, - areaD: `${p} L${width},${height} L0,${height} Z`, - w: width, - peakXY: peak, - }; - }, [data, max, height, peakIndex]); + const hovered = hoverIdx !== null ? coords[hoverIdx] : null; + const hoverRatio = hoverIdx !== null ? hoverIdx / (data.length - 1) : 0; + const hoverTime = + hoverIdx !== null && timestamps?.[hoverIdx] + ? formatTime(timestamps[hoverIdx]) + : ""; + const hoverValue = + hoverIdx !== null ? `${fmt(data[hoverIdx])}${unit ? ` ${unit}` : ""}` : ""; return (
@@ -139,22 +181,82 @@ function Sparkline({

- - - - - - - - - - {peakXY && ( - <> - - - +
+ locate(e.clientX)} + onMouseLeave={() => setHoverIdx(null)} + onTouchMove={(e) => { + const t = e.touches[0]; + if (t) locate(t.clientX); + }} + onTouchEnd={() => setHoverIdx(null)} + role="img" + aria-label={`${label} over time`} + > + + + + + + + + + {peakXY && ( + <> + + + + )} + {hovered && ( + <> + + + + + )} + + {hovered && ( +
0.85 ? "-100%" : hoverRatio < 0.15 ? "0%" : "-50%" + }, -100%)`, + }} + > + {hoverTime && ( + {hoverTime} + )} + + {hoverValue} + +
)} - +
{peakLabel && (

peak {peakLabel} @@ -283,37 +385,45 @@ export function Analytics() {

m.tps)} + timestamps={metrics.map((m) => m.ts)} color="#4ade80" max={22} label="TPS" unit="" currentValue={latest ? latest.tps.toFixed(1) : "-"} + formatValue={(v) => v.toFixed(1)} /> m.ramUsedMB)} + timestamps={metrics.map((m) => m.ts)} color="#60a5fa" label="RAM" - unit="MB" + unit="GB" currentValue={ latest ? `${(latest.ramUsedMB / 1024).toFixed(1)} GB` : "-" } + formatValue={(v) => (v / 1024).toFixed(1)} /> m.cpuPercent)} + timestamps={metrics.map((m) => m.ts)} color="#f59e0b" max={100} label="CPU" unit="%" currentValue={latest ? latest.cpuPercent.toFixed(0) : "-"} + formatValue={(v) => v.toFixed(0)} /> m.playersOnline)} + timestamps={metrics.map((m) => m.ts)} color="#a78bfa" label="Players" unit="" currentValue={latest ? latest.playersOnline.toString() : "0"} peakIndex={peakPlayers > 0 ? peakIndex : undefined} peakLabel={peakPlayers > 0 ? peakPlayers.toString() : undefined} + formatValue={(v) => String(Math.round(v))} />
diff --git a/components/PlayerDrawer.tsx b/components/PlayerDrawer.tsx index bf4c665..18055fa 100644 --- a/components/PlayerDrawer.tsx +++ b/components/PlayerDrawer.tsx @@ -10,6 +10,8 @@ import { Input } from "@/components/ui/input"; 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"; type PlayerData = { ops: { name: string; uuid: string; level: number }[]; @@ -45,6 +47,17 @@ export function PlayerDrawer() { staleTime: 10_000, }); + const playtime = useQuery({ + queryKey: ["players-playtime"], + queryFn: () => fetch("/api/players/playtime").then((r) => r.json()), + enabled: authed && !!name, + staleTime: 60_000, + }); + + const myPlaytime = name + ? playtime.data?.find((p) => p.name === name) + : undefined; + // Use existing analytics cache (last entry) to determine online now const analytics = queryClient.getQueryData(["analytics", 6]); const onlineNow = @@ -144,6 +157,26 @@ export function PlayerDrawer() {

) : ( <> + {myPlaytime && ( +
+
+

Playtime

+

+ {formatHours(myPlaytime.playtimeHours)} +

+
+
+

Last seen

+

+ {timeAgo(myPlaytime.lastPlayedMs)} +

+
+
+ )} + {bannedEntry?.reason && (

@@ -226,6 +259,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 || "—"}

diff --git a/components/PlayerManager.tsx b/components/PlayerManager.tsx index b32280f..3298758 100644 --- a/components/PlayerManager.tsx +++ b/components/PlayerManager.tsx @@ -15,6 +15,7 @@ import { CardTitle, } from "@/components/ui/card"; import { PlayerAvatar } from "@/components/PlayerAvatar"; +import { PlaytimeLeaderboard } from "@/components/PlaytimeLeaderboard"; type PlayerData = { ops: { name: string; uuid: string; level: number }[]; @@ -127,6 +128,8 @@ export function PlayerManager() { }; return ( +
+ Player Management @@ -296,5 +299,6 @@ export function PlayerManager() { )} +
); } diff --git a/components/PlaytimeLeaderboard.tsx b/components/PlaytimeLeaderboard.tsx new file mode 100644 index 0000000..1590df9 --- /dev/null +++ b/components/PlaytimeLeaderboard.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { 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 { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export type PlaytimeEntry = { + uuid: string; + name: string; + playtimeTicks: number; + playtimeHours: number; + lastPlayedMs: number; +}; + +const TOP_N = 10; + +export function PlaytimeLeaderboard() { + const [showAll, setShowAll] = useState(false); + const { data = [], isLoading, isError } = useQuery({ + queryKey: ["players-playtime"], + queryFn: () => fetch("/api/players/playtime").then((r) => r.json()), + staleTime: 60_000, + refetchInterval: 5 * 60_000, + }); + + const visible = showAll ? data : data.slice(0, TOP_N); + const totalHours = data.reduce((sum, e) => sum + e.playtimeHours, 0); + + return ( + + +
+
+ Playtime + + Total hours played by each whitelisted player + +
+ {data.length > 0 && ( +
+

Combined

+

+ {formatHours(totalHours)} +

+
+ )} +
+
+ + {isLoading ? ( +
    + {Array.from({ length: 5 }).map((_, i) => ( +
  • + + + +
  • + ))} +
+ ) : isError ? ( +

+ Failed to load playtime. +

+ ) : data.length === 0 ? ( +

+ No playtime recorded yet. +

+ ) : ( + <> +
    + {visible.map((p, i) => ( +
  1. + + {i + 1} + + +
    +

    {p.name}

    +

    + last seen {timeAgo(p.lastPlayedMs)} +

    +
    + + {formatHours(p.playtimeHours)} + +
  2. + ))} +
+ {data.length > TOP_N && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/lib/time.ts b/lib/time.ts index af014e2..9f6fa1c 100644 --- a/lib/time.ts +++ b/lib/time.ts @@ -15,6 +15,19 @@ export function timeAgo(iso: string | number | Date): string { return `${yr}y ago`; } +// Format a duration in hours as "H.Hh" under a day, otherwise "Dd Hh". +export function formatHours(hours: number): string { + if (!isFinite(hours) || hours <= 0) return "0h"; + if (hours < 1) { + const m = Math.round(hours * 60); + return `${m}m`; + } + if (hours < 24) return `${hours.toFixed(1)}h`; + const days = Math.floor(hours / 24); + const rem = Math.round(hours - days * 24); + return rem ? `${days}d ${rem}h` : `${days}d`; +} + export function formatBytes(n: number): string { if (!isFinite(n) || n <= 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"];