feat(analytics): refactor ActivityChart and TrendChart to use custom rendering and improve data visualization

This commit is contained in:
MythEclipse
2026-05-31 01:00:08 +07:00
parent 38ae6b03dd
commit 39e4e0ddc5
2 changed files with 83 additions and 56 deletions
@@ -1,4 +1,3 @@
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
import type { HourlyBucket } from "../../api/analytics"; import type { HourlyBucket } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
@@ -36,44 +35,46 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) {
<CardDescription className="text-xs">Distribusi pesan per jam berdasarkan status moderasi.</CardDescription> <CardDescription className="text-xs">Distribusi pesan per jam berdasarkan status moderasi.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ResponsiveContainer width="100%" height={200}> <div className="space-y-3">
<AreaChart data={data} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}> <div className="grid grid-cols-4 gap-2 text-[10px] uppercase tracking-wider text-muted-foreground">
<XAxis dataKey="hour" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} /> <span>Clean</span>
<YAxis tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={false} width={28} /> <span>Warned</span>
<Tooltip <span>Flagged</span>
contentStyle={{ <span>Error</span>
backgroundColor: "#1e293b", </div>
border: "1px solid #334155", <div className="max-h-55 space-y-2 overflow-auto pr-1">
borderRadius: "6px", {data.map((bucket) => {
fontSize: "11px", const total = Math.max(bucket.total, 1);
color: "#e2e8f0", const clean = bucket.clean / total;
}} const warned = bucket.warned / total;
formatter={(value: unknown, name: unknown) => { const flagged = bucket.flagged / total;
const v = typeof value === "number" ? value : String(value); const error = bucket.error / total;
return [v, label(String(name))]; return (
}} <div key={bucket.hour} className="grid gap-1 rounded-xl border border-border bg-background/50 p-3">
/> <div className="flex items-center justify-between text-[11px] text-muted-foreground">
<Legend wrapperStyle={{ fontSize: "11px" }} /> <span className="font-medium text-foreground">{bucket.hour}</span>
<Area type="monotone" dataKey="clean" stackId="1" stroke="#10b981" fill="#10b981" fillOpacity={0.6} name="clean" /> <span>{bucket.total} pesan</span>
<Area type="monotone" dataKey="warned" stackId="1" stroke="#f59e0b" fill="#f59e0b" fillOpacity={0.6} name="warned" /> </div>
<Area type="monotone" dataKey="flagged" stackId="1" stroke="#ef4444" fill="#ef4444" fillOpacity={0.6} name="flagged" /> <div className="flex h-3 overflow-hidden rounded-full bg-muted">
<Area type="monotone" dataKey="error" stackId="1" stroke="#f97316" fill="#f97316" fillOpacity={0.4} name="error" /> <div className="bg-emerald-500/80" style={{ width: `${clean * 100}%` }} />
</AreaChart> <div className="bg-amber-500/80" style={{ width: `${warned * 100}%` }} />
</ResponsiveContainer> <div className="bg-red-500/80" style={{ width: `${flagged * 100}%` }} />
<div className="bg-orange-500/80" style={{ width: `${error * 100}%` }} />
</div>
</div>
);
})}
</div>
</div>
</CardContent> </CardContent>
</Card> </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() { function LoadingBox() {
return ( return (
<Card className="col-span-2"> <Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground"> <CardContent className="flex h-65 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="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span> <span className="ml-2">Memuat data...</span>
</CardContent> </CardContent>
@@ -84,7 +85,7 @@ function LoadingBox() {
function EmptyBox({ text }: { text: string }) { function EmptyBox({ text }: { text: string }) {
return ( return (
<Card className="col-span-2"> <Card className="col-span-2">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground"> <CardContent className="flex h-65 items-center justify-center text-sm text-muted-foreground">
{text} {text}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,4 +1,3 @@
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts";
import type { TrendBucket } from "../../api/analytics"; import type { TrendBucket } from "../../api/analytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
@@ -32,39 +31,66 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
<CardDescription className="text-xs">Volume pesan per hari dengan status moderasi.</CardDescription> <CardDescription className="text-xs">Volume pesan per hari dengan status moderasi.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ResponsiveContainer width="100%" height={200}> <div className="space-y-4">
<LineChart data={data} margin={{ top: 4, right: 4, left: -16, bottom: 0 }}> <div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground">
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={{ stroke: "#334155" }} tickFormatter={(d: string) => d.slice(5)} /> <span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-blue-500" /> Total</span>
<YAxis tick={{ fontSize: 10, fill: "#64748b" }} tickLine={false} axisLine={false} width={28} /> <span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-emerald-500" /> Clean</span>
<Tooltip <span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-amber-500" /> Warned</span>
contentStyle={{ backgroundColor: "#1e293b", border: "1px solid #334155", borderRadius: "6px", fontSize: "11px", color: "#e2e8f0" }} <span className="flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-red-500" /> Flagged</span>
formatter={(value: unknown, name: unknown) => { </div>
const v = typeof value === "number" ? value : String(value); <div className="rounded-2xl border border-border bg-background/50 p-4">
return [v, label(String(name))]; <svg viewBox={`0 0 ${Math.max(data.length - 1, 1)} 100`} className="h-55 w-full overflow-visible">
}} <g stroke="#334155" strokeWidth="0.5" opacity="0.6">
labelFormatter={(l: unknown) => String(l)} {Array.from({ length: 5 }, (_, index) => {
/> const y = (index / 4) * 100;
<Legend wrapperStyle={{ fontSize: "11px" }} /> return <line key={index} x1="0" x2={Math.max(data.length - 1, 1)} y1={y} y2={y} />;
<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" /> </g>
<Line type="monotone" dataKey="warned" stroke="#f59e0b" strokeWidth={1.5} dot={false} name="Warned" /> <TrendPath data={data} color="#3b82f6" strokeWidth={2} keyName="total" />
<Line type="monotone" dataKey="flagged" stroke="#ef4444" strokeWidth={1.5} dot={false} name="Flagged" /> <TrendPath data={data} color="#10b981" strokeWidth={1.5} keyName="clean" />
</LineChart> <TrendPath data={data} color="#f59e0b" strokeWidth={1.5} keyName="warned" />
</ResponsiveContainer> <TrendPath data={data} color="#ef4444" strokeWidth={1.5} keyName="flagged" />
</svg>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-muted-foreground sm:grid-cols-4">
{data.map((item) => (
<div key={item.date} className="truncate rounded-lg bg-muted/30 px-2 py-1 text-center">
{item.date.slice(5)}
</div>
))}
</div>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function label(key: string): string { function TrendPath({
const map: Record<string, string> = { total: "Total", clean: "Clean", warned: "Warned", flagged: "Flagged" }; data,
return map[key] ?? key; color,
strokeWidth,
keyName,
}: {
data: Array<Record<string, number | string>>;
color: string;
strokeWidth: number;
keyName: string;
}) {
const values = data.map((item) => Number(item[keyName] ?? 0));
const maxValue = Math.max(...values, 1);
const points = values.map((value, index) => {
const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * 100;
const y = 100 - (value / maxValue) * 90 - 5;
return `${x},${y}`;
});
return <polyline fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round" points={points.join(" ")} />;
} }
function LoadingBox() { function LoadingBox() {
return ( return (
<Card className="col-span-3"> <Card className="col-span-3">
<CardContent className="flex h-[260px] items-center justify-center text-sm text-muted-foreground"> <CardContent className="flex h-65 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="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span> <span className="ml-2">Memuat data...</span>
</CardContent> </CardContent>