Skip to content

Commit 3e1090a

Browse files
authored
Merge pull request #250 from mchestr/feature/discord-detailed-analytics
feat: add detailed Discord analytics to admin dashboard
2 parents 93679e8 + a1c4ef2 commit 3e1090a

File tree

9 files changed

+1586
-2
lines changed

9 files changed

+1586
-2
lines changed

actions/discord-activity.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import {
77
getDailyActivity,
88
getActiveUsers,
99
getSummaryStats,
10+
getHelpCommandStats,
11+
getAccountLinkingMetrics,
12+
getMediaMarkingBreakdown,
13+
getContextMetrics,
14+
getErrorAnalysis,
15+
getSelectionMenuStats,
1016
type GetCommandLogsParams,
1117
} from "@/lib/discord/audit"
1218
import { prisma } from "@/lib/prisma"
@@ -244,3 +250,158 @@ export async function getDiscordDashboardData(params: GetStatsParams) {
244250
}
245251
}
246252
}
253+
254+
export async function getDiscordHelpStats(params: GetStatsParams) {
255+
await requireAdmin()
256+
257+
try {
258+
const stats = await getHelpCommandStats(
259+
new Date(params.startDate),
260+
toEndOfDayExclusive(params.endDate)!
261+
)
262+
263+
return { success: true, stats }
264+
} catch (error) {
265+
logger.error("Failed to get Discord help stats", error instanceof Error ? error : undefined)
266+
return {
267+
success: false,
268+
error: error instanceof Error ? error.message : "Failed to get help stats",
269+
stats: null,
270+
}
271+
}
272+
}
273+
274+
export async function getDiscordLinkingMetrics(params: GetStatsParams) {
275+
await requireAdmin()
276+
277+
try {
278+
const metrics = await getAccountLinkingMetrics(
279+
new Date(params.startDate),
280+
toEndOfDayExclusive(params.endDate)!
281+
)
282+
283+
return { success: true, metrics }
284+
} catch (error) {
285+
logger.error("Failed to get Discord linking metrics", error instanceof Error ? error : undefined)
286+
return {
287+
success: false,
288+
error: error instanceof Error ? error.message : "Failed to get linking metrics",
289+
metrics: null,
290+
}
291+
}
292+
}
293+
294+
export async function getDiscordMediaMarkingBreakdown(params: GetStatsParams) {
295+
await requireAdmin()
296+
297+
try {
298+
const breakdown = await getMediaMarkingBreakdown(
299+
new Date(params.startDate),
300+
toEndOfDayExclusive(params.endDate)!
301+
)
302+
303+
return { success: true, breakdown }
304+
} catch (error) {
305+
logger.error("Failed to get Discord media marking breakdown", error instanceof Error ? error : undefined)
306+
return {
307+
success: false,
308+
error: error instanceof Error ? error.message : "Failed to get media marking breakdown",
309+
breakdown: null,
310+
}
311+
}
312+
}
313+
314+
export async function getDiscordContextMetrics(params: GetStatsParams) {
315+
await requireAdmin()
316+
317+
try {
318+
const metrics = await getContextMetrics(
319+
new Date(params.startDate),
320+
toEndOfDayExclusive(params.endDate)!
321+
)
322+
323+
return { success: true, metrics }
324+
} catch (error) {
325+
logger.error("Failed to get Discord context metrics", error instanceof Error ? error : undefined)
326+
return {
327+
success: false,
328+
error: error instanceof Error ? error.message : "Failed to get context metrics",
329+
metrics: null,
330+
}
331+
}
332+
}
333+
334+
export async function getDiscordErrorAnalysis(params: GetStatsParams) {
335+
await requireAdmin()
336+
337+
try {
338+
const analysis = await getErrorAnalysis(
339+
new Date(params.startDate),
340+
toEndOfDayExclusive(params.endDate)!
341+
)
342+
343+
return { success: true, analysis }
344+
} catch (error) {
345+
logger.error("Failed to get Discord error analysis", error instanceof Error ? error : undefined)
346+
return {
347+
success: false,
348+
error: error instanceof Error ? error.message : "Failed to get error analysis",
349+
analysis: null,
350+
}
351+
}
352+
}
353+
354+
export async function getDiscordSelectionStats(params: GetStatsParams) {
355+
await requireAdmin()
356+
357+
try {
358+
const stats = await getSelectionMenuStats(
359+
new Date(params.startDate),
360+
toEndOfDayExclusive(params.endDate)!
361+
)
362+
363+
return { success: true, stats }
364+
} catch (error) {
365+
logger.error("Failed to get Discord selection stats", error instanceof Error ? error : undefined)
366+
return {
367+
success: false,
368+
error: error instanceof Error ? error.message : "Failed to get selection stats",
369+
stats: null,
370+
}
371+
}
372+
}
373+
374+
export async function getDiscordDetailedStats(params: GetStatsParams) {
375+
await requireAdmin()
376+
377+
try {
378+
const [helpResult, linkingResult, mediaResult, contextResult, errorResult, selectionResult] =
379+
await Promise.all([
380+
getDiscordHelpStats(params),
381+
getDiscordLinkingMetrics(params),
382+
getDiscordMediaMarkingBreakdown(params),
383+
getDiscordContextMetrics(params),
384+
getDiscordErrorAnalysis(params),
385+
getDiscordSelectionStats(params),
386+
])
387+
388+
return {
389+
success: true,
390+
data: {
391+
helpStats: helpResult.success ? helpResult.stats : null,
392+
linkingMetrics: linkingResult.success ? linkingResult.metrics : null,
393+
mediaMarkingBreakdown: mediaResult.success ? mediaResult.breakdown : null,
394+
contextMetrics: contextResult.success ? contextResult.metrics : null,
395+
errorAnalysis: errorResult.success ? errorResult.analysis : null,
396+
selectionStats: selectionResult.success ? selectionResult.stats : null,
397+
},
398+
}
399+
} catch (error) {
400+
logger.error("Failed to get Discord detailed stats", error instanceof Error ? error : undefined)
401+
return {
402+
success: false,
403+
error: error instanceof Error ? error.message : "Failed to get detailed stats",
404+
data: null,
405+
}
406+
}
407+
}

app/admin/discord/page.tsx

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
1-
import { getDiscordDashboardData, getDiscordActivityLogs } from "@/actions/discord-activity"
1+
import {
2+
getDiscordDashboardData,
3+
getDiscordActivityLogs,
4+
getDiscordDetailedStats,
5+
} from "@/actions/discord-activity"
26
import { DiscordActivityTable } from "@/components/admin/discord/discord-activity-table"
37
import { DiscordBotStatus } from "@/components/admin/discord/discord-bot-status"
48
import { DiscordDateFilter } from "@/components/admin/discord/discord-date-filter"
59
import { DiscordCommandChart } from "@/components/admin/discord/discord-command-chart"
610
import { DiscordTrendChart } from "@/components/admin/discord/discord-trend-chart"
711
import { DiscordActiveUsers } from "@/components/admin/discord/discord-active-users"
12+
import { DiscordHelpStats } from "@/components/admin/discord/discord-help-stats"
13+
import { DiscordLinkingMetrics } from "@/components/admin/discord/discord-linking-metrics"
14+
import { DiscordMediaMarkingBreakdown } from "@/components/admin/discord/discord-media-marking-breakdown"
15+
import { DiscordContextMetrics } from "@/components/admin/discord/discord-context-metrics"
16+
import { DiscordErrorAnalysis } from "@/components/admin/discord/discord-error-analysis"
17+
import { DiscordSelectionStats } from "@/components/admin/discord/discord-selection-stats"
818
import { Suspense } from "react"
919

1020
export const dynamic = "force-dynamic"
@@ -25,14 +35,16 @@ export default async function DiscordDashboardPage({
2535
const startDate = params.startDate || defaultStartDate.toISOString().split("T")[0]
2636

2737
// Fetch dashboard data
28-
const [dashboardResult, logsResult] = await Promise.all([
38+
const [dashboardResult, logsResult, detailedResult] = await Promise.all([
2939
getDiscordDashboardData({ startDate, endDate }),
3040
getDiscordActivityLogs({ limit: 20 }),
41+
getDiscordDetailedStats({ startDate, endDate }),
3142
])
3243

3344
const dashboard = dashboardResult.success ? dashboardResult.data : null
3445
const logs = logsResult.success ? logsResult.logs : []
3546
const logsTotal = logsResult.success ? logsResult.total : 0
47+
const detailed = detailedResult.success ? detailedResult.data : null
3648

3749
// Format date range display
3850
const formatDateRange = () => {
@@ -217,6 +229,63 @@ export default async function DiscordDashboardPage({
217229
</div>
218230
</div>
219231

232+
{/* Detailed Analytics Section */}
233+
<div className="mb-6">
234+
<h2 className="text-xl font-semibold text-white mb-4">
235+
Detailed Analytics
236+
</h2>
237+
238+
{/* Help Stats and Linking Metrics */}
239+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
240+
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-lg p-6">
241+
<h3 className="text-lg font-semibold text-white mb-4">
242+
Help Command Usage
243+
</h3>
244+
<DiscordHelpStats data={detailed?.helpStats ?? null} />
245+
</div>
246+
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-lg p-6">
247+
<h3 className="text-lg font-semibold text-white mb-4">
248+
Account Linking
249+
</h3>
250+
<DiscordLinkingMetrics data={detailed?.linkingMetrics ?? null} />
251+
</div>
252+
</div>
253+
254+
{/* Media Marking Breakdown */}
255+
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-lg p-6 mb-6">
256+
<h3 className="text-lg font-semibold text-white mb-4">
257+
Media Marking Breakdown
258+
</h3>
259+
<DiscordMediaMarkingBreakdown
260+
data={detailed?.mediaMarkingBreakdown ?? null}
261+
/>
262+
</div>
263+
264+
{/* Context Metrics and Selection Stats */}
265+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
266+
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-lg p-6">
267+
<h3 className="text-lg font-semibold text-white mb-4">
268+
Context Management
269+
</h3>
270+
<DiscordContextMetrics data={detailed?.contextMetrics ?? null} />
271+
</div>
272+
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-lg p-6">
273+
<h3 className="text-lg font-semibold text-white mb-4">
274+
Selection Menu
275+
</h3>
276+
<DiscordSelectionStats data={detailed?.selectionStats ?? null} />
277+
</div>
278+
</div>
279+
280+
{/* Error Analysis */}
281+
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-lg p-6 mb-6">
282+
<h3 className="text-lg font-semibold text-white mb-4">
283+
Error Analysis
284+
</h3>
285+
<DiscordErrorAnalysis data={detailed?.errorAnalysis ?? null} />
286+
</div>
287+
</div>
288+
220289
{/* Recent Activity */}
221290
<div className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-lg overflow-hidden">
222291
<div className="p-4 border-b border-slate-700 flex items-center justify-between">
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"use client"
2+
3+
import {
4+
ArcElement,
5+
Chart as ChartJS,
6+
ChartOptions,
7+
Legend,
8+
Tooltip,
9+
} from "chart.js"
10+
import { Pie } from "react-chartjs-2"
11+
12+
ChartJS.register(ArcElement, Tooltip, Legend)
13+
14+
interface ContextMetrics {
15+
totalClears: number
16+
clearsByCommand: { commandName: string; count: number }[]
17+
topClearUsers: {
18+
discordUserId: string
19+
discordUsername: string | null
20+
clearCount: number
21+
}[]
22+
}
23+
24+
interface DiscordContextMetricsProps {
25+
data: ContextMetrics | null
26+
}
27+
28+
const COLORS = ["#22d3ee", "#a855f7", "#22c55e", "#f59e0b"]
29+
30+
export function DiscordContextMetrics({ data }: DiscordContextMetricsProps) {
31+
if (!data || data.totalClears === 0) {
32+
return (
33+
<div className="flex items-center justify-center h-full text-slate-500 text-sm">
34+
No context clear data available
35+
</div>
36+
)
37+
}
38+
39+
const chartData = {
40+
labels: data.clearsByCommand.map((c) => c.commandName),
41+
datasets: [
42+
{
43+
data: data.clearsByCommand.map((c) => c.count),
44+
backgroundColor: COLORS.slice(0, data.clearsByCommand.length),
45+
borderColor: COLORS.slice(0, data.clearsByCommand.length),
46+
borderWidth: 2,
47+
},
48+
],
49+
}
50+
51+
const options: ChartOptions<"pie"> = {
52+
responsive: true,
53+
maintainAspectRatio: false,
54+
plugins: {
55+
legend: {
56+
display: true,
57+
position: "bottom" as const,
58+
labels: {
59+
color: "#94a3b8",
60+
font: { size: 11 },
61+
padding: 12,
62+
usePointStyle: true,
63+
pointStyle: "circle",
64+
},
65+
},
66+
tooltip: {
67+
backgroundColor: "#1e293b",
68+
titleColor: "#cbd5e1",
69+
bodyColor: "#e2e8f0",
70+
borderColor: "#475569",
71+
borderWidth: 1,
72+
padding: 12,
73+
cornerRadius: 6,
74+
},
75+
},
76+
}
77+
78+
return (
79+
<div className="space-y-4" data-testid="discord-context-metrics">
80+
<div className="bg-slate-700/30 rounded-lg p-3 text-center">
81+
<div className="text-2xl font-bold text-cyan-400">
82+
{data.totalClears}
83+
</div>
84+
<div className="text-xs text-slate-400">Total Context Clears</div>
85+
</div>
86+
87+
{data.clearsByCommand.length > 0 && (
88+
<div className="h-40">
89+
<Pie data={chartData} options={options} />
90+
</div>
91+
)}
92+
93+
{data.topClearUsers.length > 0 && (
94+
<div>
95+
<h4 className="text-sm font-medium text-slate-400 mb-2">
96+
Frequent Clearers
97+
</h4>
98+
<div className="space-y-2 max-h-32 overflow-y-auto">
99+
{data.topClearUsers.slice(0, 5).map((user) => (
100+
<div
101+
key={user.discordUserId}
102+
className="flex items-center justify-between bg-slate-700/20 rounded px-3 py-2"
103+
data-testid={`clear-user-${user.discordUserId}`}
104+
>
105+
<span className="text-sm text-slate-300 truncate">
106+
{user.discordUsername ?? user.discordUserId}
107+
</span>
108+
<span className="text-sm font-medium text-cyan-400">
109+
{user.clearCount}x
110+
</span>
111+
</div>
112+
))}
113+
</div>
114+
</div>
115+
)}
116+
</div>
117+
)
118+
}

0 commit comments

Comments
 (0)