Refactor user detail page: split components, unify formatters

Break the 615-line UserDetail.tsx into focused sub-components
(header, stat cards, activity chart, request history, open alerts)
and extract shared utilities to lib/ (format, userChart,
enrichRequests). Promote storage load (GB/hr) to a stat card and
collapse the chart UX to a single metric picker. Add server-wide
average reference line alongside the user's own on every metric,
and link request titles to their Seerr pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 10:00:21 -04:00
parent b2c1642065
commit 74588e50f6
18 changed files with 994 additions and 664 deletions
+21 -12
View File
@@ -98,25 +98,34 @@ export function computeStats(
)
: null;
const userStats: UserStat[] = rawStats.map((raw) => {
// First pass: compute totalGB, avgGB, and loadGBPerHour (needs totalGB)
const enriched = rawStats.map((raw) => {
const totalGB = bytesToGB(raw.totalBytes);
const avgGB =
raw.requestCount > 0
? Math.round((totalGB / raw.requestCount) * 10) / 10
: 0;
return {
...raw,
totalGB,
avgGB,
storageRank: storageRanks.get(raw.userId) ?? totalUsers,
requestRank: requestRanks.get(raw.userId) ?? totalUsers,
playsRank: playsRanks?.get(raw.userId) ?? null,
watchRank: watchRanks?.get(raw.userId) ?? null,
totalUsers,
};
const loadGBPerHour =
hasTautulli && raw.watchHours !== null && raw.watchHours > 0
? Math.round((totalGB / raw.watchHours) * 10) / 10
: null;
return { ...raw, totalGB, avgGB, loadGBPerHour };
});
const loadRanks = hasTautulli
? computeRanks(enriched.map((r) => ({ userId: r.userId, value: r.loadGBPerHour })))
: null;
const userStats: UserStat[] = enriched.map((raw) => ({
...raw,
storageRank: storageRanks.get(raw.userId) ?? totalUsers,
requestRank: requestRanks.get(raw.userId) ?? totalUsers,
playsRank: playsRanks?.get(raw.userId) ?? null,
watchRank: watchRanks?.get(raw.userId) ?? null,
loadRank: loadRanks?.get(raw.userId) ?? null,
totalUsers,
}));
// Generate alert candidates and persist to DB
const candidates = generateAlertCandidates(
userStats,
+46
View File
@@ -0,0 +1,46 @@
import { OverseerrRequest, MediaEntry, EnrichedRequest } from "@/lib/types";
import { bytesToGB } from "@/lib/aggregate";
export function enrichRequests(
userRequests: OverseerrRequest[],
radarrMap: Map<number, MediaEntry>,
sonarrMap: Map<number, MediaEntry>,
seerrBaseUrl?: string
): EnrichedRequest[] {
return userRequests.map((req) => {
let sizeOnDisk = 0;
let title = req.media.title ?? "";
if (req.type === "movie") {
const entry = radarrMap.get(req.media.tmdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
} else if (req.type === "tv" && req.media.tvdbId) {
const entry = sonarrMap.get(req.media.tvdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
}
if (!title) {
title = req.type === "movie"
? `Movie #${req.media.tmdbId}`
: `Show #${req.media.tmdbId}`;
}
const seerrUrl = seerrBaseUrl
? `${seerrBaseUrl}/${req.type === "movie" ? "movie" : "tv"}/${req.media.tmdbId}`
: undefined;
return {
id: req.id,
type: req.type,
status: req.status,
createdAt: req.createdAt,
mediaId: req.type === "movie" ? req.media.tmdbId : (req.media.tvdbId ?? 0),
title,
sizeOnDisk,
sizeGB: bytesToGB(sizeOnDisk),
seerrUrl,
};
});
}
+27
View File
@@ -0,0 +1,27 @@
export function formatGB(gb: number): string {
if (gb >= 1000) return `${(gb / 1000).toFixed(2)} TB`;
return `${gb.toFixed(1)} GB`;
}
export function formatHours(h: number): string {
if (h >= 1000) return `${(h / 1000).toFixed(1)}k h`;
return `${h.toFixed(0)}h`;
}
export function timeAgo(iso: string | null | undefined): string {
if (!iso) return "Never";
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", year: "numeric" });
}
export function unixTimeAgo(ts: number | null): string {
if (ts === null) return "Never";
return timeAgo(new Date(ts * 1000).toISOString());
}
+25 -3
View File
@@ -9,9 +9,15 @@
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap } from "@/lib/tautulli";
import { buildTautulliMap, lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { DashboardStats, MediaEntry, OverseerrRequest, TautulliUser } from "@/lib/types";
import {
DashboardStats,
MediaEntry,
OverseerrRequest,
TautulliUser,
WatchDataPoint,
} from "@/lib/types";
const BATCH_SIZE = 5;
const STALE_MS = 5 * 60 * 1000;
@@ -28,6 +34,8 @@ export interface RawCache {
sonarrMap: Map<number, MediaEntry>;
allRequests: Map<number, OverseerrRequest[]>;
tautulliMap: Map<string, TautulliUser> | null;
/** Per-user daily watch history, keyed by Overseerr user id. Empty map if Tautulli isn't configured. */
watchHistoryMap: Map<number, WatchDataPoint[]>;
}
let rawCache: RawCache | null = null;
@@ -53,7 +61,21 @@ async function buildStats(): Promise<DashboardStats> {
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap };
const watchHistoryMap = new Map<number, WatchDataPoint[]>();
if (tautulliMap) {
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
chunk.map(async (u) => {
const tu = lookupTautulliUser(tautulliMap, u.email, u.displayName);
return tu ? fetchUserWatchHistory(tu.user_id) : [];
})
);
chunk.forEach((u, idx) => watchHistoryMap.set(u.id, results[idx]));
}
}
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap, watchHistoryMap };
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
}
+18
View File
@@ -78,11 +78,14 @@ export interface UserStat {
plays: number | null;
watchHours: number | null;
tautulliLastSeen: number | null; // unix timestamp (seconds), null if no Tautulli data
// GB requested ÷ hours watched. null when Tautulli is off or the user has no watch time.
loadGBPerHour: number | null;
// Per-metric ranks (1 = top user for that metric, null = Tautulli not available)
storageRank: number;
requestRank: number;
playsRank: number | null;
watchRank: number | null;
loadRank: number | null;
totalUsers: number;
}
@@ -143,6 +146,8 @@ export interface EnrichedRequest {
title: string;
sizeOnDisk: number; // bytes
sizeGB: number;
/** Deep link to the item's page in Seerr. Undefined if seerr.url isn't configured. */
seerrUrl?: string;
}
/** One day of watch activity from Tautulli */
@@ -152,11 +157,24 @@ export interface WatchDataPoint {
durationHours: number;
}
export type Timeframe = "1W" | "1M" | "3M" | "1Y";
/** Per-bucket means across all users for the given timeframe. */
export interface TimeframeServerAverages {
requests: number;
gb: number;
watchHours: number | null; // null if Tautulli is off
load: number | null; // null if Tautulli is off
}
export type ServerAverages = Record<Timeframe, TimeframeServerAverages>;
export interface UserPageData {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
openAlerts: Alert[];
serverAverages: ServerAverages;
}
/** Full persisted alert returned by the API */
+97
View File
@@ -0,0 +1,97 @@
import { EnrichedRequest, WatchDataPoint, Timeframe } from "@/lib/types";
export const TIMEFRAMES: Timeframe[] = ["1W", "1M", "3M", "1Y"];
export const TF_CONFIG: Record<Timeframe, { rangeDays: number; bucketDays: number }> = {
"1W": { rangeDays: 7, bucketDays: 1 },
"1M": { rangeDays: 30, bucketDays: 1 },
"3M": { rangeDays: 91, bucketDays: 7 },
"1Y": { rangeDays: 365, bucketDays: 30 },
};
export interface ChartPoint {
label: string;
requests: number;
gb: number;
watchHours: number;
/**
* Running cumulative GB ÷ cumulative watch hours as of the end of this bucket —
* including all history before the window. A new request bumps it up; more
* watching drags it back down. null until the user has any watch hours.
*/
load: number | null;
}
export function buildUserChartPoints(
enrichedRequests: EnrichedRequest[],
watchHistory: WatchDataPoint[],
tf: Timeframe
): ChartPoint[] {
const now = Date.now();
const MS = 86_400_000;
const { rangeDays, bucketDays } = TF_CONFIG[tf];
const numBuckets = Math.ceil(rangeDays / bucketDays);
const points: ChartPoint[] = Array.from({ length: numBuckets }, (_, i) => {
const midMs = now - (numBuckets - 1 - i + 0.5) * bucketDays * MS;
const d = new Date(midMs);
const label =
bucketDays >= 28
? d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
: d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return { label, requests: 0, gb: 0, watchHours: 0, load: null };
});
// Per-bucket flows: count requests / GB / watch hours that landed inside the bucket.
for (const req of enrichedRequests) {
const ageMs = now - new Date(req.createdAt).getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].requests += 1;
points[idx].gb = Math.round((points[idx].gb + req.sizeGB) * 10) / 10;
}
}
for (const wh of watchHistory) {
const ageMs = now - new Date(wh.date + "T12:00:00").getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].watchHours = Math.round((points[idx].watchHours + wh.durationHours) * 10) / 10;
}
}
// Running load: sweep all requests / history (including before the window) and
// accumulate GB + hours up to the end of each bucket.
const reqSorted = [...enrichedRequests].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const watchSorted = [...watchHistory].sort((a, b) => a.date.localeCompare(b.date));
let reqIdx = 0;
let watchIdx = 0;
let cumGB = 0;
let cumHours = 0;
for (let i = 0; i < numBuckets; i++) {
const bucketEndMs = now - (numBuckets - 1 - i) * bucketDays * MS;
while (
reqIdx < reqSorted.length &&
new Date(reqSorted[reqIdx].createdAt).getTime() <= bucketEndMs
) {
cumGB += reqSorted[reqIdx].sizeGB;
reqIdx++;
}
while (
watchIdx < watchSorted.length &&
new Date(watchSorted[watchIdx].date + "T12:00:00").getTime() <= bucketEndMs
) {
cumHours += watchSorted[watchIdx].durationHours;
watchIdx++;
}
points[i].load = cumHours > 0 ? Math.round((cumGB / cumHours) * 10) / 10 : null;
}
return points;
}