Files
OverSnitch/src/components/LeaderboardTable.tsx
Josh Wright f871f86284 Build OverSnitch dashboard
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>
2026-04-12 11:13:57 -04:00

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>
);
}