diff --git a/services/backend/src/modules/dashboard/dashboard.repository.ts b/services/backend/src/modules/dashboard/dashboard.repository.ts index d0404e0..78bbbb7 100644 --- a/services/backend/src/modules/dashboard/dashboard.repository.ts +++ b/services/backend/src/modules/dashboard/dashboard.repository.ts @@ -82,6 +82,52 @@ export class DashboardRepository { }; } + async getActivity(days: number) { + const db = getDatabase(); + const sinceMs = Date.now() - days * 86400000; + const dayAgoMs = Date.now() - 86400000; + + // Daily buckets (last N days) + const daily = await db.execute(sql` + SELECT + to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day, + COUNT(*)::int AS messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged, + COUNT(DISTINCT user_id)::int AS active_users + FROM ${pgMessagesTable} + WHERE created_at >= ${sinceMs} + GROUP BY day + ORDER BY day + `); + + // Hourly distribution (last 24h) + const hourly = await db.execute(sql` + SELECT + EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour, + COUNT(*)::int AS messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged + FROM ${pgMessagesTable} + WHERE created_at >= ${dayAgoMs} + GROUP BY hour + ORDER BY hour + `); + + return { + days, + daily: (daily.rows as Record[]).map((r) => ({ + day: String(r.day), + messages: Number(r.messages), + flagged: Number(r.flagged), + active_users: Number(r.active_users), + })), + hourly: (hourly.rows as Record[]).map((r) => ({ + hour: Number(r.hour), + messages: Number(r.messages), + flagged: Number(r.flagged), + })), + }; + } + async listUsers(query: ListUsersQuery) { const db = getDatabase(); const limit = query.limit ?? 20; diff --git a/services/backend/src/modules/dashboard/dashboard.routes.ts b/services/backend/src/modules/dashboard/dashboard.routes.ts index 085b5fe..ecd5b47 100644 --- a/services/backend/src/modules/dashboard/dashboard.routes.ts +++ b/services/backend/src/modules/dashboard/dashboard.routes.ts @@ -19,6 +19,16 @@ export function createDashboardRouter(): Router { }), ); + // GET /api/dashboard/activity?days=14 — message volume over time + router.get( + "/dashboard/activity", + asyncHandler(async (req: Request, res: Response) => { + const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90); + const activity = await dashboardService.getActivity(days); + res.json(activity); + }), + ); + // GET /api/dashboard/users — paginated user list with profiles router.get( "/dashboard/users", diff --git a/services/backend/src/modules/dashboard/dashboard.service.ts b/services/backend/src/modules/dashboard/dashboard.service.ts index ea39a29..79dc03d 100644 --- a/services/backend/src/modules/dashboard/dashboard.service.ts +++ b/services/backend/src/modules/dashboard/dashboard.service.ts @@ -15,6 +15,11 @@ export class DashboardService { return dashboardRepository.getStats(); } + async getActivity(days: number) { + logger.debug({ days }, "Fetching dashboard activity"); + return dashboardRepository.getActivity(days); + } + async listUsers(query: ListUsersQuery) { logger.debug({ query }, "Listing dashboard users"); return dashboardRepository.listUsers(query); diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index 91224ea..f68218c 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -9,19 +9,34 @@ import { Users, } from "lucide-react"; import { useState } from "react"; +import { ActivityChart } from "@/components/dashboard/activity-chart"; import { ChannelsSection } from "@/components/dashboard/channels-section"; +import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart"; +import { ModerationDonut } from "@/components/dashboard/moderation-donut"; import { StatCard } from "@/components/dashboard/stat-card"; import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; import { UsersSection } from "@/components/dashboard/users-section"; import { SubNav } from "@/components/layout/sub-nav"; import { ErrorState, LoadingSkeleton } from "@/components/shared"; -import { useStats } from "@/hooks"; +import { useActivity, useStats } from "@/hooks"; +import { cn } from "@/lib/utils"; type DashboardTab = "stats" | "users" | "channels"; +const DAY_RANGES = [7, 14, 30] as const; + +const MODERATION_COLORS: Record = { + Clean: "oklch(0.72 0.16 155)", + Flagged: "oklch(0.62 0.19 25)", + Warned: "oklch(0.78 0.15 80)", + Error: "oklch(0.55 0.02 245)", +}; + export default function DashboardPage() { const [tab, setTab] = useState("stats"); + const [days, setDays] = useState(14); const { data: stats, isLoading, error, mutate: refetch } = useStats(); + const { data: activity, isLoading: activityLoading } = useActivity(days); const subNavTabs = [ { id: "stats", label: "Stats", icon: }, @@ -29,6 +44,31 @@ export default function DashboardPage() { { id: "channels", label: "Channels", icon: }, ]; + const moderationData = stats + ? [ + { + name: "Clean", + value: stats.total_clean, + color: MODERATION_COLORS.Clean, + }, + { + name: "Flagged", + value: stats.total_flagged, + color: MODERATION_COLORS.Flagged, + }, + { + name: "Warned", + value: stats.total_warned, + color: MODERATION_COLORS.Warned, + }, + { + name: "Error", + value: stats.total_error, + color: MODERATION_COLORS.Error, + }, + ].filter((d) => d.value > 0) + : []; + return (
- ({ - name: c.channel_name ?? c.channel_id, - count: c.message_count, - }))} - /> +
+ {DAY_RANGES.map((range) => ( + + ))} +
+ +
+
+ {activityLoading ? ( + + ) : ( + + )} +
+ +
+ +
+
+ {activityLoading ? ( + + ) : ( + + )} +
+ ({ + name: c.channel_name ?? c.channel_id, + count: c.message_count, + }))} + /> +
)} diff --git a/services/frontend/src/components/dashboard/activity-chart.tsx b/services/frontend/src/components/dashboard/activity-chart.tsx new file mode 100644 index 0000000..dc43cc5 --- /dev/null +++ b/services/frontend/src/components/dashboard/activity-chart.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { GlassCard } from "@/components/glass/card"; +import { useMounted } from "@/lib/hooks/use-mounted"; + +interface ActivityChartProps { + data?: { day: string; messages: number; flagged: number }[]; +} + +const TOOLTIP_STYLE = { + background: "oklch(0.11 0.02 245 / 0.95)", + border: "1px solid oklch(1 0 0 / 0.08)", + borderRadius: 8, + fontSize: 12, + color: "oklch(0.93 0.01 245)", +} as const; + +export function ActivityChart({ data = [] }: ActivityChartProps) { + const mounted = useMounted(); + + return ( + +
+ + Message Activity + + + messages · flagged per day + +
+
+ {mounted ? ( + + + + + + + + + + + + + + { + const [, m, d] = v.split("-"); + return `${Number(m)}/${Number(d)}`; + }} + minTickGap={24} + /> + + { + const [y, m, d] = String(label).split("-"); + return `${d}/${m}/${y}`; + }} + /> + + + + + ) : ( +
+ )} +
+ + ); +} diff --git a/services/frontend/src/components/dashboard/hourly-activity-chart.tsx b/services/frontend/src/components/dashboard/hourly-activity-chart.tsx new file mode 100644 index 0000000..cf64013 --- /dev/null +++ b/services/frontend/src/components/dashboard/hourly-activity-chart.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + Bar, + BarChart, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { GlassCard } from "@/components/glass/card"; +import { useMounted } from "@/lib/hooks/use-mounted"; + +interface HourlyActivityChartProps { + data?: { hour: number; messages: number; flagged: number }[]; +} + +const HOUR_LABELS = Array.from({ length: 24 }, (_, i) => { + const h = i % 12 === 0 ? 12 : i % 12; + return `${h}${i < 12 ? "am" : "pm"}`; +}); + +const TOOLTIP_STYLE = { + background: "oklch(0.11 0.02 245 / 0.95)", + border: "1px solid oklch(1 0 0 / 0.08)", + borderRadius: 8, + fontSize: 12, + color: "oklch(0.93 0.01 245)", +} as const; + +export function HourlyActivityChart({ data = [] }: HourlyActivityChartProps) { + const mounted = useMounted(); + + const full = Array.from({ length: 24 }, (_, hour) => { + const found = data.find((d) => d.hour === hour); + return { + hour, + label: HOUR_LABELS[hour], + messages: found?.messages ?? 0, + flagged: found?.flagged ?? 0, + }; + }); + + const peak = Math.max(1, ...full.map((d) => d.messages)); + const maxMessages = Math.max(...full.map((d) => d.messages)); + + return ( + +
+ + Hourly Activity + + + last 24h · peak {maxMessages} msgs + +
+
+ {mounted ? ( + + + + Math.max(1, dataMax)]} + /> + `Hour ${label}`} + /> + + {full.map((d) => ( + 0 + ? "var(--color-primary)" + : d.messages > peak * 0.5 + ? "oklch(0.52 0.13 245 / 0.7)" + : "oklch(0.52 0.13 245 / 0.35)" + } + /> + ))} + + + + ) : ( +
+ )} +
+ + ); +} diff --git a/services/frontend/src/components/dashboard/moderation-donut.tsx b/services/frontend/src/components/dashboard/moderation-donut.tsx new file mode 100644 index 0000000..eb829bd --- /dev/null +++ b/services/frontend/src/components/dashboard/moderation-donut.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; +import { GlassCard } from "@/components/glass/card"; +import { useMounted } from "@/lib/hooks/use-mounted"; + +interface ModerationDonutProps { + data?: { name: string; value: number; color: string }[]; +} + +const TOOLTIP_STYLE = { + background: "oklch(0.11 0.02 245 / 0.95)", + border: "1px solid oklch(1 0 0 / 0.08)", + borderRadius: 8, + fontSize: 12, + color: "oklch(0.93 0.01 245)", +} as const; + +export function ModerationDonut({ data = [] }: ModerationDonutProps) { + const mounted = useMounted(); + const total = data.reduce((sum, d) => sum + d.value, 0); + const cleanPct = + total > 0 + ? Math.round( + ((data.find((d) => d.name === "Clean")?.value ?? 0) / total) * 100, + ) + : 0; + + return ( + +
+ + Moderation Breakdown + +
+
+
+ {mounted ? ( + + + + {data.map((entry) => ( + + ))} + + + + + ) : ( +
+ )} +
+ + {total.toLocaleString()} + + + messages + +
+
+ +
+ {data.map((d) => ( +
+ + {d.name} + + {d.value.toLocaleString()} + + + {total > 0 ? Math.round((d.value / total) * 100) : 0}% + +
+ ))} + {cleanPct >= 90 && ( +

+ ✓ Server is {cleanPct}% clean — moderation is holding up well +

+ )} + {cleanPct < 90 && cleanPct > 0 && ( +

+ {100 - cleanPct}% of messages were flagged or warned — review + activity in the Analysis tab +

+ )} +
+
+ + ); +} diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 6303094..2d98823 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -1,5 +1,6 @@ export { useConfig } from "./use-config"; export { + useActivity, useChannelDetail, useChannels, useStats, diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index a1715c9..bdcfd56 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -2,6 +2,7 @@ import useSWR from "swr"; import { dashboardApi } from "@/lib/api"; import type { + DashboardActivity, DashboardChannelDetail, DashboardStats, DashboardUserDetail, @@ -13,6 +14,12 @@ export function useStats() { ); } +export function useActivity(days = 14) { + return useSWR(["dashboard-activity", days], () => + dashboardApi.getActivity(days), + ); +} + export function useUsers(search?: string) { return useSWR( ["dashboard-users", search ?? ""], diff --git a/services/frontend/src/lib/api/dashboard.ts b/services/frontend/src/lib/api/dashboard.ts index 4ce5186..617c6ac 100644 --- a/services/frontend/src/lib/api/dashboard.ts +++ b/services/frontend/src/lib/api/dashboard.ts @@ -1,4 +1,5 @@ import type { + DashboardActivity, DashboardChannelDetail, DashboardStats, DashboardUserDetail, @@ -10,6 +11,9 @@ import { api } from "./client"; export const dashboardApi = { getStats: () => api.get("/api/dashboard/stats"), + getActivity: (days = 14) => + api.get(`/api/dashboard/activity?days=${days}`), + listUsers: (limit?: number, cursor?: string, search?: string) => { const params = new URLSearchParams(); if (limit) params.set("limit", String(limit)); diff --git a/services/frontend/src/lib/types/dashboard.ts b/services/frontend/src/lib/types/dashboard.ts index d2c9b75..741bfdd 100644 --- a/services/frontend/src/lib/types/dashboard.ts +++ b/services/frontend/src/lib/types/dashboard.ts @@ -28,6 +28,25 @@ export interface ModerationOverview { error: number; } +export interface DashboardActivity { + days: number; + daily: DailyActivityPoint[]; + hourly: HourlyActivityPoint[]; +} + +export interface DailyActivityPoint { + day: string; // YYYY-MM-DD + messages: number; + flagged: number; + active_users: number; +} + +export interface HourlyActivityPoint { + hour: number; // 0-23 + messages: number; + flagged: number; +} + export interface DashboardUser { user_id: string; username?: string | null;