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({
-
) : (
<>
+ {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) => (
+ -
+
+ {i + 1}
+
+
+
+
{p.name}
+
+ last seen {timeAgo(p.lastPlayedMs)}
+
+
+
+ {formatHours(p.playtimeHours)}
+
+
+ ))}
+
+ {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"];