import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { History, Pause, Play, RefreshCw } from "lucide-react"; import { api, type AdminQuotaRow } from "../lib/api"; import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { useWizard } from "./wizardContext"; import { LS, useTabPersistedState } from "../lib/storage"; import Tooltip from "./ui/Tooltip"; import { LoadingState, StateMessage } from "./ui/QueryState"; import FilterChipGroup from "./ui/FilterChipGroup"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { PageToolbar } from "./ui/PageShell"; import { Section } from "./ui/form"; import { SectionLabel } from "./ui/Headings"; import { StatBars, StatTile, StatTiles } from "./ui/StatTiles"; import { useMe } from "../lib/useMe"; import { notifyActionError } from "../lib/actionErrors"; import { Button } from "./ui/Button"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; const RANGES = [7, 30, 90] as const; type StatsTab = "overview" | "system"; // Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions); // "System" is the admin-only instance-wide quota dashboard + background-sync control. The // per-user content used to live in Settings → Sync; it moved here so Settings stays config-only. export default function Stats() { const me = useMe(); const { t } = useTranslation(); const isAdmin = me.role === "admin"; const [tabRaw, setTab] = useTabPersistedState(LS.statsTab, "overview"); // Clamp at render: only admins may land on the System tab (covers a stale stored value). const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; const panel = useTabPanel(); return ( // A tab panel only where there are tabs: everyone but an admin sees the Overview alone.
{/* Only admins have a second (System) tab; everyone else just sees the Overview. The tabs are the page's band (B4), so they stay in reach on the long System tab. */} {isAdmin && (
({ id, label: t(`stats.tabs.${id}`), }))} className="flex flex-wrap gap-1 pb-2" />
)} {tab === "system" && isAdmin ? : }
); } // Per-user view: sync status + your own API usage + the manual sync actions. function Overview() { const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); // The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here. const { openWizard } = useWizard(); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage }); const s = status.data; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.channels() }); notify({ level: "success", message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }), }); }, // The same helper and the same wizard handle as the Channels page: it translates the 403 into // "connect YouTube" WITH a button that opens the wizard — instead of surfacing the route's bare // English detail under a Hungarian UI — and delegates everything else to notifyActionError so // the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit // rather than "Sync failed". onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.channels() }); notify({ level: "success", message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, onError: (e) => notifyActionError(e, t("stats.sync.fullHistoryFailed")), }); return ( <>
{s ? ( {s.deep_pending_count > 0 && ( )} ) : status.isError ? ( status.refetch()}> {t("common.loadError")} ) : ( )}
{usage.data ? ( <> {Object.keys(usage.data.by_action).length > 0 && (
{t("stats.sync.byAction")} formatNumber(n, lang)} rows={Object.entries(usage.data.by_action).map(([action, units]) => ({ id: action, label: quotaActionLabel(t, action), value: units, }))} />
)} ) : usage.isError ? ( usage.refetch()}> {t("common.loadError")} ) : usage.isLoading ? ( ) : (
{t("stats.sync.noUsage")}
)}
); } // Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle. function SystemStats() { const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); const [days, setDays] = useState(30); const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) }); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const pauseResume = useMutation({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }), onError: (e, paused) => notifyActionError( e, t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"), ), }); const data = q.data; const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total)); const grandTotal = (data?.rows ?? []).reduce((s, r) => s + r.total, 0); const s = status.data; return ( <> {s && (
)}

{t("stats.title")}

({ value: String(d), label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }), }))} value={String(days)} onChange={(v) => setDays(Number(v))} />

{t("stats.introBefore")} {t("stats.introSystem")} {t("stats.introAfter")}

{q.isError && !data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading ? ( ) : !data ? (
{t("stats.noData")}
) : ( <> {/* Daily totals (instance-wide, Pacific days) */}
{t("stats.dailyTotal", { d: formatUnit(data.range_days, "day", lang), units: formatNumber(grandTotal, lang), })}
{data.daily.length === 0 ? (
{t("stats.noUsageInRange")}
) : (
{data.daily.map((d) => (
))}
)}
{/* Per-user breakdown */}
{data.rows.length === 0 ? (
{t("stats.noUsageInRange")}
) : ( data.rows.map((r) => ( )) )}
)} ); } function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const [open, setOpen] = useState(false); const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]); const isSystem = row.user_id === null; return (
setOpen((o) => !o)} aria-expanded={open} className="w-full flex items-center gap-3" > {isSystem ? t("stats.system") : row.email} {formatNumber(row.total, lang)} {/* Proportion bar */}
{open && actions.length > 0 && (
{actions.map(([action, units]) => (
{quotaActionLabel(t, action)} {formatNumber(units, lang)}
))}
)}
); }