From 00001973d855542c90b85618d848e1fe0b1dc89f Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 4 Jun 2026 14:14:49 +0700 Subject: [PATCH] refactor(analytics): replace ActivityChart stacked bars with interactive SVG chart featuring tooltips and grid lines --- .../analytics/components/ActivityChart.tsx | 252 ++++++++++++++---- 1 file changed, 195 insertions(+), 57 deletions(-) diff --git a/services/frontend/src/features/analytics/components/ActivityChart.tsx b/services/frontend/src/features/analytics/components/ActivityChart.tsx index 258b372..39ed19d 100644 --- a/services/frontend/src/features/analytics/components/ActivityChart.tsx +++ b/services/frontend/src/features/analytics/components/ActivityChart.tsx @@ -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 ; - } +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 ; - } +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(null); + + if (loading && !hourly?.length) return ; + if (!hourly?.length) return ; 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 ( - - Aktivitas per Jam - - - Distribusi pesan per jam berdasarkan status moderasi. - +
+
+ + Aktivitas per Jam + + + Distribusi pesan per jam — arahkan kursor ke bar untuk detail. + +
+
-
-
- Clean - Warned - Flagged - Error -
-
- {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 ( -
-
- - {bucket.hour} - - {bucket.total} pesan -
-
-
+ {bars.map((b) => ( + + + {b.label} + + ))} +
+ + {/* Chart area */} +
+
+ + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const y = CHART_HEIGHT - ratio * (CHART_HEIGHT - 20) - 20; + return ( + + -
+ {Math.round(ratio * maxTotal)} + + + ); + })} + + {/* Bars */} + {data.map((d, i) => { + const x = i * BAR_GROUP_WIDTH + 32; + let accumulated = 0; + + return ( + + {/* Hover target (invisible wider rect) */} + { + setTooltip({ hour: d.hour, total: d.total }); + setHoveredBar(d.hour); + }} + onMouseLeave={() => { + setTooltip(null); + setHoveredBar(null); + }} /> -
-
-
+ + {/* 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 ? ( + + ) : null; + })} + + {/* X-axis label */} + {i % labelInterval === 0 && ( + + {d.hour} + + )} + + ); + })} + + + {/* Tooltip */} + {tooltip && ( +
d.hour === tooltip.hour) * BAR_GROUP_WIDTH + 36}px`, + }} + > +
+ {tooltip.hour}
- ); - })} + {bars.map((b) => { + const d = data.find((d) => d.hour === tooltip.hour); + const val = d?.[b.key] ?? 0; + return val > 0 ? ( +
+ + {b.label} + + {val} + +
+ ) : null; + })} +
+ Total + + {tooltip.total} + +
+
+ )}
@@ -101,7 +239,7 @@ export function ActivityChart({ hourly, loading }: ActivityChartProps) { function LoadingBox() { return ( - + Memuat data... );