feat(dashboard): message activity timeline + moderation donut
Backend: - GET /api/dashboard/activity?days=1..90 — daily buckets (messages, flagged, active_users) + hourly distribution last 24h - clamped days param, reuses existing indexes (idx_messages_created, ai_status_created) Frontend: - ActivityChart: area chart messages+flagged per day, 7/14/30d range - HourlyActivityChart: 24h bars with peak highlight - ModerationDonut: clean/flagged/warned/error breakdown with live summary line (server X% clean) - Dashboard layout: activity 2/3 + donut 1/3, hourly + top channels Verified: endpoint returns real data from prod DB (784/1897/8 msgs per day), tsc clean backend+frontend.
This commit is contained in:
@@ -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 (
|
||||
<GlassCard variant="base">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||
Message Activity
|
||||
</span>
|
||||
<span className="text-[10px] text-text-secondary/50">
|
||||
messages · flagged per day
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-56">
|
||||
{mounted ? (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
>
|
||||
<AreaChart data={data} margin={{ left: -18, right: 4, top: 4 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradMessages" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--color-primary)"
|
||||
stopOpacity={0.45}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--color-primary)"
|
||||
stopOpacity={0.02}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradFlagged" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="oklch(0.62 0.19 25)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="oklch(0.62 0.19 25)"
|
||||
stopOpacity={0.02}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="oklch(1 0 0 / 0.05)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||
tickFormatter={(v: string) => {
|
||||
const [, m, d] = v.split("-");
|
||||
return `${Number(m)}/${Number(d)}`;
|
||||
}}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
labelFormatter={(label) => {
|
||||
const [y, m, d] = String(label).split("-");
|
||||
return `${d}/${m}/${y}`;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="messages"
|
||||
stroke="var(--color-primary)"
|
||||
strokeWidth={2}
|
||||
fill="url(#gradMessages)"
|
||||
name="Messages"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="flagged"
|
||||
stroke="oklch(0.62 0.19 25)"
|
||||
strokeWidth={2}
|
||||
fill="url(#gradFlagged)"
|
||||
name="Flagged"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<GlassCard variant="base">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||
Hourly Activity
|
||||
</span>
|
||||
<span className="text-[10px] text-text-secondary/50">
|
||||
last 24h · peak {maxMessages} msgs
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-40">
|
||||
{mounted ? (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
>
|
||||
<BarChart data={full} margin={{ left: -22, right: 4, top: 4 }}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 9 }}
|
||||
interval={3}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||
allowDecimals={false}
|
||||
domain={[0, (dataMax: number) => Math.max(1, dataMax)]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
cursor={{ fill: "oklch(1 0 0 / 0.04)" }}
|
||||
labelFormatter={(label) => `Hour ${label}`}
|
||||
/>
|
||||
<Bar dataKey="messages" radius={[3, 3, 0, 0]} name="Messages">
|
||||
{full.map((d) => (
|
||||
<Cell
|
||||
key={d.hour}
|
||||
fill={
|
||||
d.messages === maxMessages && maxMessages > 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)"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<GlassCard variant="base">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||
Moderation Breakdown
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-40 w-40 shrink-0">
|
||||
{mounted ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={52}
|
||||
outerRadius={72}
|
||||
paddingAngle={2}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full animate-pulse rounded-full bg-card/40" />
|
||||
)}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-2xl font-bold text-text-primary">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wide text-text-secondary/60">
|
||||
messages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{data.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-sm"
|
||||
style={{ background: d.color }}
|
||||
/>
|
||||
<span className="text-text-secondary">{d.name}</span>
|
||||
<span className="ml-auto font-mono text-text-primary">
|
||||
{d.value.toLocaleString()}
|
||||
</span>
|
||||
<span className="w-10 text-right font-mono text-text-secondary/50">
|
||||
{total > 0 ? Math.round((d.value / total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{cleanPct >= 90 && (
|
||||
<p className="pt-1 text-[10px] text-green-500/80">
|
||||
✓ Server is {cleanPct}% clean — moderation is holding up well
|
||||
</p>
|
||||
)}
|
||||
{cleanPct < 90 && cleanPct > 0 && (
|
||||
<p className="pt-1 text-[10px] text-amber-500/80">
|
||||
{100 - cleanPct}% of messages were flagged or warned — review
|
||||
activity in the Analysis tab
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user