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:
+21
-12
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user