diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index a611a82..855829f 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -1,8 +1,63 @@ -import { getStats, getRawCache } from "@/lib/statsBuilder"; -import { lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli"; +import { getStats, getRawCache, RawCache } from "@/lib/statsBuilder"; import { getAllAlerts } from "@/lib/db"; -import { bytesToGB } from "@/lib/aggregate"; -import { EnrichedRequest, UserPageData } from "@/lib/types"; +import { enrichRequests } from "@/lib/enrichRequests"; +import { getSettings } from "@/lib/settings"; +import { buildUserChartPoints, TIMEFRAMES } from "@/lib/userChart"; +import { + ServerAverages, + Timeframe, + TimeframeServerAverages, + UserPageData, + UserStat, +} from "@/lib/types"; + +function meanOrNull(values: (number | null)[]): number | null { + const clean = values.filter((v): v is number => v !== null); + if (clean.length === 0) return null; + return Math.round((clean.reduce((s, v) => s + v, 0) / clean.length) * 10) / 10; +} + +function computeServerAverages(users: UserStat[], raw: RawCache): ServerAverages { + const result = {} as Record; + + for (const tf of TIMEFRAMES) { + const userPoints = users.map((u) => { + const reqs = enrichRequests( + raw.allRequests.get(u.userId) ?? [], + raw.radarrMap, + raw.sonarrMap + ); + const wh = raw.watchHistoryMap.get(u.userId) ?? []; + return { user: u, points: buildUserChartPoints(reqs, wh, tf) }; + }); + + // Per-user per-bucket means, then mean across users. + const userMeans = userPoints.map(({ user, points }) => ({ + hasTautulli: user.plays !== null, + requests: meanOrNull(points.map((p) => p.requests)), + gb: meanOrNull(points.map((p) => p.gb)), + watchHours: meanOrNull(points.map((p) => p.watchHours)), + load: meanOrNull(points.map((p) => p.load)), + })); + + const tautulliMeans = userMeans.filter((m) => m.hasTautulli); + + result[tf] = { + requests: meanOrNull(userMeans.map((m) => m.requests)) ?? 0, + gb: meanOrNull(userMeans.map((m) => m.gb)) ?? 0, + watchHours: + tautulliMeans.length > 0 + ? meanOrNull(tautulliMeans.map((m) => m.watchHours)) + : null, + load: + tautulliMeans.length > 0 + ? meanOrNull(tautulliMeans.map((m) => m.load)) + : null, + }; + } + + return result; +} export async function GET( _req: Request, @@ -15,73 +70,41 @@ export async function GET( } try { - // Find the user in the cached stats (triggers a build if cache is cold) const stats = await getStats(); const stat = stats.users.find((u) => u.userId === userId); if (!stat) { return Response.json({ error: "User not found" }, { status: 404 }); } - // Enrich requests with resolved title + size from cached media maps const raw = getRawCache(); - const userRequests = raw?.allRequests.get(userId) ?? []; - - const enrichedRequests: EnrichedRequest[] = userRequests.map((req) => { - let sizeOnDisk = 0; - let title = req.media.title ?? ""; - - if (req.type === "movie") { - const entry = raw?.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 = raw?.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}`; - } - - 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), - }; - }); - - // Fetch watch history from Tautulli (if available) - let tautulliUserId: number | null = null; - if (raw?.tautulliMap) { - const tu = lookupTautulliUser(raw.tautulliMap, stat.email, stat.displayName); - tautulliUserId = tu?.user_id ?? null; + if (!raw) { + return Response.json({ error: "Raw cache unavailable" }, { status: 503 }); } - const watchHistory = tautulliUserId - ? await fetchUserWatchHistory(tautulliUserId) - : []; + const seerrBaseUrl = getSettings().seerr.url || undefined; + const enrichedRequests = enrichRequests( + raw.allRequests.get(userId) ?? [], + raw.radarrMap, + raw.sonarrMap, + seerrBaseUrl + ); - // Open alerts involving this user - const allAlerts = getAllAlerts(); - const openAlerts = allAlerts.filter( + const watchHistory = raw.watchHistoryMap.get(userId) ?? []; + + const openAlerts = getAllAlerts().filter( (a) => a.status === "open" && (a.userId === userId || a.requesterIds?.includes(userId)) ); + const serverAverages = computeServerAverages(stats.users, raw); + const result: UserPageData = { stat, enrichedRequests, watchHistory, openAlerts, + serverAverages, }; return Response.json(result); diff --git a/src/app/page.tsx b/src/app/page.tsx index 7310864..0b3009f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { DashboardStats } from "@/lib/types"; +import { timeAgo } from "@/lib/format"; import SummaryCards from "@/components/SummaryCards"; import LeaderboardTable from "@/components/LeaderboardTable"; import AlertsPanel from "@/components/AlertsPanel"; @@ -11,16 +12,6 @@ import SettingsModal from "@/components/SettingsModal"; type Tab = "leaderboard" | "alerts"; const LS_KEY = "oversnitch_stats"; -function timeAgo(iso: string): string { - 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`; - return new Date(iso).toLocaleDateString(); -} - export default function Page() { const [tab, setTab] = useState("leaderboard"); const [data, setData] = useState(null); diff --git a/src/app/users/[id]/UserActivityChart.tsx b/src/app/users/[id]/UserActivityChart.tsx new file mode 100644 index 0000000..dd1f561 --- /dev/null +++ b/src/app/users/[id]/UserActivityChart.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceLine, +} from "recharts"; +import { + EnrichedRequest, + WatchDataPoint, + UserStat, + Timeframe, + ServerAverages, +} from "@/lib/types"; +import { + buildUserChartPoints, + ChartPoint, + TIMEFRAMES, +} from "@/lib/userChart"; +import { formatGB } from "@/lib/format"; + +type MetricKey = "requests" | "gb" | "watchHours" | "load"; + +interface MetricConfig { + key: MetricKey; + label: string; + color: string; + /** how the visible data values compare against ChartPoint fields */ + dataKey: keyof ChartPoint; + /** show the line only when Tautulli is configured */ + tautulliOnly?: boolean; + /** formats the raw numeric value for tooltip and reference-line labels */ + format: (v: number) => string; + /** formats the Y axis tick */ + tickFormat: (v: number) => string; + /** reserved Y axis width */ + yWidth: number; +} + +const METRICS: Record = { + requests: { + key: "requests", + label: "Requests", + color: "#3b82f6", + dataKey: "requests", + format: (v) => `${v}`, + tickFormat: (v) => `${v}`, + yWidth: 32, + }, + gb: { + key: "gb", + label: "Storage", + color: "#facc15", + dataKey: "gb", + format: (v) => formatGB(v), + tickFormat: (v) => `${v}G`, + yWidth: 40, + }, + watchHours: { + key: "watchHours", + label: "Watch Hours", + color: "#4ade80", + dataKey: "watchHours", + tautulliOnly: true, + format: (v) => `${v}h`, + tickFormat: (v) => `${v}h`, + yWidth: 38, + }, + load: { + key: "load", + label: "Storage Load", + color: "#f97316", + dataKey: "load", + tautulliOnly: true, + format: (v) => `${v} GB/hr`, + tickFormat: (v) => `${v}G/h`, + yWidth: 44, + }, +}; + +const SERVER_COLOR = "#94a3b8"; // slate-400 + +export default function UserActivityChart({ + stat, + enrichedRequests, + watchHistory, + serverAverages, +}: { + stat: UserStat; + enrichedRequests: EnrichedRequest[]; + watchHistory: WatchDataPoint[]; + serverAverages: ServerAverages; +}) { + const hasTautulli = stat.plays !== null; + const [tf, setTf] = useState("1M"); + const [metric, setMetric] = useState("gb"); + + const points = useMemo( + () => buildUserChartPoints(enrichedRequests, watchHistory, tf), + [enrichedRequests, watchHistory, tf] + ); + + const cfg = METRICS[metric]; + + const availableMetrics: MetricKey[] = (["requests", "gb", "watchHours", "load"] as MetricKey[]) + .filter((k) => hasTautulli || !METRICS[k].tautulliOnly); + + // User avg = mean of visible buckets (flows). For load, use the overall scalar. + const userAvg = useMemo(() => { + if (metric === "load") return stat.loadGBPerHour; + const vals = points + .map((p) => p[cfg.dataKey]) + .filter((v): v is number => typeof v === "number"); + if (vals.length === 0) return null; + return Math.round((vals.reduce((s, v) => s + v, 0) / vals.length) * 10) / 10; + }, [metric, points, cfg.dataKey, stat.loadGBPerHour]); + + const serverAvg = serverAverages[tf][metric]; + + const isEmpty = points.every( + (p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0 + ); + + return ( +
+
+
+ {availableMetrics.map((k) => { + const m = METRICS[k]; + const active = k === metric; + return ( + + ); + })} +
+ +
+ {TIMEFRAMES.map((t) => ( + + ))} +
+
+ + + + + + + { + const num = typeof value === "number" ? value : Number(value); + return [cfg.format(num), cfg.label]; + }} + /> + {userAvg !== null && ( + + )} + {serverAvg !== null && ( + + )} + + + + + {isEmpty && ( +

+ No activity in this period +

+ )} +
+ ); +} diff --git a/src/app/users/[id]/UserDetail.tsx b/src/app/users/[id]/UserDetail.tsx index bb1866d..2b0feca 100644 --- a/src/app/users/[id]/UserDetail.tsx +++ b/src/app/users/[id]/UserDetail.tsx @@ -1,233 +1,18 @@ "use client"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, - ReferenceLine, -} from "recharts"; -import { - EnrichedRequest, - WatchDataPoint, - UserPageData, - AlertSeverity, -} from "@/lib/types"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function formatGB(gb: number): string { - if (gb === 0) return "—"; - 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 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" }); -} - -function unixTimeAgo(ts: number | null): string { - if (ts === null) return "Never"; - return timeAgo(new Date(ts * 1000).toISOString()); -} - -// ── Status badge ────────────────────────────────────────────────────────────── - -// Overseerr media status codes (media.status, not request approval status): -// 1=Unknown, 2=Pending, 3=Processing, 4=Partially Available, 5=Available -const STATUS_LABEL: Record = { - 1: "Unknown", - 2: "Pending", - 3: "Processing", - 4: "Partial", - 5: "Available", -}; - -const STATUS_COLOR: Record = { - 1: "bg-slate-700/30 text-slate-500 border-slate-600/40", - 2: "bg-yellow-500/15 text-yellow-400 border-yellow-700/40", - 3: "bg-blue-500/15 text-blue-400 border-blue-700/40", - 4: "bg-cyan-500/15 text-cyan-400 border-cyan-700/40", - 5: "bg-green-500/15 text-green-400 border-green-700/40", -}; - -function StatusBadge({ status }: { status: number }) { - return ( - - {STATUS_LABEL[status] ?? `Status ${status}`} - - ); -} - -// ── Rank chip ───────────────────────────────────────────────────────────────── - -function RankChip({ rank, total }: { rank: number | null; total: number }) { - if (rank === null) return null; - return ( - - #{rank}/{total} - - ); -} - -// ── Stat cards ──────────────────────────────────────────────────────────────── - -function StatCard({ label, value, rank, total, highlight }: { - label: string; - value: string; - rank?: number | null; - total?: number; - highlight?: boolean; -}) { - return ( -
- {label} - - {value} - - {rank !== undefined && rank !== null && total !== undefined && ( - - )} -
- ); -} - -// ── Chart ───────────────────────────────────────────────────────────────────── - -type Timeframe = "1W" | "1M" | "3M" | "1Y"; - -const TF_CONFIG: Record = { - "1W": { rangeDays: 7, bucketDays: 1 }, - "1M": { rangeDays: 30, bucketDays: 1 }, - "3M": { rangeDays: 91, bucketDays: 7 }, - "1Y": { rangeDays: 365, bucketDays: 30 }, -}; - -interface ChartPoint { - label: string; - requests: number; - gb: number; - plays: number; - watchHours: number; - /** GB requested ÷ watch hours. null when watchHours = 0 (no denominator). */ - load: number | null; -} - -function buildChartPoints( - 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, plays: 0, watchHours: 0, load: null }; - }); - - 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].plays += wh.plays; - points[idx].watchHours = Math.round((points[idx].watchHours + wh.durationHours) * 10) / 10; - } - } - - for (const p of points) { - p.load = p.watchHours > 0 ? Math.round((p.gb / p.watchHours) * 10) / 10 : null; - } - - return points; -} - -/** - * Normalize each series to % of its own period average. - * A value equal to the mean shows as 100; double the mean shows as 200. - * Series with a mean of 0 stay at 0. load is a ratio — not normalized. - */ -function normalizeData(points: ChartPoint[]): ChartPoint[] { - if (points.length === 0) return points; - const n = points.length; - const meanReq = points.reduce((s, p) => s + p.requests, 0) / n; - const meanGb = points.reduce((s, p) => s + p.gb, 0) / n; - const meanWh = points.reduce((s, p) => s + p.watchHours, 0) / n; - - return points.map((p) => ({ - label: p.label, - requests: meanReq > 0 ? Math.round((p.requests / meanReq) * 100) : 0, - gb: meanGb > 0 ? Math.round((p.gb / meanGb) * 100) : 0, - plays: p.plays, - watchHours: meanWh > 0 ? Math.round((p.watchHours / meanWh) * 100) : 0, - load: p.load, - })); -} - -// ── Alert severity helpers ──────────────────────────────────────────────────── - -const SEV_COLOR: Record = { - danger: "border-l-red-500 bg-red-950/20", - warning: "border-l-yellow-500 bg-yellow-950/10", - info: "border-l-blue-500 bg-blue-950/20", -}; - -const SEV_TEXT: Record = { - danger: "text-red-400", - warning: "text-yellow-400", - info: "text-blue-400", -}; - -// ── Main component ───────────────────────────────────────────────────────────── +import { UserPageData } from "@/lib/types"; +import UserHeader from "./UserHeader"; +import UserStatCards from "./UserStatCards"; +import UserActivityChart from "./UserActivityChart"; +import UserRequestHistory from "./UserRequestHistory"; +import UserOpenAlerts from "./UserOpenAlerts"; export default function UserDetail({ userId }: { userId: number }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [tf, setTf] = useState("1M"); - const [viewMode, setViewMode] = useState<"metrics" | "load">("metrics"); - const [normalized, setNormalized] = useState(false); - const [showRequests, setShowRequests] = useState(true); - const [showStorage, setShowStorage] = useState(true); - const [showWatchHours, setShowWatchHours] = useState(true); - const [showAll, setShowAll] = useState(false); useEffect(() => { setLoading(true); @@ -242,28 +27,6 @@ export default function UserDetail({ userId }: { userId: number }) { .finally(() => setLoading(false)); }, [userId]); - const chartData = useMemo(() => { - if (!data) return []; - return buildChartPoints(data.enrichedRequests, data.watchHistory, tf); - }, [data, tf]); - - const displayData = useMemo( - () => (normalized && viewMode === "metrics" ? normalizeData(chartData) : chartData), - [chartData, normalized, viewMode] - ); - - const hasWatch = (data?.watchHistory.length ?? 0) > 0; - - // Request history sorted newest first - const sortedByDate = useMemo(() => { - if (!data) return []; - return [...data.enrichedRequests].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - }, [data]); - - const displayedRequests = showAll ? sortedByDate : sortedByDate.slice(0, 20); - if (loading) { return (
@@ -295,320 +58,20 @@ export default function UserDetail({ userId }: { userId: number }) { ); } - const { stat, openAlerts } = data; - const hasTautulli = stat.plays !== null; - - // User's overall average load (GB requested per watch hour, all time) - const overallLoad = - hasTautulli && stat.watchHours && stat.watchHours > 0 - ? Math.round((stat.totalGB / stat.watchHours) * 10) / 10 - : null; - - const statCols = hasTautulli ? "sm:grid-cols-5" : "sm:grid-cols-3"; + const { stat, enrichedRequests, watchHistory, openAlerts, serverAverages } = data; return (
- - {/* Back */} - - - - - All Users - - - {/* Header */} -
-
-
-

{stat.displayName}

- {stat.requestCount === 0 && ( - - No requests - - )} -
-

{stat.email}

-
- -
- {hasTautulli && ( - - Last seen: {unixTimeAgo(stat.tautulliLastSeen)} - - )} - - #{stat.storageRank} of {stat.totalUsers} by storage - -
-
- - {/* Stat cards */} -
- - - 0 ? formatGB(stat.avgGB) : "—"} /> - {hasTautulli && ( - - )} - {hasTautulli && ( - 0 ? formatHours(stat.watchHours) : "0h"} - rank={stat.watchRank} - total={stat.totalUsers} - /> - )} -
- - {/* Activity chart */} -
- - {/* Chart header */} -
-
-

Activity

- - {/* Metrics / Storage Load mode */} -
- - -
- - {/* Raw / Relative — metrics mode only */} - {viewMode === "metrics" && ( - - )} -
- -
- {(["1W", "1M", "3M", "1Y"] as Timeframe[]).map((t) => ( - - ))} -
-
- - {/* Series toggles — metrics mode only */} - {viewMode === "metrics" && ( -
- - - {hasWatch && ( - - )} -
- )} - - {/* Load mode explainer */} - {viewMode === "load" && ( -

- GB requested ÷ watch hours per period. Lower is healthier — a well-watched library stays near your average. - {overallLoad !== null && ( - <> Your overall average is {overallLoad} GB/hr. - )} -

- )} - - {/* Chart */} - - - - - - {viewMode === "load" && ( - `${v}G/h`} width={44} /> - )} - {viewMode === "metrics" && normalized && ( - `${v}%`} width={40} /> - )} - {viewMode === "metrics" && !normalized && ( - - )} - {viewMode === "metrics" && !normalized && ( - `${v}G`} width={40} /> - )} - - { - const num = typeof value === "number" ? value : Number(value); - const label = String(name ?? ""); - if (viewMode === "load") return [`${num} GB/hr`, label]; - if (normalized) return [`${num}%`, label]; - if (label === "Storage (GB)") return [formatGB(num), label]; - if (label === "Watch Hours") return [`${num}h`, label]; - return [num, label]; - }} - /> - - - {viewMode === "metrics" && normalized && ( - - )} - {viewMode === "load" && overallLoad !== null && ( - - )} - - {viewMode === "load" && ( - - )} - {viewMode === "metrics" && showRequests && ( - - )} - {viewMode === "metrics" && showStorage && ( - - )} - {viewMode === "metrics" && hasWatch && showWatchHours && ( - - )} - - - - {chartData.every((p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0) && ( -

No activity in this period

- )} -
- - {/* Request history */} -
-

- Request History - {sortedByDate.length} total -

- - {sortedByDate.length === 0 ? ( -
- No requests yet -
- ) : ( - <> -
- - - - - - - - - - - - {displayedRequests.map((req) => ( - - - - - - - - ))} - -
TitleTypeStatusSizeRequested
{req.title} - - {req.type === "movie" ? "Movie" : "TV"} - - - - - {req.sizeGB > 0 ? formatGB(req.sizeGB) : } - - {new Date(req.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} -
-
- - {sortedByDate.length > 20 && ( - - )} - - )} -
- - {/* Open alerts */} - {openAlerts.length > 0 && ( -
-

- Open Alerts - - {openAlerts.length} - -

-
- {openAlerts.map((alert) => ( - -
-
- - {alert.category} - -

{alert.title}

-

{alert.description}

-
- - - -
- - ))} -
-
- )} - + + + + +
); } diff --git a/src/app/users/[id]/UserHeader.tsx b/src/app/users/[id]/UserHeader.tsx new file mode 100644 index 0000000..0c8b9fb --- /dev/null +++ b/src/app/users/[id]/UserHeader.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; +import { UserStat } from "@/lib/types"; +import { unixTimeAgo } from "@/lib/format"; + +export default function UserHeader({ stat }: { stat: UserStat }) { + const hasTautulli = stat.plays !== null; + + return ( + <> + + + + + All Users + + +
+
+
+

{stat.displayName}

+ {stat.requestCount === 0 && ( + + No requests + + )} +
+

{stat.email}

+
+ +
+ {hasTautulli && ( + + Last seen: {unixTimeAgo(stat.tautulliLastSeen)} + + )} + + #{stat.storageRank} of {stat.totalUsers} by storage + +
+
+ + ); +} diff --git a/src/app/users/[id]/UserOpenAlerts.tsx b/src/app/users/[id]/UserOpenAlerts.tsx new file mode 100644 index 0000000..f584ef6 --- /dev/null +++ b/src/app/users/[id]/UserOpenAlerts.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { Alert, AlertSeverity } from "@/lib/types"; + +const SEV_COLOR: Record = { + danger: "border-l-red-500 bg-red-950/20", + warning: "border-l-yellow-500 bg-yellow-950/10", + info: "border-l-blue-500 bg-blue-950/20", +}; + +const SEV_TEXT: Record = { + danger: "text-red-400", + warning: "text-yellow-400", + info: "text-blue-400", +}; + +export default function UserOpenAlerts({ alerts }: { alerts: Alert[] }) { + if (alerts.length === 0) return null; + + return ( +
+

+ Open Alerts + + {alerts.length} + +

+
+ {alerts.map((alert) => ( + +
+
+ + {alert.category} + +

{alert.title}

+

{alert.description}

+
+ + + +
+ + ))} +
+
+ ); +} diff --git a/src/app/users/[id]/UserRequestHistory.tsx b/src/app/users/[id]/UserRequestHistory.tsx new file mode 100644 index 0000000..6be8319 --- /dev/null +++ b/src/app/users/[id]/UserRequestHistory.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { EnrichedRequest } from "@/lib/types"; +import { formatGB } from "@/lib/format"; +import StatusBadge from "@/components/StatusBadge"; + +const INITIAL_COUNT = 20; + +export default function UserRequestHistory({ + requests, +}: { + requests: EnrichedRequest[]; +}) { + const [showAll, setShowAll] = useState(false); + + const sorted = useMemo( + () => + [...requests].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ), + [requests] + ); + + const visible = showAll ? sorted : sorted.slice(0, INITIAL_COUNT); + + return ( +
+

+ Request History + + {sorted.length} total + +

+ + {sorted.length === 0 ? ( +
+ No requests yet +
+ ) : ( + <> +
+ + + + + + + + + + + + {visible.map((req) => ( + + + + + + + + ))} + +
TitleTypeStatusSizeRequested
+ {req.seerrUrl ? ( + + {req.title} + + ) : ( + req.title + )} + + + {req.type === "movie" ? "Movie" : "TV"} + + + + + {req.sizeGB > 0 ? formatGB(req.sizeGB) : } + + {new Date(req.createdAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} +
+
+ + {sorted.length > INITIAL_COUNT && ( + + )} + + )} +
+ ); +} diff --git a/src/app/users/[id]/UserStatCards.tsx b/src/app/users/[id]/UserStatCards.tsx new file mode 100644 index 0000000..50acb70 --- /dev/null +++ b/src/app/users/[id]/UserStatCards.tsx @@ -0,0 +1,150 @@ +import { UserStat } from "@/lib/types"; +import { formatGB, formatHours } from "@/lib/format"; +import RankChip from "@/components/RankChip"; + +type Accent = "yellow" | "green" | "orange" | null; + +const ICONS = { + requests: "M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776", + storage: "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 2.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125m16.5 2.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125", + avg: "M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 0 1-2.031.352 5.988 5.988 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971Zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 0 1-2.031.352 5.989 5.989 0 0 1-2.032-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971Z", + plays: "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM10.5 8.25v7.5l6-3.75-6-3.75Z", + watch: "M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75.125v-6.375c0-.621.504-1.125 1.125-1.125H4.5m0-3.375v3.375m0-3.375h12M4.5 7.5H18a.75.75 0 0 1 .75.75v9.375c0 .621-.504 1.125-1.125 1.125h-1.5m-13.5 0h13.5m0 0v-3.375m0 3.375v-9.375c0-.621.504-1.125 1.125-1.125H21", + load: "m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z", +}; + +type IconKey = keyof typeof ICONS; + +const ACCENT_CLASSES: Record, { box: string; value: string; icon: string }> = { + yellow: { + box: "bg-yellow-950/30 border-yellow-700/50", + value: "text-yellow-300", + icon: "text-yellow-500/70", + }, + green: { + box: "bg-green-950/30 border-green-800/50", + value: "text-white", + icon: "text-green-500/70", + }, + orange: { + box: "bg-orange-950/30 border-orange-800/50", + value: "text-orange-300", + icon: "text-orange-500/70", + }, +}; + +const DEFAULT_CLASSES = { + box: "bg-slate-800/60 border-slate-700/60", + value: "text-white", + icon: "text-slate-600", +}; + +function Card({ + label, + value, + icon, + rank, + total, + accent, +}: { + label: string; + value: string; + icon: IconKey; + rank?: number | null; + total?: number; + accent?: Accent; +}) { + const c = accent ? ACCENT_CLASSES[accent] : DEFAULT_CLASSES; + return ( +
+
+ + {label} + + + + +
+ {value} + {rank !== undefined && total !== undefined && ( + + )} +
+ ); +} + +export default function UserStatCards({ stat }: { stat: UserStat }) { + const hasTautulli = stat.plays !== null; + const total = stat.totalUsers; + + const grid = hasTautulli + ? "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6" + : "grid grid-cols-2 gap-3 sm:grid-cols-3"; + + return ( +
+ + + 0 ? formatGB(stat.avgGB) : "—"} + icon="avg" + /> + {hasTautulli && ( + + )} + {hasTautulli && ( + 0 + ? formatHours(stat.watchHours) + : "0h" + } + icon="watch" + rank={stat.watchRank} + total={total} + accent="green" + /> + )} + {hasTautulli && ( + + )} +
+ ); +} diff --git a/src/components/LeaderboardTable.tsx b/src/components/LeaderboardTable.tsx index 3a4483e..9864d12 100644 --- a/src/components/LeaderboardTable.tsx +++ b/src/components/LeaderboardTable.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import Link from "next/link"; import { UserStat } from "@/lib/types"; +import { formatGB, formatHours } from "@/lib/format"; +import RankChip from "@/components/RankChip"; type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours"; @@ -11,24 +13,6 @@ interface LeaderboardTableProps { 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 ; - return ( - - #{rank}/{total} - - ); -} - function SortChevrons({ active, asc }: { active: boolean; asc: boolean }) { return ( @@ -142,7 +126,7 @@ export default function LeaderboardTable({ {user.requestCount.toLocaleString()}
- +
@@ -152,7 +136,7 @@ export default function LeaderboardTable({ {formatGB(user.totalGB)}
- +
@@ -168,7 +152,7 @@ export default function LeaderboardTable({ {user.plays !== null ? user.plays.toLocaleString() : "—"}
- +
)} @@ -184,7 +168,7 @@ export default function LeaderboardTable({ : "—"}
- +
)} diff --git a/src/components/RankChip.tsx b/src/components/RankChip.tsx new file mode 100644 index 0000000..0499bed --- /dev/null +++ b/src/components/RankChip.tsx @@ -0,0 +1,15 @@ +export default function RankChip({ + rank, + total, +}: { + rank: number | null; + total: number; +}) { + if (rank === null) return ; + return ( + + #{rank} + /{total} + + ); +} diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx new file mode 100644 index 0000000..96104f5 --- /dev/null +++ b/src/components/StatusBadge.tsx @@ -0,0 +1,29 @@ +// Overseerr media.status codes: +// 1=Unknown, 2=Pending, 3=Processing, 4=Partially Available, 5=Available +const LABEL: Record = { + 1: "Unknown", + 2: "Pending", + 3: "Processing", + 4: "Partial", + 5: "Available", +}; + +const COLOR: Record = { + 1: "bg-slate-700/30 text-slate-500 border-slate-600/40", + 2: "bg-yellow-500/15 text-yellow-400 border-yellow-700/40", + 3: "bg-blue-500/15 text-blue-400 border-blue-700/40", + 4: "bg-cyan-500/15 text-cyan-400 border-cyan-700/40", + 5: "bg-green-500/15 text-green-400 border-green-700/40", +}; + +export default function StatusBadge({ status }: { status: number }) { + return ( + + {LABEL[status] ?? `Status ${status}`} + + ); +} diff --git a/src/components/SummaryCards.tsx b/src/components/SummaryCards.tsx index cb6e32f..59b68c8 100644 --- a/src/components/SummaryCards.tsx +++ b/src/components/SummaryCards.tsx @@ -1,3 +1,5 @@ +import { formatGB, formatHours } from "@/lib/format"; + interface SummaryCardsProps { totalUsers: number; totalRequests: number; @@ -7,16 +9,6 @@ interface SummaryCardsProps { onAlertsClick?: () => void; } -function formatStorage(gb: number): string { - if (gb >= 1000) return `${(gb / 1000).toFixed(1)} TB`; - return `${gb.toFixed(1)} GB`; -} - -function formatHours(h: number): string { - if (h >= 1000) return `${(h / 1000).toFixed(1)}k hrs`; - return `${h.toFixed(0)} hrs`; -} - // Heroicons outline paths const ICONS = { users: "M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z", @@ -106,7 +98,7 @@ export default function SummaryCards({ > - + {totalWatchHours !== null && ( )} diff --git a/src/lib/aggregate.ts b/src/lib/aggregate.ts index 46444a6..4cac421 100644 --- a/src/lib/aggregate.ts +++ b/src/lib/aggregate.ts @@ -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, diff --git a/src/lib/enrichRequests.ts b/src/lib/enrichRequests.ts new file mode 100644 index 0000000..cf37943 --- /dev/null +++ b/src/lib/enrichRequests.ts @@ -0,0 +1,46 @@ +import { OverseerrRequest, MediaEntry, EnrichedRequest } from "@/lib/types"; +import { bytesToGB } from "@/lib/aggregate"; + +export function enrichRequests( + userRequests: OverseerrRequest[], + radarrMap: Map, + sonarrMap: Map, + 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, + }; + }); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..2c14201 --- /dev/null +++ b/src/lib/format.ts @@ -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()); +} diff --git a/src/lib/statsBuilder.ts b/src/lib/statsBuilder.ts index 29c4d65..2c8e6be 100644 --- a/src/lib/statsBuilder.ts +++ b/src/lib/statsBuilder.ts @@ -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; allRequests: Map; tautulliMap: Map | null; + /** Per-user daily watch history, keyed by Overseerr user id. Empty map if Tautulli isn't configured. */ + watchHistoryMap: Map; } let rawCache: RawCache | null = null; @@ -53,7 +61,21 @@ async function buildStats(): Promise { chunk.forEach((u, idx) => allRequests.set(u.id, results[idx])); } - rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap }; + const watchHistoryMap = new Map(); + 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); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 4631fa7..fffc66c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; + 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 */ diff --git a/src/lib/userChart.ts b/src/lib/userChart.ts new file mode 100644 index 0000000..042f15a --- /dev/null +++ b/src/lib/userChart.ts @@ -0,0 +1,97 @@ +import { EnrichedRequest, WatchDataPoint, Timeframe } from "@/lib/types"; + +export const TIMEFRAMES: Timeframe[] = ["1W", "1M", "3M", "1Y"]; + +export const TF_CONFIG: Record = { + "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; +}