refactor(analytics): replace ActivityChart stacked bars with interactive SVG chart featuring tooltips and grid lines

This commit is contained in:
MythEclipse
2026-06-04 14:14:49 +07:00
parent 259b0babb5
commit 00001973d8
@@ -1,3 +1,4 @@
import { useState } from "react";
import type { HourlyBucket } from "../../../shared/api/client";
import {
Card,
@@ -6,20 +7,31 @@ import {
CardHeader,
CardTitle,
} from "../../../shared/ui";
import { cn } from "../../../shared/lib/utils";
interface ActivityChartProps {
hourly: HourlyBucket[];
loading: boolean;
}
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
if (loading && !hourly?.length) {
return <LoadingBox />;
}
const COLORS = {
clean: { fill: "#38bdf8", label: "Clean" },
warned: { fill: "#facc15", label: "Warned" },
flagged: { fill: "#f472b6", label: "Flagged" },
error: { fill: "#fb923c", label: "Error" },
} as const;
if (!hourly?.length) {
return <EmptyBox text="Belum ada data untuk periode ini." />;
}
type BarKey = keyof typeof COLORS;
export function ActivityChart({ hourly, loading }: ActivityChartProps) {
const [tooltip, setTooltip] = useState<{
hour: string;
total: number;
} | null>(null);
const [hoveredBar, setHoveredBar] = useState<string | null>(null);
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);
@@ -34,63 +46,189 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) {
};
});
const maxTotal = Math.max(...data.map((d) => d.total), 1);
// Only show every Nth label to avoid crowding
const labelInterval = data.length > 16 ? 2 : 1;
const bars: Array<{ key: BarKey; color: string; label: string }> = [
{ key: "clean", color: COLORS.clean.fill, label: COLORS.clean.label },
{ key: "warned", color: COLORS.warned.fill, label: COLORS.warned.label },
{ key: "flagged", color: COLORS.flagged.fill, label: COLORS.flagged.label },
{ key: "error", color: COLORS.error.fill, label: COLORS.error.label },
];
const CHART_HEIGHT = 200;
const BAR_GROUP_WIDTH = 28;
const BAR_WIDTH = 5;
const GAP = 2;
return (
<Card className="col-span-1 lg: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>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm font-semibold">
Aktivitas per Jam
</CardTitle>
<CardDescription className="text-xs">
Distribusi pesan per jam arahkan kursor ke bar untuk detail.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>Clean</span>
<span>Warned</span>
<span>Flagged</span>
<span>Error</span>
</div>
<div className="max-h-55 space-y-2 overflow-auto pr-1">
{data.map((bucket) => {
const total = Math.max(bucket.total, 1);
const clean = bucket.clean / total;
const warned = bucket.warned / total;
const flagged = bucket.flagged / total;
const error = bucket.error / total;
return (
<div
key={bucket.hour}
className="grid gap-1 rounded-xl border border-sky-100 bg-white p-3"
>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span className="font-medium text-foreground">
{bucket.hour}
</span>
<span>{bucket.total} pesan</span>
</div>
<div className="flex h-3 overflow-hidden rounded-full bg-sky-50">
<div
className="bg-gradient-to-r from-primary to-teal-300"
style={{ width: `${clean * 100}%` }}
{/* Legend */}
<div className="mb-3 flex flex-wrap gap-4 text-[11px]">
{bars.map((b) => (
<span key={b.key} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: b.color }}
/>
{b.label}
</span>
))}
</div>
{/* Chart area */}
<div className="relative overflow-x-auto">
<div className="min-w-[560px]">
<svg
viewBox={`0 0 ${Math.max(data.length * BAR_GROUP_WIDTH + 40, 200)} ${CHART_HEIGHT + 40}`}
className="w-full"
style={{ height: CHART_HEIGHT + 40 }}
>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const y = CHART_HEIGHT - ratio * (CHART_HEIGHT - 20) - 20;
return (
<g key={ratio}>
<line
x1={30}
y1={y}
x2={data.length * BAR_GROUP_WIDTH + 10}
y2={y}
stroke="hsl(var(--muted))"
strokeWidth={1}
strokeDasharray="3 3"
/>
<div
className="bg-yellow-400/60"
style={{ width: `${warned * 100}%` }}
<text
x={28}
y={y + 3}
textAnchor="end"
className="fill-muted-foreground"
fontSize={9}
>
{Math.round(ratio * maxTotal)}
</text>
</g>
);
})}
{/* Bars */}
{data.map((d, i) => {
const x = i * BAR_GROUP_WIDTH + 32;
let accumulated = 0;
return (
<g key={d.hour}>
{/* Hover target (invisible wider rect) */}
<rect
x={x - 4}
y={0}
width={BAR_GROUP_WIDTH}
height={CHART_HEIGHT}
fill="transparent"
className="cursor-crosshair"
onMouseEnter={() => {
setTooltip({ hour: d.hour, total: d.total });
setHoveredBar(d.hour);
}}
onMouseLeave={() => {
setTooltip(null);
setHoveredBar(null);
}}
/>
<div
className="bg-accent/70"
style={{ width: `${flagged * 100}%` }}
/>
<div
className="bg-orange-300/70"
style={{ width: `${error * 100}%` }}
/>
</div>
{/* Stacked bars */}
{bars.map((bar) => {
const val = d[bar.key];
const barH = (val / maxTotal) * (CHART_HEIGHT - 20);
const y = CHART_HEIGHT - accumulated - barH - 20;
accumulated += barH;
return val > 0 ? (
<rect
key={bar.key}
x={x + GAP}
y={y}
width={BAR_WIDTH}
height={Math.max(barH, 1)}
fill={bar.color}
rx={1.5}
className={cn(
"transition-opacity",
hoveredBar === d.hour
? "opacity-100"
: hoveredBar
? "opacity-40"
: "opacity-90",
)}
/>
) : null;
})}
{/* X-axis label */}
{i % labelInterval === 0 && (
<text
x={x + BAR_WIDTH / 2 + GAP}
y={CHART_HEIGHT - 2}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={9}
>
{d.hour}
</text>
)}
</g>
);
})}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className="pointer-events-none absolute top-0 z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
style={{
left: `${data.findIndex((d) => d.hour === tooltip.hour) * BAR_GROUP_WIDTH + 36}px`,
}}
>
<div className="mb-1 font-semibold text-foreground">
{tooltip.hour}
</div>
);
})}
{bars.map((b) => {
const d = data.find((d) => d.hour === tooltip.hour);
const val = d?.[b.key] ?? 0;
return val > 0 ? (
<div key={b.key} className="flex items-center gap-2 text-muted-foreground">
<span
className="inline-block h-2 w-2 rounded-sm"
style={{ backgroundColor: b.color }}
/>
<span>{b.label}</span>
<span className="ml-auto font-medium tabular-nums text-foreground">
{val}
</span>
</div>
) : null;
})}
<div className="mt-1 flex items-center gap-2 border-t border-muted pt-1 text-muted-foreground">
<span>Total</span>
<span className="ml-auto font-bold tabular-nums text-foreground">
{tooltip.total}
</span>
</div>
</div>
)}
</div>
</div>
</CardContent>
@@ -101,7 +239,7 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) {
function LoadingBox() {
return (
<Card className="col-span-1 flex h-65 items-center justify-center text-sm text-muted-foreground lg:col-span-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="h-4 w-4 animate-spin rounded-sm border-2 border-primary border-t-transparent" />
<span className="ml-2">Memuat data...</span>
</Card>
);