feat(analytics): add daily trend and activity heatmap endpoints, and implement corresponding frontend components

- Added new API endpoints for daily trend data and activity heatmap in analyticsRoutes.ts.
- Created new frontend components: ActivityChart, ControlBar, Heatmap, SummaryCards, TopicList, TrendChart, UserTable, and ViolatorTable for displaying analytics data.
- Implemented loading and empty states in the new components.
- Enhanced the existing moderation tests with remote fallback handling for Indonesian text normalization.
This commit is contained in:
MythEclipse
2026-05-31 00:41:34 +07:00
parent 4e9e370eb1
commit 71e240c1e7
21 changed files with 2130 additions and 1066 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import { DashboardLayout } from "./components/layout/DashboardLayout";
import { LivePanel } from "./components/live/LivePanel";
import { MessagesPanel } from "./components/messages/MessagesPanel";
import { Tabs, TabsContent } from "./components/ui/tabs";
import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel";
import { AnalyticsPanel } from "./components/analytics";
import { AuthOverlay } from "./components/layout/AuthOverlay";
import { useDashboardSocket } from "./hooks/useDashboardSocket";
import { mergeMessages, useMessages } from "./hooks/useMessages";
+44
View File
@@ -139,3 +139,47 @@ export async function fetchViolators(params: {
});
return request<ViolatorStat[]>(`/api/analytics/violators?${searchParams}`);
}
export interface TrendBucket {
date: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export interface HeatmapCell {
dayOfWeek: number;
hour: number;
count: number;
clean: number;
warned: number;
flagged: number;
}
export async function fetchTrend(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TrendBucket[]> {
const searchParams = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<TrendBucket[]>(`/api/analytics/trend?${searchParams}`);
}
export async function fetchHeatmap(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HeatmapCell[]> {
const searchParams = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<HeatmapCell[]>(`/api/analytics/heatmap?${searchParams}`);
}
@@ -0,0 +1,92 @@
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
import type { HourlyBucket } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
interface ActivityChartProps {
hourly: HourlyBucket[];
loading: boolean;
}
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
if (loading && !hourly?.length) {
return <LoadingBox />;
}
if (!hourly?.length) {
return <EmptyBox text="Belum ada data untuk periode ini." />;
}
const data = hourly.map((b) => {
const utcHour = parseInt(b.hour.slice(11, 13), 10);
const jakartaHour = (utcHour + 7) % 24;
return {
hour: `${String(jakartaHour).padStart(2, "0")}:00`,
clean: b.clean,
warned: b.warned,
flagged: b.flagged,
error: b.error,
total: b.count,
};
});
return (
<Card className="col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Aktivitas per Jam</CardTitle>
<CardDescription className="text-xs">Distribusi pesan per jam berdasarkan status moderasi.</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
<XAxis dataKey="hour" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} />
<YAxis tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={false} width={28} />
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "6px",
fontSize: "11px",
color: "#e2e8f0",
}}
formatter={(value: unknown, name: unknown) => {
const v = typeof value === "number" ? value : String(value);
return [v, label(String(name))];
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
<Area type="monotone" dataKey="clean" stackId="1" stroke="#10b981" fill="#10b981" fillOpacity={0.6} name="clean" />
<Area type="monotone" dataKey="warned" stackId="1" stroke="#f59e0b" fill="#f59e0b" fillOpacity={0.6} name="warned" />
<Area type="monotone" dataKey="flagged" stackId="1" stroke="#ef4444" fill="#ef4444" fillOpacity={0.6} name="flagged" />
<Area type="monotone" dataKey="error" stackId="1" stroke="#f97316" fill="#f97316" fillOpacity={0.4} name="error" />
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
function label(key: string): string {
const map: Record<string, string> = { clean: "Clean", warned: "Warned", flagged: "Flagged", error: "Error" };
return map[key] ?? key;
}
function LoadingBox() {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
function EmptyBox({ text }: { text: string }) {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
{text}
</CardContent>
</Card>
);
}
@@ -1,993 +0,0 @@
import { useRef, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
Activity,
AlertTriangle,
BarChart3,
CheckCircle2,
Clock,
Flame,
MessageSquare,
Shield,
Siren,
TrendingDown,
TrendingUp,
Users,
XCircle,
} from "lucide-react";
import type { Channel, Guild } from "../../types/voice";
import { useAnalytics } from "../../hooks/useAnalytics";
import type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat } from "../../hooks/useAnalytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Select } from "../ui/select";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { ScrollArea } from "../ui/scroll-area";
import { cn } from "../../lib/utils";
const TIME_RANGES = [
{ label: "1h", value: 1 },
{ label: "3h", value: 3 },
{ label: "6h", value: 6 },
{ label: "12h", value: 12 },
{ label: "24h", value: 24 },
{ label: "48h", value: 48 },
{ label: "7d", value: 168 },
];
interface AnalyticsPanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
}
// ── Color Palette ──────────────────────────────────────────────────────
const GLOW_COLORS = {
clean: "from-emerald-500/20 via-emerald-500/5 to-transparent",
warned: "from-amber-500/20 via-amber-500/5 to-transparent",
flagged: "from-red-500/20 via-red-500/5 to-transparent",
error: "from-orange-500/20 via-orange-500/5 to-transparent",
neutral: "from-blue-500/15 via-blue-500/5 to-transparent",
};
export function AnalyticsPanel({
guilds,
channels,
selectedGuild,
selectedChannel,
onGuildChange,
onChannelChange,
}: AnalyticsPanelProps) {
const [hours, setHours] = useState(24);
const {
overview,
isLoading,
isFetching,
error,
refresh,
violators,
violatorsLoading,
violatorsFetching,
refreshViolators,
} = useAnalytics({
guildId: selectedGuild,
channelId: selectedChannel || undefined,
hours,
});
// Loading is true only on first load (no cached data); fetching means background refresh
const loading = isLoading && !isFetching;
return (
<div className="grid gap-6">
{/* ── Control Bar ─────────────────────────────────────────────── */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Card className="overflow-hidden border-0 bg-gradient-to-r from-card via-card to-blue-950/20 shadow-lg shadow-blue-500/5">
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/5 via-transparent to-transparent pointer-events-none" />
<CardHeader>
<CardTitle className="flex items-center gap-2 text-2xl">
<BarChart3 className="h-6 w-6 text-blue-400" />
Analytics & Insights
</CardTitle>
<CardDescription>
Pantau statistik moderasi, topik trending, dan aktivitas user dalam satu dasbor.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
<Select
value={selectedGuild}
onChange={(e) => onGuildChange(e.target.value)}
placeholder="Select guild"
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
/>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="All channels"
options={[
{ value: "", label: "All channels" },
...channels.map((c) => ({ value: c.id, label: c.name })),
]}
/>
<div className="flex gap-1 rounded-xl bg-muted/50 p-1 backdrop-blur">
{TIME_RANGES.map((tr) => (
<button
key={tr.value}
type="button"
onClick={() => setHours(tr.value)}
className={cn(
"relative flex-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-all",
hours === tr.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{hours === tr.value && (
<motion.div
layoutId="timeRangeActive"
className="absolute inset-0 rounded-lg bg-background shadow-sm"
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
/>
)}
<span className="relative z-10">{tr.label}</span>
</button>
))}
</div>
<Button
onClick={() => { refresh(); refreshViolators(); }}
disabled={isFetching}
className="bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white shadow-lg shadow-blue-500/25 transition-all hover:shadow-blue-500/40"
>
{isFetching ? (
<span className="flex items-center gap-2">
<motion.span
animate={{ rotate: 360 }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1, ease: "linear" }}
className="inline-block h-3.5 w-3.5 rounded-full border-2 border-white/30 border-t-white"
/>
Loading...
</span>
) : (
<span className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Refresh
</span>
)}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
{error && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<Card className="border-red-500/30 bg-red-500/5">
<CardContent className="flex items-center gap-3 py-4">
<XCircle className="h-5 w-5 shrink-0 text-red-400" />
<p className="text-sm text-red-300">{error}</p>
</CardContent>
</Card>
</motion.div>
)}
{!selectedGuild ? (
<EmptyState icon={BarChart3} text="Pilih guild untuk melihat analitik." />
) : (
<AnimatePresence mode="wait">
<motion.div
key={`${selectedGuild}-${hours}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid gap-6"
>
{/* ── KPI Stat Cards ─────────────────────────────────────── */}
<StatsGrid overview={overview} loading={loading} totalChannels={overview?.total_channels ?? 0} />
{/* ── Hourly Activity Chart ──────────────────────────────── */}
<AnimatedCard glow="neutral">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-blue-400" />
Aktivitas Pesan Per Jam
</CardTitle>
<CardDescription>
Distribusi pesan per jam dengan breakdown status moderasi.
</CardDescription>
</CardHeader>
<CardContent>
<HourlyChart hourly={overview?.hourly} loading={loading} />
</CardContent>
</AnimatedCard>
{/* ── Topics + Leaderboard row ───────────────────────────── */}
<div className="grid gap-6 lg:grid-cols-2">
<AnimatedCard glow="neutral">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Flame className="h-5 w-5 text-orange-400" />
Topik Trending
</CardTitle>
<CardDescription>
Yang paling ramai dibicarakan orang.
</CardDescription>
</CardHeader>
<CardContent>
<TopicCloud topics={overview?.topics} loading={loading} />
</CardContent>
</AnimatedCard>
<AnimatedCard glow="neutral">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-violet-400" />
User Paling Aktif
</CardTitle>
<CardDescription>
Leaderboard berdasarkan jumlah pesan.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<UserLeaderboard users={overview?.top_users} loading={loading} compact />
</CardContent>
</AnimatedCard>
</div>
{/* ── VIOLATORS LEADERBOARD ──────────────────────────────── */}
<AnimatedCard glow="flagged">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Siren className="h-5 w-5 text-red-400" />
Pelanggar Terbanyak
</CardTitle>
<CardDescription>
User dengan skor pelanggaran tertinggi (flagged × 3 + warned × 1).
</CardDescription>
</div>
<Badge variant="destructive" className={cn(violatorsFetching && "animate-pulse")}>
{violators.length} pelanggar
</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ViolatorsLeaderboard users={violators} loading={violatorsLoading} />
</CardContent>
</AnimatedCard>
{/* ── Full User Leaderboard ──────────────────────────────── */}
<AnimatedCard glow="neutral">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-cyan-400" />
Leaderboard Lengkap
</CardTitle>
<CardDescription>
Detail aktivitas user: pesan, edit, hapus, flag, dan waktu aktif terakhir.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<UserLeaderboard users={overview?.top_users} loading={loading} />
</CardContent>
</AnimatedCard>
</motion.div>
</AnimatePresence>
)}
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════
// SUB-COMPONENTS
// ══════════════════════════════════════════════════════════════════════════
// ── Animated Card Wrapper ──────────────────────────────────────────────
function AnimatedCard({
children,
glow,
className,
}: {
children: React.ReactNode;
glow?: keyof typeof GLOW_COLORS;
className?: string;
}) {
const glowClass = GLOW_COLORS[glow ?? "neutral"];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className={cn("group relative", className)}
>
{/* Animated border glow on hover */}
<div className="absolute -inset-px rounded-2xl bg-gradient-to-r from-blue-500/0 via-blue-500/0 to-blue-500/0 opacity-0 transition-all duration-500 group-hover:from-blue-500/20 group-hover:via-violet-500/10 group-hover:to-blue-500/20 group-hover:opacity-100 blur-md pointer-events-none" />
<Card className="relative overflow-hidden border-muted/60 bg-card/80 backdrop-blur shadow-lg transition-shadow group-hover:shadow-xl group-hover:shadow-blue-500/5">
<div className={cn("absolute inset-0 bg-gradient-to-b pointer-events-none", glowClass)} />
{children}
</Card>
</motion.div>
);
}
// ── Stats Grid ─────────────────────────────────────────────────────────
function StatsGrid({
overview,
loading,
totalChannels,
}: {
overview: AnalyticsOverview | null;
loading: boolean;
totalChannels: number;
}) {
const cards = [
{
label: "Total Pesan",
value: overview?.messages.total ?? null,
icon: MessageSquare,
color: "text-blue-400",
bg: "bg-blue-500/10",
border: "border-blue-500/20",
sub: totalChannels > 0 ? `${totalChannels} channel` : "",
trend: null,
},
{
label: "Clean",
value: overview?.messages.clean ?? null,
icon: CheckCircle2,
color: "text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/20",
sub: overview ? `${pct(overview.messages.clean, overview.messages.total)}%` : "",
trend: "up",
},
{
label: "Warned",
value: overview?.messages.warned ?? null,
icon: AlertTriangle,
color: "text-amber-400",
bg: "bg-amber-500/10",
border: "border-amber-500/20",
sub: overview ? `${pct(overview.messages.warned, overview.messages.total)}%` : "",
trend: overview && overview.messages.warned > 0 ? "down" : null,
},
{
label: "Flagged",
value: overview?.messages.flagged ?? null,
icon: Siren,
color: "text-red-400",
bg: "bg-red-500/10",
border: "border-red-500/20",
sub: overview ? `${pct(overview.messages.flagged, overview.messages.total)}%` : "",
trend: overview && overview.messages.flagged > 0 ? "down" : null,
},
{
label: "Error",
value: overview?.messages.error ?? null,
icon: XCircle,
color: "text-orange-400",
bg: "bg-orange-500/10",
border: "border-orange-500/20",
sub: null,
trend: null,
},
{
label: "Pending",
value: overview?.messages.pending ?? null,
icon: Clock,
color: "text-slate-400",
bg: "bg-slate-500/10",
border: "border-slate-500/20",
sub: null,
trend: null,
},
{
label: "Rata-rata Skor",
value: overview?.messages.average_score ?? null,
icon: Shield,
color: "text-cyan-400",
bg: "bg-cyan-500/10",
border: "border-cyan-500/20",
sub: null,
trend: null,
},
{
label: "User Aktif",
value: overview?.active_users_count ?? null,
icon: Users,
color: "text-violet-400",
bg: "bg-violet-500/10",
border: "border-violet-500/20",
sub: null,
trend: null,
},
];
return (
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{cards.map((card, i) => {
const Icon = card.icon;
return (
<motion.div
key={card.label}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.05, duration: 0.3 }}
>
<Card className={cn("group relative overflow-hidden border transition-all hover:shadow-lg", card.border, card.bg)}>
{/* Background pulse */}
<div className={cn("absolute -right-4 -top-4 h-20 w-20 rounded-full opacity-20 blur-xl transition-opacity group-hover:opacity-30", card.color.replace("text-", "bg-"))} />
<CardContent className="relative py-4">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{card.label}
</p>
<Icon className={cn("h-4 w-4 opacity-50", card.color)} />
</div>
<div className="mt-2 flex items-end gap-2">
<span className={cn("text-3xl font-bold tabular-nums tracking-tight", card.color)}>
{loading ? (
<motion.span
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
>
</motion.span>
) : (
card.value ?? "—"
)}
</span>
{card.trend && (
<span className="pb-1">
{card.trend === "up" ? (
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TrendingDown className="h-3.5 w-3.5 text-red-400" />
)}
</span>
)}
</div>
{card.sub && (
<p className="mt-1 text-xs text-muted-foreground">{card.sub}</p>
)}
</CardContent>
</Card>
</motion.div>
);
})}
</div>
);
}
// ── Hourly Chart ───────────────────────────────────────────────────────
function HourlyChart({ hourly, loading }: { hourly: HourlyBucket[] | undefined; loading: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
if (loading && !hourly?.length) {
return <LoadingSkeleton />;
}
if (!hourly?.length) {
return (
<div className="flex h-56 flex-col items-center justify-center gap-2 text-muted-foreground">
<BarChart3 className="h-10 w-10 opacity-20" />
<p className="text-sm">Belum ada data untuk periode ini.</p>
</div>
);
}
const maxCount = Math.max(...hourly.map((b) => b.count), 1);
// Convert UTC hour buckets to Jakarta time (UTC+7)
const labels = hourly.map((b) => {
const utcHour = parseInt(b.hour.slice(11, 13), 10);
const jakartaHour = (utcHour + 7) % 24;
return `${String(jakartaHour).padStart(2, "0")}:00`;
});
return (
<div ref={containerRef} className="space-y-3">
<div className="relative flex h-52 items-end gap-[2px]">
{/* Grid lines */}
{[0.25, 0.5, 0.75, 1].map((pct) => (
<div
key={pct}
className="absolute left-0 right-0 border-t border-white/[0.04]"
style={{ bottom: `${pct * 100}%` }}
/>
))}
{hourly.map((bucket, i) => {
const heightPct = (bucket.count / maxCount) * 100;
const total = bucket.clean + bucket.warned + bucket.flagged + bucket.error || 1;
const cleanH = (bucket.clean / total) * heightPct;
const warnedH = (bucket.warned / total) * heightPct;
const flaggedH = (bucket.flagged / total) * heightPct;
const errorH = (bucket.error / total) * heightPct;
return (
<motion.div
key={bucket.hour}
initial={{ height: 0 }}
animate={{ height: `${heightPct}%` }}
transition={{ delay: i * 0.02, duration: 0.5, ease: "easeOut" }}
className="group relative flex flex-1 flex-col justify-end"
>
{/* Stacked segments */}
<div className="relative w-full" style={{ height: `${heightPct}%` }}>
<div
className="absolute bottom-0 w-full rounded-t-sm bg-emerald-500/80 transition-colors hover:bg-emerald-400"
style={{ height: `${cleanH}%` }}
title={`Clean: ${bucket.clean}`}
/>
<div
className="absolute w-full bg-amber-500/80 transition-colors hover:bg-amber-400"
style={{ bottom: `${cleanH}%`, height: `${warnedH}%` }}
title={`Warned: ${bucket.warned}`}
/>
<div
className="absolute w-full bg-red-500/80 transition-colors hover:bg-red-400"
style={{ bottom: `${cleanH + warnedH}%`, height: `${flaggedH}%` }}
title={`Flagged: ${bucket.flagged}`}
/>
<div
className="absolute top-0 w-full rounded-t-sm bg-orange-500/60 transition-colors hover:bg-orange-400"
style={{ height: `${errorH}%` }}
title={`Error: ${bucket.error}`}
/>
</div>
{/* Hover tooltip */}
<div className="absolute -top-10 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-lg bg-popover px-2.5 py-1.5 text-xs font-medium text-popover-foreground opacity-0 shadow-lg transition-opacity group-hover:opacity-100 pointer-events-none">
{labels[hourly.indexOf(bucket)]} {bucket.count} msgs
</div>
</motion.div>
);
})}
</div>
{/* X-axis labels */}
<div className="flex justify-between px-1">
{labels.filter((_, i) => i % Math.max(1, Math.floor(labels.length / 6)) === 0 || i === labels.length - 1).map((label, i) => (
<span key={i} className="text-[10px] text-muted-foreground tabular-nums">{label}</span>
))}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-5 text-xs text-muted-foreground">
<Legend color="bg-emerald-500/80" label="Clean" />
<Legend color="bg-amber-500/80" label="Warned" />
<Legend color="bg-red-500/80" label="Flagged" />
<Legend color="bg-orange-500/60" label="Error" />
</div>
</div>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<span className="inline-flex items-center gap-1.5">
<span className={cn("inline-block h-2.5 w-2.5 rounded-sm", color)} />
{label}
</span>
);
}
// ── Topic Cloud ────────────────────────────────────────────────────────
const TOPIC_GRADIENTS = [
"from-blue-500/30 via-blue-500/15 to-blue-600/20",
"from-emerald-500/30 via-emerald-500/15 to-emerald-600/20",
"from-violet-500/30 via-violet-500/15 to-violet-600/20",
"from-amber-500/30 via-amber-500/15 to-amber-600/20",
"from-cyan-500/30 via-cyan-500/15 to-cyan-600/20",
"from-pink-500/30 via-pink-500/15 to-pink-600/20",
"from-teal-500/30 via-teal-500/15 to-teal-600/20",
"from-orange-500/30 via-orange-500/15 to-orange-600/20",
];
const TOPIC_TEXT = [
"text-blue-300",
"text-emerald-300",
"text-violet-300",
"text-amber-300",
"text-cyan-300",
"text-pink-300",
"text-teal-300",
"text-orange-300",
];
const TOPIC_BORDER = [
"border-blue-500/30",
"border-emerald-500/30",
"border-violet-500/30",
"border-amber-500/30",
"border-cyan-500/30",
"border-pink-500/30",
"border-teal-500/30",
"border-orange-500/30",
];
function TopicCloud({ topics, loading }: { topics: TopicTrend[] | undefined; loading: boolean }) {
if (loading && !topics?.length) {
return <LoadingSkeleton />;
}
if (!topics?.length) {
return (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
<Flame className="h-10 w-10 opacity-20" />
<p className="text-sm">Topik akan muncul setelah AI selesai menganalisis.</p>
</div>
);
}
const maxCount = Math.max(...topics.map((t) => t.count), 1);
return (
<div className="flex flex-wrap gap-2.5">
{topics.map((topic, i) => {
const scale = 0.65 + (topic.count / maxCount) * 1.35;
return (
<motion.span
key={topic.topic}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.04, type: "spring", bounce: 0.3 }}
whileHover={{ scale: 1.08, y: -2 }}
className={cn(
"relative inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 cursor-default",
"bg-gradient-to-br backdrop-blur transition-shadow hover:shadow-lg",
TOPIC_GRADIENTS[i % TOPIC_GRADIENTS.length],
TOPIC_BORDER[i % TOPIC_BORDER.length],
TOPIC_TEXT[i % TOPIC_TEXT.length],
)}
style={{ fontSize: `${Math.round(scale * 100)}%` }}
title={`${topic.count} kali disebut${topic.score > 0 ? ` · Skor: ${topic.score}` : ""}`}
>
{/* Sparkle dot */}
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 animate-pulse rounded-full bg-white/30" />
{topic.topic}
<span className="text-[0.65em] font-mono opacity-50 tabular-nums">
{topic.count}
</span>
</motion.span>
);
})}
</div>
);
}
// ── User Leaderboard ───────────────────────────────────────────────────
function UserLeaderboard({
users,
loading,
compact,
}: {
users: UserStat[] | undefined;
loading: boolean;
compact?: boolean;
}) {
if (loading && !users?.length) {
return <LoadingSkeleton />;
}
if (!users?.length) {
return (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
<Users className="h-10 w-10 opacity-20" />
<p className="text-sm">Belum ada aktivitas user.</p>
</div>
);
}
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
const medals = ["🥇", "🥈", "🥉"];
const displayUsers = compact ? users.slice(0, 5) : users;
return (
<ScrollArea className={compact ? "max-h-[300px]" : "max-h-[500px]"}>
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="py-3 pl-6 pr-3 font-semibold">#</th>
<th className="py-3 pr-3 font-semibold">User</th>
{!compact && (
<>
<th className="py-3 pr-3 font-semibold text-right">Pesan</th>
<th className="py-3 pr-3 font-semibold text-right">Edit</th>
<th className="py-3 pr-3 font-semibold text-right">Hapus</th>
<th className="py-3 pr-3 font-semibold text-right">Flag</th>
<th className="py-3 pr-6 font-semibold text-right">Aktif</th>
</>
)}
{compact && (
<th className="py-3 pr-6 font-semibold text-right">Pesan</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{displayUsers.map((user, i) => (
<motion.tr
key={user.user_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.03 }}
className="group transition-colors hover:bg-muted/20"
>
<td className="py-2.5 pl-6 pr-3 tabular-nums font-mono text-muted-foreground">
{medals[i] ?? i + 1}
</td>
<td className="py-2.5 pr-3">
<div className="flex items-center gap-2.5">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt=""
className="h-7 w-7 rounded-full ring-1 ring-border/50"
loading="lazy"
/>
) : (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold ring-1 ring-border/50">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="max-w-[100px] truncate font-medium">
{user.username}
</span>
</div>
</td>
{!compact && (
<>
<td className="py-2.5 pr-3 text-right tabular-nums">
<div className="flex items-center justify-end gap-2">
<div className="hidden h-1.5 w-10 overflow-hidden rounded-full bg-muted sm:block">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-blue-400"
initial={{ width: 0 }}
animate={{ width: `${(user.message_count / maxMsgs) * 100}%` }}
transition={{ delay: i * 0.05 + 0.2, duration: 0.6 }}
/>
</div>
<span className="font-mono text-xs font-semibold">{user.message_count}</span>
</div>
</td>
<td className="py-2.5 pr-3 text-right tabular-nums text-muted-foreground text-xs">
{user.edited_count > 0 ? user.edited_count : "—"}
</td>
<td className="py-2.5 pr-3 text-right tabular-nums text-muted-foreground text-xs">
{user.deleted_count > 0 ? user.deleted_count : "—"}
</td>
<td className="py-2.5 pr-3 text-right tabular-nums text-xs">
{user.flagged_count > 0 ? (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
{user.flagged_count}
</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="py-2.5 pr-6 text-right tabular-nums text-muted-foreground text-xs">
{formatTimeAgo(user.last_active)}
</td>
</>
)}
{compact && (
<td className="py-2.5 pr-6 text-right">
<span className="font-mono text-sm font-bold tabular-nums">{user.message_count}</span>
<span className="ml-1 text-[10px] text-muted-foreground">msg</span>
</td>
)}
</motion.tr>
))}
</tbody>
</table>
{compact && users.length > 5 && (
<div className="border-t border-border px-6 py-3 text-center text-xs text-muted-foreground">
+{users.length - 5} user lainnya lihat leaderboard lengkap di bawah
</div>
)}
</ScrollArea>
);
}
// ── Violators Leaderboard ──────────────────────────────────────────────
function ViolatorsLeaderboard({
users,
loading,
}: {
users: ViolatorStat[] | undefined;
loading: boolean;
}) {
if (loading && !users?.length) {
return <LoadingSkeleton />;
}
if (!users?.length) {
return (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
<Shield className="h-10 w-10 opacity-20" />
<p className="text-sm">Tidak ada pelanggaran terdeteksi. 🎉</p>
</div>
);
}
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
// Danger level colors
interface DangerLevel { bg: string; border: string; text: string; label: string }
function dangerLevel(score: number): DangerLevel {
if (score >= 10) return { bg: "bg-red-500/15 border-red-500/40", border: "border-red-500/40", text: "text-red-300", label: "HIGH" };
if (score >= 5) return { bg: "bg-amber-500/10 border-amber-500/30", border: "border-amber-500/30", text: "text-amber-300", label: "MED" };
return { bg: "bg-yellow-500/10 border-yellow-500/20", border: "border-yellow-500/20", text: "text-yellow-300", label: "LOW" };
}
return (
<ScrollArea className="max-h-[500px]">
<div className="divide-y divide-border/20">
{users.map((user, i) => {
const danger = dangerLevel(user.violation_score);
return (
<motion.div
key={user.user_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
className={cn(
"group flex items-center gap-4 px-6 py-3 transition-colors hover:bg-red-500/5",
)}
>
{/* Rank + Danger indicator */}
<div className="relative flex-shrink-0">
<div className={cn(
"flex h-10 w-10 items-center justify-center rounded-xl border text-lg font-bold",
danger.bg, danger.border, danger.text,
i < 3 && "shadow-lg",
)}>
{i + 1}
</div>
{i === 0 && (
<span className="absolute -right-1 -top-1 text-sm">🔥</span>
)}
</div>
{/* Avatar */}
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="h-9 w-9 rounded-full ring-1 ring-border/50" loading="lazy" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-xs font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-semibold text-sm">{user.username}</span>
<Badge
variant="outline"
className={cn(
"text-[9px] px-1.5 py-0 font-mono tracking-wider",
danger.text, danger.border,
)}
>
{danger.label}
</Badge>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
{user.worst_flags.length > 0 ? (
user.worst_flags.map((flag) => (
<span
key={flag}
className="inline-flex items-center rounded-md bg-red-500/10 px-1.5 py-0.5 text-red-300/80"
>
{flag}
</span>
))
) : (
<span className="italic">no flags</span>
)}
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-right tabular-nums flex-shrink-0">
<div>
<div className="text-xs text-muted-foreground">Pesan</div>
<div className="font-mono text-sm font-medium">{user.total_messages}</div>
</div>
<div>
<div className="text-xs text-amber-400/70">Warned</div>
<div className="font-mono text-sm font-medium text-amber-400">{user.warned_count}</div>
</div>
<div>
<div className="text-xs text-red-400/70">Flagged</div>
<div className="font-mono text-sm font-bold text-red-400">{user.flagged_count}</div>
</div>
<div className="w-24">
<div className="text-xs text-muted-foreground mb-1">Skor</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<motion.div
className={cn(
"h-full rounded-full",
user.violation_score >= 10
? "bg-gradient-to-r from-red-600 to-red-400"
: user.violation_score >= 5
? "bg-gradient-to-r from-amber-500 to-amber-400"
: "bg-gradient-to-r from-yellow-500 to-yellow-400",
)}
initial={{ width: 0 }}
animate={{ width: `${(user.violation_score / maxScore) * 100}%` }}
transition={{ delay: i * 0.05 + 0.2, duration: 0.6 }}
/>
</div>
<div className={cn("mt-0.5 text-xs font-bold font-mono", danger.text)}>
{user.violation_score}
</div>
</div>
</div>
</motion.div>
);
})}
</div>
</ScrollArea>
);
}
// ── Empty State ────────────────────────────────────────────────────────
function EmptyState({ icon: Icon, text }: { icon: typeof BarChart3; text: string }) {
return (
<Card className="border-dashed">
<CardContent className="flex min-h-[300px] flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/50">
<Icon className="h-8 w-8 text-muted-foreground/40" />
</div>
<p className="text-sm text-muted-foreground">{text}</p>
</CardContent>
</Card>
);
}
// ── Loading Skeleton ───────────────────────────────────────────────────
function LoadingSkeleton() {
return (
<div className="flex h-40 flex-col items-center justify-center gap-3">
<motion.div
className="h-10 w-10 rounded-full border-2 border-blue-500/20 border-t-blue-400"
animate={{ rotate: 360 }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1, ease: "linear" }}
/>
<motion.p
className="text-xs text-muted-foreground"
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 2 }}
>
Memuat data...
</motion.p>
</div>
);
}
// ── Helpers ────────────────────────────────────────────────────────────
function pct(part: number, total: number): number {
if (!total) return 0;
return Math.round((part / total) * 100);
}
function formatTimeAgo(ts: number): string {
// Use Jakarta time as reference for "ago" calculations
const jakartaNow = new Date(new Date().toLocaleString("en-US", { timeZone: "Asia/Jakarta" }));
const diff = jakartaNow.getTime() - ts;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "baru saja";
if (minutes < 60) return `${minutes}m lalu`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}j lalu`;
const days = Math.floor(hours / 24);
return `${days}h lalu`;
}
@@ -0,0 +1,113 @@
import { Activity, BarChart3 } from "lucide-react";
import type { Channel, Guild } from "../../types/voice";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Select } from "../ui/select";
import { Button } from "../ui/button";
import { cn } from "../../lib/utils";
const TIME_RANGES = [
{ label: "1j", value: 1 },
{ label: "3j", value: 3 },
{ label: "6j", value: 6 },
{ label: "12j", value: 12 },
{ label: "24j", value: 24 },
{ label: "48j", value: 48 },
{ label: "7h", value: 168 },
];
interface ControlBarProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
hours: number;
isFetching: boolean;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onHoursChange: (hours: number) => void;
onRefresh: () => void;
}
export function ControlBar({
guilds,
channels,
selectedGuild,
selectedChannel,
hours,
isFetching,
onGuildChange,
onChannelChange,
onHoursChange,
onRefresh,
}: ControlBarProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
Analisis Moderasi
</CardTitle>
<CardDescription>
Pantau statistik, tren topik, dan aktivitas user.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-3">
<Select
value={selectedGuild}
onChange={(e) => onGuildChange(e.target.value)}
placeholder="Pilih guild"
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
className="min-w-[180px]"
/>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="Semua channel"
options={[
{ value: "", label: "Semua channel" },
...channels.map((c) => ({ value: c.id, label: c.name })),
]}
className="min-w-[160px]"
/>
<div className="flex items-center gap-1 rounded-md bg-muted p-0.5">
{TIME_RANGES.map((tr) => (
<button
key={tr.value}
type="button"
onClick={() => onHoursChange(tr.value)}
className={cn(
"rounded-sm px-2.5 py-1 text-xs font-medium transition-colors",
hours === tr.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tr.label}
</button>
))}
</div>
<Button
onClick={onRefresh}
disabled={isFetching}
variant="outline"
size="sm"
className="ml-auto shrink-0"
>
{isFetching ? (
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
Memuat...
</span>
) : (
<span className="flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5" />
Refresh
</span>
)}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,112 @@
import { useMemo } from "react";
import type { HeatmapCell } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { cn } from "../../lib/utils";
const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
interface HeatmapProps {
cells: HeatmapCell[];
loading: boolean;
}
export function Heatmap({ cells, loading }: HeatmapProps) {
const maxCount = useMemo(() => Math.max(1, ...cells.map((c) => c.count)), [cells]);
if (loading && !cells?.length) {
return <LoadingBox />;
}
if (!cells?.length) {
return <EmptyBox />;
}
const cellMap = new Map<string, HeatmapCell>();
for (const c of cells) cellMap.set(`${c.dayOfWeek}-${c.hour}`, c);
function getIntensity(day: number, hour: number): number {
return (cellMap.get(`${day}-${hour}`)?.count ?? 0) / maxCount;
}
function getHeatClass(intensity: number): string {
if (intensity === 0) return "bg-muted/30";
if (intensity < 0.1) return "bg-blue-500/10";
if (intensity < 0.2) return "bg-blue-500/20";
if (intensity < 0.35) return "bg-blue-500/30";
if (intensity < 0.5) return "bg-blue-500/45";
if (intensity < 0.7) return "bg-blue-500/60";
return "bg-blue-500/80";
}
return (
<Card className="col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Heatmap Aktivitas</CardTitle>
<CardDescription className="text-xs">Hari × jam area biru = lebih ramai.</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="min-w-[520px]">
{/* Header row */}
<div className="mb-1 ml-8 flex gap-[2px]">
{Array.from({ length: 24 }, (_, h) => (
<div key={h} className="flex-1 text-center text-[9px] text-muted-foreground tabular-nums">
{h % 3 === 0 ? `${h}` : ""}
</div>
))}
</div>
{/* Rows */}
{DAYS.map((day, d) => (
<div key={d} className="mb-[2px] flex items-center gap-[2px]">
<div className="w-8 shrink-0 text-right pr-1 text-[10px] text-muted-foreground">
{day}
</div>
{Array.from({ length: 24 }, (_, h) => {
const intensity = getIntensity(d, h);
const cell = cellMap.get(`${d}-${h}`);
return (
<div
key={h}
className={cn("flex-1 rounded-sm aspect-square", getHeatClass(intensity))}
title={`${day} ${h}:00 — ${cell?.count ?? 0} pesan`}
/>
);
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="mt-3 flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span>Sepi</span>
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-muted/30" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/20" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/45" />
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-blue-500/80" />
<span>Ramai</span>
</div>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
function EmptyBox() {
return (
<Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada data heatmap.
</CardContent>
</Card>
);
}
@@ -0,0 +1,54 @@
import type { ModerationBreakdown } from "../../api/analytics";
import { Card, CardContent } from "../ui/card";
import { cn } from "../../lib/utils";
interface SummaryCardsProps {
messages: ModerationBreakdown | null;
activeUsersCount: number;
totalChannels: number;
loading: boolean;
}
export function SummaryCards({ messages, activeUsersCount, totalChannels, loading }: SummaryCardsProps) {
const avgPerHour = messages ? Math.round(messages.total / Math.max(1, 24)) : 0;
const cleanPct = messages && messages.total > 0 ? Math.round((messages.clean / messages.total) * 100) : 0;
const warnedPct = messages && messages.total > 0 ? Math.round((messages.warned / messages.total) * 100) : 0;
const flaggedPct = messages && messages.total > 0 ? Math.round((messages.flagged / messages.total) * 100) : 0;
const cards = [
{ label: "Total Pesan", value: formatNum(messages?.total), accent: "text-foreground" },
{ label: "Rata-rata/jam", value: formatNum(avgPerHour), accent: "text-muted-foreground" },
{ label: "Clean", value: cleanPct > 0 ? `${cleanPct}%` : "—", accent: "text-emerald-400" },
{ label: "Warned", value: warnedPct > 0 ? `${warnedPct}%` : "—", accent: "text-amber-400" },
{ label: "Flagged", value: flaggedPct > 0 ? `${flaggedPct}%` : "—", accent: "text-red-400" },
{ label: "Pending", value: formatNum(messages?.pending), accent: "text-slate-400" },
{ label: "User Aktif", value: formatNum(activeUsersCount), accent: "text-violet-400" },
{ label: "Channel", value: formatNum(totalChannels), accent: "text-blue-400" },
];
return (
<div className="grid grid-cols-4 gap-2 lg:grid-cols-8">
{cards.map((card) => (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-3">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{card.label}
</div>
<div className={cn("mt-1 font-mono text-lg font-bold tabular-nums", card.accent)}>
{loading ? (
<span className="animate-pulse"></span>
) : (
card.value
)}
</div>
</CardContent>
</Card>
))}
</div>
);
}
function formatNum(v: number | undefined | null): string {
if (v == null || v === 0) return "—";
return v.toLocaleString("id-ID");
}
@@ -0,0 +1,77 @@
import { Flame } from "lucide-react";
import type { TopicTrend } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { ScrollArea } from "../ui/scroll-area";
interface TopicListProps {
topics: TopicTrend[];
loading: boolean;
}
export function TopicList({ topics, loading }: TopicListProps) {
if (loading && !topics?.length) {
return <LoadingBox />;
}
if (!topics?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Topik akan muncul setelah AI selesai menganalisis.
</CardContent>
</Card>
);
}
const maxCount = Math.max(...topics.map((t) => t.count), 1);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Flame className="h-4 w-4 text-orange-400" />
Topik Trending
</CardTitle>
<CardDescription className="text-xs">
Yang paling ramai dibicarakan.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<div className="divide-y divide-border/30">
{topics.map((topic, i) => (
<div key={topic.topic} className="flex items-center gap-3 px-5 py-2 text-sm">
<span className="w-5 shrink-0 text-right font-mono text-[10px] text-muted-foreground">
{i + 1}
</span>
<span className="flex-1 truncate font-medium">{topic.topic}</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500/60"
style={{ width: `${(topic.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-right font-mono text-xs tabular-nums text-muted-foreground">
{topic.count}
</span>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -0,0 +1,73 @@
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
import type { TrendBucket } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
interface TrendChartProps {
trend: TrendBucket[];
loading: boolean;
}
export function TrendChart({ trend, loading }: TrendChartProps) {
if (loading && !trend?.length) {
return <LoadingBox />;
}
if (!trend?.length) {
return null;
}
const data = trend.map((b) => ({
date: b.date,
clean: b.clean,
warned: b.warned,
flagged: b.flagged,
error: b.error,
total: b.count,
}));
return (
<Card className="col-span-3">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
<CardDescription className="text-xs">Volume pesan per hari dengan status moderasi.</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} tickFormatter={(d: string) => d.slice(5)} />
<YAxis tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={false} width={28} />
<Tooltip
contentStyle={{ backgroundColor: "#1e293b", border: "1px solid #334155", borderRadius: "6px", fontSize: "11px", color: "#e2e8f0" }}
formatter={(value: unknown, name: unknown) => {
const v = typeof value === "number" ? value : String(value);
return [v, label(String(name))];
}}
labelFormatter={(l: unknown) => String(l)}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
<Line type="monotone" dataKey="total" stroke="#3b82f6" strokeWidth={2} dot={false} name="Total" />
<Line type="monotone" dataKey="clean" stroke="#10b981" strokeWidth={1.5} dot={false} name="Clean" />
<Line type="monotone" dataKey="warned" stroke="#f59e0b" strokeWidth={1.5} dot={false} name="Warned" />
<Line type="monotone" dataKey="flagged" stroke="#ef4444" strokeWidth={1.5} dot={false} name="Flagged" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
function label(key: string): string {
const map: Record<string, string> = { total: "Total", clean: "Clean", warned: "Warned", flagged: "Flagged" };
return map[key] ?? key;
}
function LoadingBox() {
return (
<Card className="col-span-3">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -0,0 +1,117 @@
import { Users } from "lucide-react";
import type { UserStat } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge";
import { ScrollArea } from "../ui/scroll-area";
interface UserTableProps {
users: UserStat[];
loading: boolean;
}
export function UserTable({ users, loading }: UserTableProps) {
if (loading && !users?.length) {
return <LoadingBox />;
}
if (!users?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Belum ada aktivitas user.
</CardContent>
</Card>
);
}
const maxMsgs = Math.max(...users.map((u) => u.message_count), 1);
const medals = ["🥇", "🥈", "🥉"];
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4 text-violet-400" />
User Paling Aktif
</CardTitle>
<CardDescription className="text-xs">
Leaderboard berdasarkan jumlah pesan.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
<th className="py-2 pr-2 font-semibold">User</th>
<th className="py-2 pr-2 font-semibold text-right">Pesan</th>
<th className="py-2 pr-2 font-semibold text-right">Edit</th>
<th className="py-2 pr-2 font-semibold text-right">Hapus</th>
<th className="py-2 pr-4 font-semibold text-right">Flag</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{users.map((user, i) => (
<tr key={user.user_id} className="hover:bg-muted/20 transition-colors">
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
{medals[i] ?? i + 1}
</td>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="h-6 w-6 rounded-full" loading="lazy" />
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="max-w-[100px] truncate text-xs font-medium">{user.username}</span>
</div>
</td>
<td className="py-1.5 pr-2 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="h-1 w-8 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-blue-500/60"
style={{ width: `${(user.message_count / maxMsgs) * 100}%` }}
/>
</div>
<span className="font-mono text-xs tabular-nums">{user.message_count}</span>
</div>
</td>
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
{user.edited_count > 0 ? user.edited_count : "—"}
</td>
<td className="py-1.5 pr-2 text-right font-mono text-[10px] text-muted-foreground tabular-nums">
{user.deleted_count > 0 ? user.deleted_count : "—"}
</td>
<td className="py-1.5 pr-4 text-right">
{user.flagged_count > 0 ? (
<Badge variant="destructive" className="text-[9px] px-1 py-0">
{user.flagged_count}
</Badge>
) : (
<span className="text-[10px] text-muted-foreground"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
);
}
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
@@ -0,0 +1,132 @@
import { Siren } from "lucide-react";
import type { ViolatorStat } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge";
import { ScrollArea } from "../ui/scroll-area";
interface ViolatorTableProps {
users: ViolatorStat[];
loading: boolean;
}
export function ViolatorTable({ users, loading }: ViolatorTableProps) {
if (loading && !users?.length) {
return <LoadingBox />;
}
if (!users?.length) {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
Tidak ada pelanggaran terdeteksi.
</CardContent>
</Card>
);
}
const maxScore = Math.max(...users.map((u) => u.violation_score), 1);
function dangerLabel(score: number) {
if (score >= 10) return { variant: "destructive" as const, text: "HIGH" };
if (score >= 5) return { variant: "warning" as const, text: "MED" };
return { variant: "secondary" as const, text: "LOW" };
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Siren className="h-4 w-4 text-red-400" />
Pelanggar Terbanyak
</CardTitle>
<CardDescription className="text-xs">
Skor: flagged × 3 + warned × 1.
</CardDescription>
</div>
<Badge variant="destructive">{users.length} pelanggar</Badge>
</div>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="max-h-[260px]">
<table className="w-full text-sm">
<thead>
<tr className="sticky top-0 z-10 bg-card/95 backdrop-blur border-b border-border text-left text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="py-2 pl-4 pr-2 font-semibold">#</th>
<th className="py-2 pr-2 font-semibold">User</th>
<th className="py-2 pr-2 font-semibold text-right">Warned</th>
<th className="py-2 pr-2 font-semibold text-right">Flagged</th>
<th className="py-2 pr-4 font-semibold text-right">Skor</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{users.map((user, i) => {
const danger = dangerLabel(user.violation_score);
return (
<tr key={user.user_id} className="hover:bg-red-500/5 transition-colors">
<td className="py-1.5 pl-4 pr-2 font-mono text-[10px] text-muted-foreground tabular-nums">
{i + 1}
</td>
<td className="py-1.5 pr-2">
<div className="flex items-center gap-2">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="h-6 w-6 rounded-full" loading="lazy" />
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-[10px] font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<span className="max-w-[100px] truncate text-xs font-medium">{user.username}</span>
<Badge variant={danger.variant} className="text-[9px] px-1 py-0">
{danger.text}
</Badge>
</div>
</td>
<td className="py-1.5 pr-2 text-right font-mono text-xs text-amber-400 tabular-nums">
{user.warned_count}
</td>
<td className="py-1.5 pr-2 text-right font-mono text-xs text-red-400 tabular-nums">
{user.flagged_count}
</td>
<td className="py-1.5 pr-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full",
user.violation_score >= 10
? "bg-gradient-to-r from-red-600 to-red-400"
: user.violation_score >= 5
? "bg-gradient-to-r from-amber-500 to-amber-400"
: "bg-gradient-to-r from-yellow-500 to-yellow-400",
)}
style={{ width: `${(user.violation_score / maxScore) * 100}%` }}
/>
</div>
<span className="font-mono text-xs font-bold tabular-nums">{user.violation_score}</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</ScrollArea>
</CardContent>
</Card>
);
}
import { cn } from "../../lib/utils";
function LoadingBox() {
return (
<Card>
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</CardContent>
</Card>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useState } from "react";
import type { Channel, Guild } from "../../types/voice";
import { useAnalytics } from "../../hooks/useAnalytics";
import { ControlBar } from "./ControlBar";
import { SummaryCards } from "./SummaryCards";
import { ActivityChart } from "./ActivityChart";
import { TrendChart } from "./TrendChart";
import { Heatmap } from "./Heatmap";
import { TopicList } from "./TopicList";
import { UserTable } from "./UserTable";
import { ViolatorTable } from "./ViolatorTable";
interface AnalyticsPanelProps {
guilds: Guild[];
channels: Channel[];
selectedGuild: string;
selectedChannel: string;
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
}
export function AnalyticsPanel({
guilds,
channels,
selectedGuild,
selectedChannel,
onGuildChange,
onChannelChange,
}: AnalyticsPanelProps) {
const [hours, setHours] = useState(24);
const {
messages,
hourly,
topics,
topUsers,
activeUsersCount,
totalChannels,
violators,
trend,
heatmap,
isLoading,
isFetching,
error,
refresh,
refreshViolators,
} = useAnalytics({ guildId: selectedGuild, channelId: selectedChannel || undefined, hours });
const loading = isLoading && !isFetching;
if (error && !messages) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">
{error}
</div>
);
}
if (!selectedGuild) {
return (
<div className="flex min-h-[300px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8">
<p className="text-sm text-muted-foreground">Pilih guild untuk melihat analitik.</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* Control bar */}
<ControlBar
guilds={guilds}
channels={channels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
hours={hours}
isFetching={isFetching}
onGuildChange={onGuildChange}
onChannelChange={onChannelChange}
onHoursChange={setHours}
onRefresh={() => { refresh(); refreshViolators(); }}
/>
{/* Summary cards */}
<SummaryCards
messages={messages}
activeUsersCount={activeUsersCount}
totalChannels={totalChannels}
loading={loading}
/>
{/* Hourly chart */}
<div className="grid grid-cols-3 gap-4">
<ActivityChart hourly={hourly} loading={loading} />
<div className="col-span-1">
<TopicList topics={topics} loading={loading} />
</div>
</div>
{/* Trend chart — only show when enough data */}
{hours >= 48 && (
<TrendChart trend={trend} loading={loading} />
)}
{/* Heatmap + leaderboard */}
<div className="grid grid-cols-3 gap-4">
<Heatmap cells={heatmap} loading={loading} />
<div className="col-span-1">
<UserTable users={topUsers} loading={loading} />
</div>
</div>
{/* Violators */}
<ViolatorTable users={violators} loading={loading} />
</div>
);
}
+41 -7
View File
@@ -3,11 +3,15 @@ import { useCallback, useEffect } from "react";
import {
fetchAnalyticsOverview,
fetchViolators,
fetchTrend,
fetchHeatmap,
type AnalyticsOverview,
type HourlyBucket,
type TopicTrend,
type UserStat,
type ViolatorStat,
type TrendBucket,
type HeatmapCell,
} from "../api/analytics";
interface UseAnalyticsOptions {
@@ -21,6 +25,8 @@ function analyticsKeys(guildId: string, channelId: string | undefined, hours: nu
return {
overview: ["analytics", "overview", guildId, channelId ?? "", hours] as const,
violators: ["analytics", "violators", guildId, channelId ?? "", hours] as const,
trend: ["analytics", "trend", guildId, channelId ?? "", hours] as const,
heatmap: ["analytics", "heatmap", guildId, channelId ?? "", hours] as const,
all: ["analytics"] as const,
};
}
@@ -34,8 +40,8 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
queryKey: keys.overview,
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 30_000, // 30s — data is fresh enough; WebSocket invalidates on change
placeholderData: keepPreviousData, // show previous data while fetching new params
staleTime: 30_000,
placeholderData: keepPreviousData,
});
// ── Violators query ──────────────────────────────────────────────────
@@ -48,12 +54,32 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
placeholderData: keepPreviousData,
});
// ── Trend query ──────────────────────────────────────────────────────
const trendQuery = useQuery({
queryKey: keys.trend,
queryFn: () => fetchTrend({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 60_000,
placeholderData: keepPreviousData,
});
// ── Heatmap query ────────────────────────────────────────────────────
const heatmapQuery = useQuery({
queryKey: keys.heatmap,
queryFn: () => fetchHeatmap({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 60_000,
placeholderData: keepPreviousData,
});
// ── Refresh: invalidate & refetch ────────────────────────────────────
const refresh = useCallback(() => {
if (!guildId) return;
queryClient.invalidateQueries({ queryKey: keys.overview });
queryClient.invalidateQueries({ queryKey: keys.violators });
}, [queryClient, keys.overview, keys.violators, guildId]);
queryClient.invalidateQueries({ queryKey: keys.trend });
queryClient.invalidateQueries({ queryKey: keys.heatmap });
}, [queryClient, keys, guildId]);
// Real-time refresh via WebSocket-triggered custom event
useEffect(() => {
@@ -68,9 +94,7 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
return {
overview,
// isLoading = true only on first load with no cached data
isLoading,
// isFetching = true on background refetch while showing stale data
isFetching,
error: overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
refresh,
@@ -83,7 +107,17 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
if (guildId) queryClient.invalidateQueries({ queryKey: keys.violators });
},
// Convenience accessors (safe navigation into nullable overview)
// Trend
trend: trendQuery.data ?? [],
trendLoading: trendQuery.isLoading && !trendQuery.data,
trendFetching: trendQuery.isFetching && !trendQuery.isLoading,
// Heatmap
heatmap: heatmapQuery.data ?? [],
heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data,
heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading,
// Convenience accessors
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
topics: overview?.topics ?? ([] as TopicTrend[]),
topUsers: overview?.top_users ?? ([] as UserStat[]),
@@ -95,4 +129,4 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt
}
// Re-export for convenience
export type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat };
export type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat, TrendBucket, HeatmapCell };
+1
View File
@@ -57,6 +57,7 @@
"prom-client": "^15.1.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"vite": "^8.0.13",
"winston": "^3.19.0",
+302
View File
@@ -101,6 +101,9 @@ importers:
react-dom:
specifier: ^19.2.6
version: 19.2.6(react@19.2.6)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -1573,6 +1576,17 @@ packages:
'@types/react':
optional: true
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.0.1':
resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1841,6 +1855,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tailwindcss/node@4.3.0':
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
@@ -1960,6 +1977,33 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
@@ -2037,6 +2081,9 @@ packages:
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -2478,6 +2525,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
@@ -2512,6 +2603,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decode-uri-component@0.4.1:
resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==}
engines: {node: '>=14.16'}
@@ -2736,6 +2830,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.47.0:
resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
@@ -2827,6 +2924,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -3152,6 +3252,12 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.8:
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -3181,6 +3287,10 @@ packages:
int64-buffer@1.1.0:
resolution: {integrity: sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip@2.0.1:
resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==}
@@ -4091,6 +4201,18 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@@ -4110,6 +4232,14 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -4134,6 +4264,14 @@ packages:
resolution: {integrity: sha512-zQv5y/cf85sxvdrKPlfcRzlDn/OqKFThNimYmsS3flmkioKvkUGn2Qg9cJVoQiEvdxFGLE0MQER/9fZ9sUqdxg==}
engines: {node: '>=0.10.0'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -4151,6 +4289,9 @@ packages:
requizzle@0.2.4:
resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -4509,6 +4650,9 @@ packages:
thunky@1.1.0:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -4719,6 +4863,11 @@ packages:
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -4736,6 +4885,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@8.0.13:
resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -6005,6 +6157,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.8
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.6
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.0.1':
optional: true
@@ -6174,6 +6338,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.3.0':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -6275,6 +6441,30 @@ snapshots:
dependencies:
'@types/node': 25.8.0
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
@@ -6361,6 +6551,8 @@ snapshots:
'@types/triple-beam@1.3.5': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.8.0
@@ -6793,6 +6985,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.29.2
@@ -6826,6 +7056,8 @@ snapshots:
decamelize@1.2.0: {}
decimal.js-light@2.5.1: {}
decode-uri-component@0.4.1: {}
decompress-response@6.0.0:
@@ -6958,6 +7190,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.3
es-toolkit@1.47.0: {}
esbuild@0.18.20:
optionalDependencies:
'@esbuild/android-arm': 0.18.20
@@ -7143,6 +7377,8 @@ snapshots:
etag@1.8.1: {}
eventemitter3@5.0.4: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -7514,6 +7750,10 @@ snapshots:
ignore@5.3.2: {}
immer@10.2.0: {}
immer@11.1.8: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -7550,6 +7790,8 @@ snapshots:
int64-buffer@1.1.0: {}
internmap@2.0.3: {}
ip@2.0.1: {}
ipaddr.js@1.9.1: {}
@@ -8386,6 +8628,15 @@ snapshots:
react-is@18.3.1: {}
react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.6
use-sync-external-store: 1.6.0(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react@19.2.6: {}
read-pkg-up@7.0.1:
@@ -8417,6 +8668,26 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.47.0
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 18.3.1
react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.6)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@@ -8436,6 +8707,12 @@ snapshots:
dependencies:
test-value: 2.1.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect-metadata@0.2.2: {}
regexpp@2.0.1: {}
@@ -8448,6 +8725,8 @@ snapshots:
dependencies:
lodash: 4.18.1
reselect@5.1.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -8854,6 +9133,8 @@ snapshots:
thunky@1.1.0: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.1.2: {}
@@ -9020,6 +9301,10 @@ snapshots:
url-join@5.0.0: {}
use-sync-external-store@1.6.0(react@19.2.6):
dependencies:
react: 19.2.6
util-deprecate@1.0.2: {}
v8-compile-cache@2.4.0: {}
@@ -9033,6 +9318,23 @@ snapshots:
vary@1.1.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
+397 -45
View File
@@ -1,6 +1,6 @@
import { config } from "../config.js";
import { executeAll, executeGet } from "../database/drizzle.js";
import { createChildLogger } from "../logger.js";
import { config } from "../config.js";
import type { MessageRecord } from "./types.js";
const logger = createChildLogger("analytics-store");
@@ -130,15 +130,19 @@ export async function getHourlyStats(input: {
GROUP BY (created_at / 3600000)
ORDER BY hour ASC
`,
channelId
? [guildId, since, channelId, channelId]
: [guildId, since],
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all hour buckets (fill gaps with zeros)
const buckets = new Map<
string,
{ count: number; clean: number; warned: number; flagged: number; error: number }
{
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
>();
for (let h = 0; h < hours; h++) {
@@ -178,25 +182,156 @@ export async function getHourlyStats(input: {
// ── Topic Trends ───────────────────────────────────────────────────────
const STOP_WORDS = new Set([
"yang", "dan", "itu", "ini", "dengan", "akan", "pada", "dari", "di", "ke",
"untuk", "tidak", "ada", "juga", "sudah", "saya", "kamu", "dia", "mereka",
"kami", "aku", "lo", "lu", "gua", "gue", "org", "orang", "aja", "sama",
"kalo", "kalau", "bisa", "karena", "gak", "nggak", "ga", "tak", "belum",
"udah", "dah", "lah", "kah", "pun", "nih", "tuh", "deh", "dong", "si",
"nya", "kan", "ya", "yah", "yuk", "kok", "loh", "nah", "wow", "eh",
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "having", "do", "does", "did", "doing",
"will", "would", "could", "should", "may", "might", "must", "shall",
"i", "you", "he", "she", "it", "we", "they",
"me", "him", "her", "us", "them",
"my", "your", "his", "its", "our", "their",
"and", "but", "or", "nor", "not", "so", "yet", "for", "if",
"to", "of", "in", "on", "at", "by", "as", "with",
"about", "just", "then", "now", "here", "there",
"when", "where", "why", "how",
"all", "both", "each", "few", "more", "most", "other",
"some", "such", "only", "own", "same", "too", "very",
"can", "go", "ok", "okay", "yeah", "yes", "no",
"yang",
"dan",
"itu",
"ini",
"dengan",
"akan",
"pada",
"dari",
"di",
"ke",
"untuk",
"tidak",
"ada",
"juga",
"sudah",
"saya",
"kamu",
"dia",
"mereka",
"kami",
"aku",
"lo",
"lu",
"gua",
"gue",
"org",
"orang",
"aja",
"sama",
"kalo",
"kalau",
"bisa",
"karena",
"gak",
"nggak",
"ga",
"tak",
"belum",
"udah",
"dah",
"lah",
"kah",
"pun",
"nih",
"tuh",
"deh",
"dong",
"si",
"nya",
"kan",
"ya",
"yah",
"yuk",
"kok",
"loh",
"nah",
"wow",
"eh",
"the",
"a",
"an",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"having",
"do",
"does",
"did",
"doing",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"shall",
"i",
"you",
"he",
"she",
"it",
"we",
"they",
"me",
"him",
"her",
"us",
"them",
"my",
"your",
"his",
"its",
"our",
"their",
"and",
"but",
"or",
"nor",
"not",
"so",
"yet",
"for",
"if",
"to",
"of",
"in",
"on",
"at",
"by",
"as",
"with",
"about",
"just",
"then",
"now",
"here",
"there",
"when",
"where",
"why",
"how",
"all",
"both",
"each",
"few",
"more",
"most",
"other",
"some",
"such",
"only",
"own",
"same",
"too",
"very",
"can",
"go",
"ok",
"okay",
"yeah",
"yes",
"no",
]);
function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
@@ -232,7 +367,10 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
existing.count++;
existing.score += msg.ai_moderation_score || 0;
} else {
topicScores.set(cat, { count: 1, score: msg.ai_moderation_score || 0 });
topicScores.set(cat, {
count: 1,
score: msg.ai_moderation_score || 0,
});
}
}
} catch {
@@ -267,7 +405,11 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
for (const [word, count] of sortedWords) {
if (!topicScores.has(word)) {
results.push({ topic: word, count, score: flaggedWordFreq.get(word) || 0 });
results.push({
topic: word,
count,
score: flaggedWordFreq.get(word) || 0,
});
}
}
@@ -289,7 +431,7 @@ export async function getTopicTrends(input: {
// Only fetch messages that have ai_analysis (the ones that actually have topics)
// This dramatically reduces rows for large guilds
const rows = await executeAll(
const rows = (await executeAll(
`
SELECT
id, content, ai_status, ai_analysis, ai_moderation_score,
@@ -303,10 +445,8 @@ export async function getTopicTrends(input: {
ORDER BY created_at DESC
LIMIT 2000
`,
channelId
? [guildId, since, channelId, channelId]
: [guildId, since],
) as MessageRecord[];
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
)) as MessageRecord[];
const result = extractTopics(rows);
setCache(cacheKey, result, TOPIC_CACHE_TTL_MS);
@@ -329,7 +469,12 @@ export async function getUserLeaderboard(input: {
limit?: number;
}): Promise<UserStat[]> {
const { guildId, channelId, hours = 24, limit = 20 } = input;
const cacheKey = makeCacheKey("leaderboard", { guildId, channelId, hours, limit });
const cacheKey = makeCacheKey("leaderboard", {
guildId,
channelId,
hours,
limit,
});
const cached = getCached<UserStat[]>(cacheKey);
if (cached) return cached;
@@ -408,9 +553,7 @@ export async function getModerationStats(input: {
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
`,
channelId
? [guildId, since, channelId, channelId]
: [guildId, since],
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
const result: ModerationBreakdown = row
@@ -423,7 +566,15 @@ export async function getModerationStats(input: {
pending: row.pending ?? 0,
average_score: row.average_score ?? 0,
}
: { total: 0, clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, average_score: 0 };
: {
total: 0,
clean: 0,
warned: 0,
flagged: 0,
error: 0,
pending: 0,
average_score: 0,
};
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
@@ -432,7 +583,15 @@ export async function getModerationStats(input: {
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get moderation stats",
);
return { total: 0, clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, average_score: 0 };
return {
total: 0,
clean: 0,
warned: 0,
flagged: 0,
error: 0,
pending: 0,
average_score: 0,
};
}
}
@@ -493,7 +652,12 @@ export async function getTopViolators(input: {
limit?: number;
}): Promise<ViolatorStat[]> {
const { guildId, channelId, hours = 24, limit = 20 } = input;
const cacheKey = makeCacheKey("violators", { guildId, channelId, hours, limit });
const cacheKey = makeCacheKey("violators", {
guildId,
channelId,
hours,
limit,
});
const cached = getCached<ViolatorStat[]>(cacheKey);
if (cached) return cached;
@@ -551,6 +715,192 @@ export async function getTopViolators(input: {
}
}
// ── Daily Trend (for multi-day line chart) ────────────────────────────
export interface TrendBucket {
date: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export async function getDailyTrend(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TrendBucket[]> {
const { guildId, channelId, hours = 168 } = input;
const cacheKey = makeCacheKey("daily_trend", { guildId, channelId, hours });
const cached = getCached<TrendBucket[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const isPg = config.DATABASE_TYPE === "postgres";
const dateExpr = isPg
? `to_char(date_trunc('day', to_timestamp(created_at / 1000)), 'YYYY-MM-DD') as date`
: `date(created_at / 1000, 'unixepoch') as date`;
const rows = await executeAll(
`
SELECT
${dateExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged,
count(case when ai_status = 'error' then 1 end) as error
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY date(created_at / 1000, 'unixepoch')
ORDER BY date ASC
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all day buckets (fill gaps with zeros)
const buckets = new Map<
string,
{
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
>();
const msPerDay = 86400_000;
const startDay = Math.floor(since / msPerDay) * msPerDay;
const endDay = Math.floor(Date.now() / msPerDay) * msPerDay;
for (let d = startDay; d <= endDay; d += msPerDay) {
const key = new Date(d).toISOString().slice(0, 10);
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
}
for (const row of rows) {
const bucket = buckets.get(row.date);
if (!bucket) continue;
bucket.count = row.count;
bucket.clean = row.clean;
bucket.warned = row.warned;
bucket.flagged = row.flagged;
bucket.error = row.error;
}
const result = Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, data]) => ({ date, ...data }));
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get daily trend",
);
return [];
}
}
// ── Activity Heatmap (day-of-week × hour-of-day) ──────────────────────
export interface HeatmapCell {
dayOfWeek: number; // 0=Senin, 6=Minggu
hour: number; // 0-23
count: number;
clean: number;
warned: number;
flagged: number;
}
export async function getActivityHeatmap(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HeatmapCell[]> {
const { guildId, channelId, hours = 168 } = input;
const cacheKey = makeCacheKey("heatmap", { guildId, channelId, hours });
const cached = getCached<HeatmapCell[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const isPg = config.DATABASE_TYPE === "postgres";
// SQLite: cast to int for modulo; Postgres: use extract()
const dayExpr = isPg
? `(extract(isodow from to_timestamp(created_at / 1000)) % 7)::int as day_of_week`
: `(cast((created_at / 86400000) as integer) % 7) as day_of_week`;
const hourExpr = isPg
? `extract(hour from to_timestamp(created_at / 1000))::int as hour`
: `(cast((created_at / 3600000) as integer) % 24) as hour`;
const rows = await executeAll(
`
SELECT
${dayExpr},
${hourExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY day_of_week, hour
ORDER BY day_of_week, hour
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all 7×24 cells with zeros
const cells = new Map<
string,
{ count: number; clean: number; warned: number; flagged: number }
>();
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
cells.set(`${d}-${h}`, { count: 0, clean: 0, warned: 0, flagged: 0 });
}
}
for (const row of rows) {
const key = `${row.day_of_week}-${row.hour}`;
const cell = cells.get(key);
if (!cell) continue;
cell.count = row.count;
cell.clean = row.clean;
cell.warned = row.warned;
cell.flagged = row.flagged;
}
const result = Array.from(cells.entries())
.map(([key, data]) => {
const [dayOfWeek, hour] = key.split("-").map(Number);
return { dayOfWeek, hour, ...data };
})
.sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.hour - b.hour);
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get activity heatmap",
);
return [];
}
}
// ── Cache Invalidation (called when new messages arrive) ───────────────
export function invalidateAnalyticsCache(guildId: string): void {
@@ -574,13 +924,15 @@ export async function getAnalyticsOverview(input: {
const now = Date.now();
const since = now - hours * 3600_000;
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all([
getModerationStats(input),
getHourlyStats(input),
getTopicTrends(input),
getUserLeaderboard(input),
getActiveChannelCount({ guildId, hours }),
]);
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all(
[
getModerationStats(input),
getHourlyStats(input),
getTopicTrends(input),
getUserLeaderboard(input),
getActiveChannelCount({ guildId, hours }),
],
);
return {
period: { start: since, end: now },
+283 -17
View File
@@ -1,7 +1,9 @@
import axios from "axios";
import OpenAI from "openai";
import { config } from "../config.js";
import { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js";
import { createChildLogger } from "../logger.js";
import { retryWithBackoff } from "../retry.js";
const log = createChildLogger("indonesianTextNormalizer");
@@ -36,6 +38,46 @@ const CATEGORY_TO_BADWORD_LABEL: Record<string, string> = {
insult: "harassment",
};
const VALID_PRIMARY_AI_FLAGS = new Set([
"spam",
"hate_speech",
"sara",
"hoaks",
"harassment",
"vulgar_language",
"sexual_content",
"sexual_deviation",
"violence",
"self_harm",
"doxxing",
"scam",
"misinformation",
"nsfw_image",
"gore_image",
"illegal_content",
"gambling",
"drugs",
"child_safety",
"financial_scam",
"religious_insult",
"self_promo",
]);
const BADWORD_CACHE_TTL_MS = 10 * 60 * 1000;
const NEMOTRON_RATE_LIMIT_COOLDOWN_MS = 60 * 1000;
const PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS = 30 * 1000;
interface BadwordCacheEntry {
value: string[];
expiresAt: number;
}
const badwordCache = new Map<string, BadwordCacheEntry>();
const inFlightBadwordLookups = new Map<string, Promise<string[]>>();
let nemotronUnavailableUntil = 0;
let primaryAiUnavailableUntil = 0;
let primaryModerationClient: OpenAI | null = null;
export interface ModerationTextEvidence {
raw: string;
normalized: string;
@@ -159,6 +201,174 @@ function detectLocalBadwords(text: string): string[] {
return Array.from(new Set(hits));
}
function normalizeBadwordCacheKey(text: string): string {
return text.trim().replace(/\s+/g, " ").toLowerCase();
}
function getCachedBadwords(key: string): string[] | null {
const entry = badwordCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
badwordCache.delete(key);
return null;
}
return [...entry.value];
}
function setCachedBadwords(key: string, value: string[]): void {
badwordCache.set(key, {
value: [...new Set(value)],
expiresAt: Date.now() + BADWORD_CACHE_TTL_MS,
});
if (badwordCache.size > 500) {
const now = Date.now();
for (const [cacheKey, entry] of badwordCache) {
if (entry.expiresAt <= now) {
badwordCache.delete(cacheKey);
}
}
if (badwordCache.size > 500) {
const oldestKeys = Array.from(badwordCache.entries())
.sort((a, b) => a[1].expiresAt - b[1].expiresAt)
.slice(0, badwordCache.size - 500)
.map(([cacheKey]) => cacheKey);
for (const cacheKey of oldestKeys) {
badwordCache.delete(cacheKey);
}
}
}
}
function getPrimaryModerationClient(): OpenAI | null {
if (!config.AI_LLM_API_KEY) {
return null;
}
if (!primaryModerationClient) {
primaryModerationClient = new OpenAI({
apiKey: config.AI_LLM_API_KEY,
baseURL: config.AI_LLM_BASE_URL,
maxRetries: 0,
timeout: 15000,
});
}
return primaryModerationClient;
}
function normalizePrimaryAiFlag(value: string): string | null {
const lower = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
if (!lower) return null;
if (VALID_PRIMARY_AI_FLAGS.has(lower)) {
return lower;
}
return CATEGORY_TO_BADWORD_LABEL[lower] ?? null;
}
function extractFlagsFromPrimaryAiContent(content: string): string[] {
const flags = new Set<string>();
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
parsed = null;
}
const addValue = (value: unknown) => {
if (typeof value !== "string") return;
const normalized = normalizePrimaryAiFlag(value);
if (normalized) flags.add(normalized);
};
if (Array.isArray(parsed)) {
for (const item of parsed) {
addValue(item);
}
} else if (parsed && typeof parsed === "object") {
const candidate = parsed as Record<string, unknown>;
for (const key of ["flags", "categories", "badwords"]) {
const value = candidate[key];
if (Array.isArray(value)) {
for (const item of value) addValue(item);
} else {
addValue(value);
}
}
}
if (flags.size > 0) {
return Array.from(flags);
}
const lowerContent = content.toLowerCase();
for (const flag of VALID_PRIMARY_AI_FLAGS) {
if (lowerContent.includes(flag)) {
flags.add(flag);
}
}
for (const category of Object.keys(CATEGORY_TO_BADWORD_LABEL)) {
if (lowerContent.includes(category)) {
const mapped = CATEGORY_TO_BADWORD_LABEL[category];
if (mapped) flags.add(mapped);
}
}
return Array.from(flags);
}
async function callPrimaryAiModeration(text: string): Promise<string[]> {
const client = getPrimaryModerationClient();
if (!client) {
return [];
}
const completion = await retryWithBackoff(
async () => {
return client.chat.completions.create({
model: config.AI_LLM_MODEL,
messages: [
{
role: "user",
content:
"Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " +
"Balas hanya JSON object dengan format {\"flags\":[...]} dan gunakan hanya flag valid ini: " +
Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") +
". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " +
text,
},
],
temperature: 0.1,
top_p: 0.9,
max_tokens: 200,
stream: false,
response_format: { type: "json_object" },
chat_template_kwargs: { enable_thinking: false },
reasoning_budget: 0,
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming);
},
{
retries: 1,
minTimeout: 500,
maxTimeout: 2000,
factor: 2,
logger: log,
},
);
const content = completion.choices[0]?.message?.content?.trim();
if (!content) {
return [];
}
return extractFlagsFromPrimaryAiContent(content);
}
// ---------------------------------------------------------------------------
// NVIDIA Nemotron-3 Content Safety API
// ---------------------------------------------------------------------------
@@ -236,25 +446,81 @@ async function callNemotronContentSafety(text: string): Promise<string[]> {
export async function detectIndonesianBadwords(
text: string,
): Promise<string[]> {
// Always run local detection first (fast, no network dependency)
const localHits = detectLocalBadwords(text);
// Try NVIDIA API if key is configured
const apiKey = config.NVIDIA_NEMOTRON_API_KEY;
if (apiKey) {
try {
const apiCategories = await callNemotronContentSafety(text);
const allHits = Array.from(new Set([...localHits, ...apiCategories]));
return allHits;
} catch (error) {
log.warn(
{ error },
"NVIDIA Nemotron API call failed, falling back to local detection",
);
}
const cacheKey = normalizeBadwordCacheKey(text);
const cached = getCachedBadwords(cacheKey);
if (cached) {
return cached;
}
return localHits;
const inFlight = inFlightBadwordLookups.get(cacheKey);
if (inFlight) {
return inFlight;
}
const lookupPromise = (async () => {
// Always run local detection first (fast, no network dependency)
const localHits = detectLocalBadwords(text);
// If we already have explicit local badword hits, avoid unnecessary API calls.
if (localHits.length > 0) {
setCachedBadwords(cacheKey, localHits);
return localHits;
}
const hits = new Set<string>(localHits);
// Try NVIDIA API if key is configured and it is not rate limited.
const apiKey = config.NVIDIA_NEMOTRON_API_KEY;
if (apiKey && Date.now() >= nemotronUnavailableUntil) {
try {
const apiCategories = await callNemotronContentSafety(text);
for (const hit of apiCategories) {
hits.add(hit);
}
} catch (error) {
const status = axios.isAxiosError(error) ? error.response?.status : null;
if (status === 429) {
nemotronUnavailableUntil = Date.now() + NEMOTRON_RATE_LIMIT_COOLDOWN_MS;
}
log.warn(
{ error },
"NVIDIA Nemotron API call failed, falling back to primary AI then local detection",
);
}
}
// Try the main AI model next, mirroring the image-analysis fallback path.
if (hits.size === 0 && Date.now() >= primaryAiUnavailableUntil) {
try {
const primaryHits = await callPrimaryAiModeration(text);
for (const hit of primaryHits) {
hits.add(hit);
}
} catch (error) {
const status = axios.isAxiosError(error) ? error.response?.status : null;
if (status === 429) {
primaryAiUnavailableUntil =
Date.now() + PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS;
}
log.warn(
{ error },
"Primary AI badword detection failed, falling back to local detection",
);
}
}
const finalHits = Array.from(hits);
setCachedBadwords(cacheKey, finalHits);
return finalHits;
})();
inFlightBadwordLookups.set(cacheKey, lookupPromise);
try {
return await lookupPromise;
} finally {
inFlightBadwordLookups.delete(cacheKey);
}
}
// ---------------------------------------------------------------------------
+67 -1
View File
@@ -2,11 +2,13 @@ import type { Router } from "express";
import express from "express";
import { AppError } from "../errors.js";
import {
getActivityHeatmap,
getAnalyticsOverview,
getDailyTrend,
getHourlyStats,
getModerationStats,
getTopViolators,
getTopicTrends,
getTopViolators,
getUserLeaderboard,
} from "../moderation/analyticsStore.js";
@@ -211,5 +213,69 @@ export function createAnalyticsRoutes(): Router {
}
});
// GET /api/analytics/trend - Daily trend data (for line chart)
// Query params: guildId (required), channelId, hours (default 168)
router.get("/analytics/trend", async (req, res, next) => {
try {
const { guildId, channelId, hours } = req.query as {
guildId?: string;
channelId?: string;
hours?: string;
};
if (!guildId) {
throw new AppError(
"guildId query parameter is required",
"MISSING_GUILD_ID",
400,
);
}
const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168;
const trend = await getDailyTrend({
guildId,
channelId,
hours: hoursNum,
});
res.json(trend);
} catch (error) {
next(error);
}
});
// GET /api/analytics/heatmap - Activity heatmap (day × hour)
// Query params: guildId (required), channelId, hours (default 168)
router.get("/analytics/heatmap", async (req, res, next) => {
try {
const { guildId, channelId, hours } = req.query as {
guildId?: string;
channelId?: string;
hours?: string;
};
if (!guildId) {
throw new AppError(
"guildId query parameter is required",
"MISSING_GUILD_ID",
400,
);
}
const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168;
const heatmap = await getActivityHeatmap({
guildId,
channelId,
hours: hoursNum,
});
res.json(heatmap);
} catch (error) {
next(error);
}
});
return router;
}
+19 -1
View File
@@ -1,4 +1,18 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/moderation/indonesianTextNormalizer.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../src/moderation/indonesianTextNormalizer.js")>();
return {
...actual,
formatModerationTextEvidenceForPrompt: vi.fn(async (content: string) => {
// Deterministic mock evidence — length tuned for the "tight budget" test:
// maxTokens=300, target ~88, c3 ~108, c2 ~108, c1 ~108
// Expectation: target+c3 fits (196), target+c3+c2 overflows (304)
return "[text_evidence] categories=[\"offensive\",\"profanity\",\"sexual_violence\"] severity=high confidence=0.92 language=id detected=badword normalized=false metadata_v2=true context=true";
}),
};
});
import {
buildConversationContext,
estimateTokens,
@@ -6,6 +20,10 @@ import {
} from "../../src/moderation/conversationContext";
import type { MessageRecord } from "../../src/moderation/types";
beforeEach(() => {
vi.clearAllMocks();
});
function message(
id: string,
content: string,
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { config } from "../../src/config";
const mocks = vi.hoisted(() => ({
axiosPost: vi.fn(),
openaiCreate: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
post: mocks.axiosPost,
isAxiosError: (error: unknown) =>
Boolean(
error &&
typeof error === "object" &&
"isAxiosError" in error &&
(error as { isAxiosError?: unknown }).isAxiosError,
),
},
}));
vi.mock("openai", () => ({
default: class MockOpenAI {
chat = {
completions: {
create: mocks.openaiCreate,
},
};
},
}));
describe("detectIndonesianBadwords remote fallback", () => {
beforeEach(() => {
mocks.axiosPost.mockReset();
mocks.openaiCreate.mockReset();
config.NVIDIA_NEMOTRON_API_KEY = "test-nemotron-key";
config.AI_LLM_API_KEY = "test-primary-key";
});
it("falls back to primary AI after Nemotron rate limits and caches the result", async () => {
mocks.axiosPost.mockRejectedValue({
isAxiosError: true,
response: { status: 429 },
message: "Too Many Requests",
});
mocks.openaiCreate.mockResolvedValue({
choices: [
{
message: {
content: JSON.stringify({ flags: ["harassment"] }),
},
},
],
});
const { detectIndonesianBadwords } = await import(
"../../src/moderation/indonesianTextNormalizer"
);
const first = await detectIndonesianBadwords("squad jump soalnya");
const second = await detectIndonesianBadwords("squad jump soalnya");
expect(first).toEqual(["harassment"]);
expect(second).toEqual(["harassment"]);
expect(mocks.axiosPost).toHaveBeenCalledTimes(1);
expect(mocks.openaiCreate).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import {
buildModerationTextEvidence,
detectIndonesianBadwords,
@@ -6,6 +6,26 @@ import {
normalizeDiscordCustomEmoji,
normalizeIndonesianSlang,
} from "../../src/moderation/indonesianTextNormalizer";
import { config } from "../../src/config";
const originalNemotronKey = config.NVIDIA_NEMOTRON_API_KEY;
const originalPrimaryAiKey = config.AI_LLM_API_KEY;
function disableRemoteModeration(): void {
config.NVIDIA_NEMOTRON_API_KEY = undefined;
config.AI_LLM_API_KEY = undefined;
}
disableRemoteModeration();
afterEach(() => {
disableRemoteModeration();
});
afterAll(() => {
config.NVIDIA_NEMOTRON_API_KEY = originalNemotronKey;
config.AI_LLM_API_KEY = originalPrimaryAiKey;
});
describe("normalizeDiscordCustomEmoji", () => {
it("replaces static custom emoji", () => {