Full implementation on top of the Next.js scaffold: - Leaderboard with per-user request count, storage, avg GB/req, and optional Tautulli watch stats (plays, watch hours), each with dense per-metric rank (#N/total) - SWR cache on /api/stats (5-min stale, force-refresh via button); client-side localStorage seed so the UI is instant on return visits - Alerting system: content-centric alerts (unfulfilled downloads, partial TV downloads, stale pending requests) and user-behavior alerts (ghost requester, low watch rate, declined streak) - Partial TV detection: flags ended series with <90% of episodes on disk - Alert persistence in data/alerts.json with open/closed state, auto-resolve when condition clears, manual close with per-category cooldown, and per-alert notes - Alert detail page rendered as a server component for instant load - Dark UI with Tailwind v4, severity-colored left borders, summary cards with icons, sortable leaderboard table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { UserStat } from "@/lib/types";
|
|
|
|
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
|
|
|
interface LeaderboardTableProps {
|
|
users: UserStat[];
|
|
hasTautulli: boolean;
|
|
}
|
|
|
|
function formatGB(gb: number): string {
|
|
return gb >= 1000 ? `${(gb / 1000).toFixed(2)} TB` : `${gb.toFixed(1)} GB`;
|
|
}
|
|
|
|
function formatHours(h: number): string {
|
|
if (h >= 1000) return `${(h / 1000).toFixed(1)}k h`;
|
|
return `${h.toFixed(0)}h`;
|
|
}
|
|
|
|
function Rank({ rank, total }: { rank: number | null; total: number }) {
|
|
if (rank === null) return <span className="text-slate-700">—</span>;
|
|
return (
|
|
<span className="text-xs font-mono text-slate-500">
|
|
#{rank}<span className="text-slate-700">/{total}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SortChevrons({ active, asc }: { active: boolean; asc: boolean }) {
|
|
return (
|
|
<span className="ml-1 inline-flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{active ? (
|
|
<svg
|
|
className="h-3 w-3 text-yellow-400"
|
|
viewBox="0 0 12 12"
|
|
fill="currentColor"
|
|
>
|
|
{asc
|
|
? <path d="M6 2 L10 8 L2 8 Z" />
|
|
: <path d="M6 10 L10 4 L2 4 Z" />
|
|
}
|
|
</svg>
|
|
) : (
|
|
<svg className="h-3 w-3 text-slate-600" viewBox="0 0 12 12" fill="currentColor">
|
|
<path d="M6 1 L9 5 L3 5 Z M6 11 L9 7 L3 7 Z" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function LeaderboardTable({
|
|
users,
|
|
hasTautulli,
|
|
}: LeaderboardTableProps) {
|
|
const [sortKey, setSortKey] = useState<SortKey>("totalBytes");
|
|
const [sortAsc, setSortAsc] = useState(false);
|
|
|
|
const sorted = [...users].sort((a, b) => {
|
|
const av = (a[sortKey] ?? -1) as number;
|
|
const bv = (b[sortKey] ?? -1) as number;
|
|
return sortAsc ? av - bv : bv - av;
|
|
});
|
|
|
|
function handleSort(key: SortKey) {
|
|
if (key === sortKey) setSortAsc((p) => !p);
|
|
else { setSortKey(key); setSortAsc(false); }
|
|
}
|
|
|
|
function Th({
|
|
label,
|
|
col,
|
|
right,
|
|
}: {
|
|
label: string;
|
|
col?: SortKey;
|
|
right?: boolean;
|
|
}) {
|
|
const active = col === sortKey;
|
|
return (
|
|
<th
|
|
onClick={col ? () => handleSort(col) : undefined}
|
|
className={[
|
|
"group py-3 px-4 text-xs font-semibold uppercase tracking-wider whitespace-nowrap select-none",
|
|
right ? "text-right" : "text-left",
|
|
col ? "cursor-pointer" : "",
|
|
active ? "text-yellow-400" : "text-slate-500 hover:text-slate-300",
|
|
].join(" ")}
|
|
>
|
|
{label}
|
|
{col && <SortChevrons active={active} asc={sortAsc} />}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
const total = users.length;
|
|
|
|
return (
|
|
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-800/80 border-b border-slate-700/60">
|
|
<tr>
|
|
<Th label="#" />
|
|
<Th label="User" />
|
|
<Th label="Requests" col="requestCount" right />
|
|
<Th label="Storage" col="totalBytes" right />
|
|
<Th label="Avg / Req" col="avgGB" right />
|
|
{hasTautulli && <Th label="Plays" col="plays" right />}
|
|
{hasTautulli && <Th label="Watch Time" col="watchHours" right />}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-700/30">
|
|
{sorted.map((user, idx) => (
|
|
<tr
|
|
key={user.userId}
|
|
className="bg-slate-900 hover:bg-slate-800/50 transition-colors"
|
|
>
|
|
{/* Row index */}
|
|
<td className="py-3 px-4 text-slate-700 font-mono text-xs w-10 tabular-nums">
|
|
{idx + 1}
|
|
</td>
|
|
|
|
{/* User */}
|
|
<td className="py-3 px-4">
|
|
<div className="font-medium text-white leading-snug">{user.displayName}</div>
|
|
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
|
|
</td>
|
|
|
|
{/* Requests */}
|
|
<td className="py-3 px-4 text-right">
|
|
<div className="text-slate-300 font-mono tabular-nums">
|
|
{user.requestCount.toLocaleString()}
|
|
</div>
|
|
<div className="mt-0.5 flex justify-end">
|
|
<Rank rank={user.requestRank} total={total} />
|
|
</div>
|
|
</td>
|
|
|
|
{/* Storage */}
|
|
<td className="py-3 px-4 text-right">
|
|
<div className="text-white font-semibold font-mono tabular-nums">
|
|
{formatGB(user.totalGB)}
|
|
</div>
|
|
<div className="mt-0.5 flex justify-end">
|
|
<Rank rank={user.storageRank} total={total} />
|
|
</div>
|
|
</td>
|
|
|
|
{/* Avg / Request */}
|
|
<td className="py-3 px-4 text-right text-slate-500 font-mono text-xs tabular-nums">
|
|
{user.requestCount > 0 ? formatGB(user.avgGB) : "—"}
|
|
</td>
|
|
|
|
{/* Plays */}
|
|
{hasTautulli && (
|
|
<td className="py-3 px-4 text-right">
|
|
<div className="text-slate-300 font-mono tabular-nums">
|
|
{user.plays !== null ? user.plays.toLocaleString() : "—"}
|
|
</div>
|
|
<div className="mt-0.5 flex justify-end">
|
|
<Rank rank={user.playsRank} total={total} />
|
|
</div>
|
|
</td>
|
|
)}
|
|
|
|
{/* Watch Time */}
|
|
{hasTautulli && (
|
|
<td className="py-3 px-4 text-right">
|
|
<div className="text-slate-500 font-mono text-xs tabular-nums">
|
|
{user.watchHours !== null && user.watchHours > 0
|
|
? formatHours(user.watchHours)
|
|
: user.watchHours === 0
|
|
? "0h"
|
|
: "—"}
|
|
</div>
|
|
<div className="mt-0.5 flex justify-end">
|
|
<Rank rank={user.watchRank} total={total} />
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|