From ad61af54e8d3849b3f88f87a54f43fe6760a48b7 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 4 Jun 2026 14:15:56 +0700 Subject: [PATCH] refactor(analytics): replace TrendChart gradient bars with interactive SVG line chart featuring tooltips and grid lines --- .../analytics/components/SummaryCards.tsx | 77 ++- .../analytics/components/TrendChart.tsx | 477 ++++++++++-------- 2 files changed, 312 insertions(+), 242 deletions(-) diff --git a/services/frontend/src/features/analytics/components/SummaryCards.tsx b/services/frontend/src/features/analytics/components/SummaryCards.tsx index de74d65..63df4a5 100644 --- a/services/frontend/src/features/analytics/components/SummaryCards.tsx +++ b/services/frontend/src/features/analytics/components/SummaryCards.tsx @@ -9,6 +9,15 @@ interface SummaryCardsProps { loading: boolean; } +interface CardDef { + label: string; + value: string; + accent: string; + barColor: string; + barPct?: number; + icon: string; +} + export function SummaryCards({ messages, activeUsersCount, @@ -26,63 +35,78 @@ export function SummaryCards({ messages && messages.total > 0 ? Math.round((messages.flagged / messages.total) * 100) : 0; - - const icons: Record = { - "Total Pesan": "💬", - "Rata-rata/jam": "📊", - Clean: "✅", - Warned: "⚠️", - Flagged: "🚩", - Pending: "⏳", - "User Aktif": "👤", - Channel: "📡", - }; - const warnedPct = messages && messages.total > 0 ? Math.round((messages.warned / messages.total) * 100) : 0; - const cards = [ + const cards: CardDef[] = [ { label: "Total Pesan", value: formatNum(messages?.total), accent: "text-foreground", + barColor: "bg-primary", + barPct: 100, + icon: "💬", }, { label: "Rata-rata/jam", value: formatNum(avgPerHour), accent: "text-muted-foreground", + barColor: "bg-primary/60", + barPct: avgPerHour > 0 ? Math.min((avgPerHour / 50) * 100, 100) : 0, + icon: "📊", }, { label: "Clean", value: cleanPct > 0 ? `${cleanPct}%` : "—", accent: "text-primary", + barColor: "bg-primary", + barPct: cleanPct, + icon: "✅", }, { label: "Warned", value: warnedPct > 0 ? `${warnedPct}%` : "—", accent: "text-yellow-600", + barColor: "bg-yellow-400", + barPct: warnedPct, + icon: "⚠️", }, { label: "Flagged", value: flaggedPct > 0 ? `${flaggedPct}%` : "—", accent: "text-accent", + barColor: "bg-accent", + barPct: flaggedPct, + icon: "🚩", }, { label: "Pending", value: formatNum(messages?.pending), accent: "text-muted-foreground", + barColor: "bg-muted-foreground/40", + barPct: + messages && messages.total > 0 + ? Math.round((messages.pending / messages.total) * 100) + : 0, + icon: "⏳", }, { label: "User Aktif", value: formatNum(activeUsersCount), accent: "text-primary", + barColor: "bg-primary", + barPct: activeUsersCount > 0 ? Math.min((activeUsersCount / 20) * 100, 100) : 0, + icon: "👤", }, { label: "Channel", value: formatNum(totalChannels), accent: "text-primary", + barColor: "bg-primary", + barPct: totalChannels > 0 ? Math.min((totalChannels / 20) * 100, 100) : 0, + icon: "📡", }, ]; @@ -91,22 +115,35 @@ export function SummaryCards({ {cards.map((card) => ( -
- - {icons[card.label] ?? "📋"} +
+ + {card.icon} -
+
{card.label}
- {loading ? : card.value} + {loading ? ( + + ) : ( + card.value + )}
+ {/* Mini bar indicator */} + {card.barPct != null && !loading && ( +
+
+
+ )} ))} diff --git a/services/frontend/src/features/analytics/components/TrendChart.tsx b/services/frontend/src/features/analytics/components/TrendChart.tsx index d1f9e7a..80c03b4 100644 --- a/services/frontend/src/features/analytics/components/TrendChart.tsx +++ b/services/frontend/src/features/analytics/components/TrendChart.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { TrendBucket } from "../../../shared/api/client"; import { Card, @@ -6,166 +7,284 @@ import { CardHeader, CardTitle, } from "../../../shared/ui"; +import { cn } from "../../../shared/lib/utils"; interface TrendChartProps { trend: TrendBucket[]; loading: boolean; } +const LINE_COLORS: Array<{ + key: keyof Omit; + color: string; + label: string; + dash?: string; +}> = [ + { key: "count", color: "#38bdf8", label: "Total" }, + { key: "clean", color: "#34d399", label: "Clean" }, + { key: "flagged", color: "#f472b6", label: "Flagged" }, + { key: "warned", color: "#facc15", label: "Warned" }, + { key: "error", color: "#fb923c", label: "Error", dash: "4 3" }, +]; + export function TrendChart({ trend, loading }: TrendChartProps) { - if (loading && !trend?.length) { - return ; + const [tooltip, setTooltip] = useState<{ + date: string; + values: Array<{ key: string; label: string; value: number; color: string }>; + } | null>(null); + + if (loading && !trend?.length) return ; + if (!trend?.length) return null; + + const CHART_HEIGHT = 200; + const CHART_PADDING = { top: 10, right: 16, bottom: 30, left: 40 }; + const chartW = Math.max((trend.length - 1) * 64, 200); + const plotW = chartW - CHART_PADDING.left - CHART_PADDING.right; + const plotH = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom; + + const allValues = trend.flatMap((d) => + LINE_COLORS.map((l) => Number(d[l.key] ?? 0)), + ); + const maxValue = Math.max(...allValues, 1); + + function getX(index: number): number { + if (trend.length <= 1) return CHART_PADDING.left; + return ( + CHART_PADDING.left + + (index / (trend.length - 1)) * plotW + ); } - if (!trend?.length) { - return null; + function getY(value: number): number { + return CHART_PADDING.top + plotH - (value / maxValue) * plotH; } - const data = trend.map((bucket) => ({ - date: bucket.date, - clean: bucket.clean, - warned: bucket.warned, - flagged: bucket.flagged, - error: bucket.error, - total: bucket.count, - })); + function buildLinePath( + data: TrendBucket[], + key: keyof Omit, + ): string { + const points = data.map((d, i) => ({ + x: getX(i), + y: getY(Number(d[key] ?? 0)), + })); + if (points.length === 0) return ""; - const totalMessages = data.reduce((sum, item) => sum + item.total, 0); + const segments: string[] = [`M ${points[0].x} ${points[0].y}`]; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const curr = points[i]; + const cx = (prev.x + curr.x) / 2; + segments.push(`Q ${cx} ${prev.y} ${curr.x} ${curr.y}`); + } + return segments.join(" "); + } + + function buildAreaPath( + data: TrendBucket[], + key: keyof Omit, + ): string { + const line = buildLinePath(data, key); + if (!line) return ""; + const first = getX(0); + const last = getX(data.length - 1); + const bottom = CHART_PADDING.top + plotH; + return `${line} L ${last} ${bottom} L ${first} ${bottom} Z`; + } + + const totalMessages = trend.reduce((sum, d) => sum + d.count, 0); return ( Tren Harian - Volume pesan per hari dengan status moderasi. + Volume pesan per hari — arahkan kursor ke titik untuk detail. -
-
- - - - - + {/* Legend */} +
+ {LINE_COLORS.map((l) => ( + + + + + {l.label} + + ))} +
+ +
+
+ + Rangkuman{" "} + {trend.length > 1 + ? `${trend.length} hari terakhir` + : "hari ini"} + + + {totalMessages} total pesan +
-
-
- Rangkuman 7 hari terakhir - {totalMessages} total pesan -
+
+ + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const y = getY(ratio * maxValue); + return ( + + + + {Math.round(ratio * maxValue)} + + + ); + })} -
- l.key === "count" || l.key === "flagged").map((l) => ( + + ))} + + {/* Lines */} + {LINE_COLORS.map((l) => ( + + ))} + + {/* Interactive dots */} + {trend.map((d, i) => { + const x = getX(i); + const y = getY(d.count); + const isActive = tooltip?.date === d.date; + return ( + + {/* Invisible hit area */} + { + setTooltip({ + date: d.date, + values: LINE_COLORS.map((l) => ({ + key: l.key, + label: l.label, + value: Number(d[l.key] ?? 0), + color: l.color, + })), + }); + }} + onMouseLeave={() => setTooltip(null)} + /> + {/* Dot */} + + {/* Date label */} + + {d.date.slice(5)} + + + ); + })} + + + {/* Tooltip */} + {tooltip && ( +
d.date === tooltip.date)) + 12}px`, + }} > - - - - - - - - - - - - - {Array.from({ length: 4 }, (_, index) => { - const y = 40 + index * 45; - return ( - - ); - })} - - - - - - - - - - - {data.map((item, index) => { - const x = - data.length <= 1 - ? 0 - : (index / (data.length - 1)) * - Math.max((data.length - 1) * 56, 56); - return ( - - - - {item.date.slice(5)} - - - ); - })} - -
+
+ {tooltip.date} +
+ {tooltip.values + .filter((v) => v.value > 0) + .map((v) => ( +
+ + + + {v.label} + + {v.value} + +
+ ))} +
+ )}
@@ -173,97 +292,11 @@ export function TrendChart({ trend, loading }: TrendChartProps) { ); } -function LegendDot({ color, label }: { color: string; label: string }) { - return ( - - {label} - - ); -} - -function TrendLine({ - data, - color, - strokeWidth, - keyName, -}: { - data: Array>; - color: string; - strokeWidth: number; - keyName: string; -}) { - const path = buildPath(data, keyName, 220, false); - - return ( - - ); -} - -function TrendArea({ - data, - keyName, - fill, - stroke, -}: { - data: Array>; - keyName: string; - fill: string; - stroke: string; -}) { - const path = buildPath(data, keyName, 220, true); - return ; -} - -function buildPath( - data: Array>, - keyName: string, - height: number, - closePath: boolean, -): string { - const values = data.map((item) => Number(item[keyName] ?? 0)); - const maxValue = Math.max(...values, 1); - const width = Math.max((data.length - 1) * 56, 56); - const points = values.map((value, index) => { - const x = data.length <= 1 ? 0 : (index / (data.length - 1)) * width; - const y = height - 35 - (value / maxValue) * 130; - return { x, y }; - }); - - if (points.length === 0) { - return ""; - } - - const segments: string[] = [`M ${points[0].x} ${points[0].y}`]; - for (let index = 1; index < points.length; index++) { - const previous = points[index - 1]; - const current = points[index]; - const controlX = (previous.x + current.x) / 2; - segments.push(`Q ${controlX} ${previous.y} ${current.x} ${current.y}`); - } - - if (closePath) { - const lastPoint = points[points.length - 1]; - const firstPoint = points[0]; - segments.push(`L ${lastPoint.x} ${height - 24}`); - segments.push(`L ${firstPoint.x} ${height - 24}`); - segments.push("Z"); - } - - return segments.join(" "); -} - function LoadingBox() { return ( - + Memuat data...