refactor(analytics): replace TrendChart gradient bars with interactive SVG line chart featuring tooltips and grid lines

This commit is contained in:
MythEclipse
2026-06-04 14:15:56 +07:00
parent ceb5fc95ba
commit ad61af54e8
2 changed files with 312 additions and 242 deletions
@@ -9,6 +9,15 @@ interface SummaryCardsProps {
loading: boolean; loading: boolean;
} }
interface CardDef {
label: string;
value: string;
accent: string;
barColor: string;
barPct?: number;
icon: string;
}
export function SummaryCards({ export function SummaryCards({
messages, messages,
activeUsersCount, activeUsersCount,
@@ -26,63 +35,78 @@ export function SummaryCards({
messages && messages.total > 0 messages && messages.total > 0
? Math.round((messages.flagged / messages.total) * 100) ? Math.round((messages.flagged / messages.total) * 100)
: 0; : 0;
const icons: Record<string, string> = {
"Total Pesan": "💬",
"Rata-rata/jam": "📊",
Clean: "✅",
Warned: "⚠️",
Flagged: "🚩",
Pending: "⏳",
"User Aktif": "👤",
Channel: "📡",
};
const warnedPct = const warnedPct =
messages && messages.total > 0 messages && messages.total > 0
? Math.round((messages.warned / messages.total) * 100) ? Math.round((messages.warned / messages.total) * 100)
: 0; : 0;
const cards = [ const cards: CardDef[] = [
{ {
label: "Total Pesan", label: "Total Pesan",
value: formatNum(messages?.total), value: formatNum(messages?.total),
accent: "text-foreground", accent: "text-foreground",
barColor: "bg-primary",
barPct: 100,
icon: "💬",
}, },
{ {
label: "Rata-rata/jam", label: "Rata-rata/jam",
value: formatNum(avgPerHour), value: formatNum(avgPerHour),
accent: "text-muted-foreground", accent: "text-muted-foreground",
barColor: "bg-primary/60",
barPct: avgPerHour > 0 ? Math.min((avgPerHour / 50) * 100, 100) : 0,
icon: "📊",
}, },
{ {
label: "Clean", label: "Clean",
value: cleanPct > 0 ? `${cleanPct}%` : "—", value: cleanPct > 0 ? `${cleanPct}%` : "—",
accent: "text-primary", accent: "text-primary",
barColor: "bg-primary",
barPct: cleanPct,
icon: "✅",
}, },
{ {
label: "Warned", label: "Warned",
value: warnedPct > 0 ? `${warnedPct}%` : "—", value: warnedPct > 0 ? `${warnedPct}%` : "—",
accent: "text-yellow-600", accent: "text-yellow-600",
barColor: "bg-yellow-400",
barPct: warnedPct,
icon: "⚠️",
}, },
{ {
label: "Flagged", label: "Flagged",
value: flaggedPct > 0 ? `${flaggedPct}%` : "—", value: flaggedPct > 0 ? `${flaggedPct}%` : "—",
accent: "text-accent", accent: "text-accent",
barColor: "bg-accent",
barPct: flaggedPct,
icon: "🚩",
}, },
{ {
label: "Pending", label: "Pending",
value: formatNum(messages?.pending), value: formatNum(messages?.pending),
accent: "text-muted-foreground", 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", label: "User Aktif",
value: formatNum(activeUsersCount), value: formatNum(activeUsersCount),
accent: "text-primary", accent: "text-primary",
barColor: "bg-primary",
barPct: activeUsersCount > 0 ? Math.min((activeUsersCount / 20) * 100, 100) : 0,
icon: "👤",
}, },
{ {
label: "Channel", label: "Channel",
value: formatNum(totalChannels), value: formatNum(totalChannels),
accent: "text-primary", 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) => ( {cards.map((card) => (
<Card key={card.label} className="overflow-hidden"> <Card key={card.label} className="overflow-hidden">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-[10px]"> <span className="flex h-5 w-5 items-center justify-center text-[11px]">
{icons[card.label] ?? "📋"} {card.icon}
</span> </span>
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{card.label} {card.label}
</div> </div>
</div> </div>
<div <div
className={cn( className={cn(
"mt-1 font-mono text-lg font-bold tabular-nums", "font-mono text-lg font-bold tabular-nums",
card.accent, loading ? "opacity-30" : card.accent,
)} )}
> >
{loading ? <Skeleton className="h-7 w-12 mt-1" /> : card.value} {loading ? (
<Skeleton className="h-7 w-12 mt-1" />
) : (
card.value
)}
</div> </div>
{/* Mini bar indicator */}
{card.barPct != null && !loading && (
<div className="mt-1.5 h-1 overflow-hidden rounded-sm bg-muted/30">
<div
className={cn("h-full rounded-sm transition-all duration-500", card.barColor)}
style={{ width: `${card.barPct}%` }}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@@ -1,3 +1,4 @@
import { useState } from "react";
import type { TrendBucket } from "../../../shared/api/client"; import type { TrendBucket } from "../../../shared/api/client";
import { import {
Card, Card,
@@ -6,166 +7,284 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../shared/ui"; } from "../../../shared/ui";
import { cn } from "../../../shared/lib/utils";
interface TrendChartProps { interface TrendChartProps {
trend: TrendBucket[]; trend: TrendBucket[];
loading: boolean; loading: boolean;
} }
const LINE_COLORS: Array<{
key: keyof Omit<TrendBucket, "date">;
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) { export function TrendChart({ trend, loading }: TrendChartProps) {
if (loading && !trend?.length) { const [tooltip, setTooltip] = useState<{
return <LoadingBox />; date: string;
values: Array<{ key: string; label: string; value: number; color: string }>;
} | null>(null);
if (loading && !trend?.length) return <LoadingBox />;
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) { function getY(value: number): number {
return null; return CHART_PADDING.top + plotH - (value / maxValue) * plotH;
} }
const data = trend.map((bucket) => ({ function buildLinePath(
date: bucket.date, data: TrendBucket[],
clean: bucket.clean, key: keyof Omit<TrendBucket, "date">,
warned: bucket.warned, ): string {
flagged: bucket.flagged, const points = data.map((d, i) => ({
error: bucket.error, x: getX(i),
total: bucket.count, 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<TrendBucket, "date">,
): 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 ( return (
<Card className="col-span-3"> <Card className="col-span-3">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Tren Harian</CardTitle> <CardTitle className="text-sm font-semibold">Tren Harian</CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
Volume pesan per hari dengan status moderasi. Volume pesan per hari arahkan kursor ke titik untuk detail.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> {/* Legend */}
<div className="flex flex-wrap gap-3 text-[10px] uppercase tracking-wider text-muted-foreground"> <div className="mb-3 flex flex-wrap gap-4 text-[11px]">
<LegendDot color="bg-primary" label="Total" /> {LINE_COLORS.map((l) => (
<LegendDot color="bg-emerald-400" label="Clean" /> <span key={l.key} className="flex items-center gap-1.5">
<LegendDot color="bg-yellow-400" label="Warned" /> <svg width="14" height="3" className="overflow-visible">
<LegendDot color="bg-accent" label="Flagged" /> <line
<LegendDot color="bg-orange-300" label="Error" /> x1="0"
y1="1.5"
x2="14"
y2="1.5"
stroke={l.color}
strokeWidth={2}
strokeDasharray={l.dash ?? "none"}
strokeLinecap="round"
/>
</svg>
{l.label}
</span>
))}
</div>
<div className="overflow-hidden rounded-xl border border-muted/50 bg-white/60 p-4">
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span>
Rangkuman{" "}
{trend.length > 1
? `${trend.length} hari terakhir`
: "hari ini"}
</span>
<span className="font-medium tabular-nums">
{totalMessages} total pesan
</span>
</div> </div>
<div className="overflow-hidden rounded-2xl border border-sky-100 bg-white/60 p-4"> <div className="overflow-x-auto">
<div className="mb-3 flex items-center justify-between text-[11px] text-muted-foreground"> <svg
<span>Rangkuman 7 hari terakhir</span> viewBox={`0 0 ${chartW} ${CHART_HEIGHT}`}
<span>{totalMessages} total pesan</span> className="w-full"
</div> style={{ height: CHART_HEIGHT }}
>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const y = getY(ratio * maxValue);
return (
<g key={ratio}>
<line
x1={CHART_PADDING.left}
y1={y}
x2={chartW - CHART_PADDING.right}
y2={y}
stroke="hsl(var(--muted))"
strokeWidth={1}
strokeDasharray="3 3"
/>
<text
x={CHART_PADDING.left - 6}
y={y + 3}
textAnchor="end"
className="fill-muted-foreground"
fontSize={9}
>
{Math.round(ratio * maxValue)}
</text>
</g>
);
})}
<div className="overflow-x-auto"> {/* Area fills */}
<svg {LINE_COLORS.filter((l) => l.key === "count" || l.key === "flagged").map((l) => (
viewBox={`0 0 ${Math.max((data.length - 1) * 56, 56)} 220`} <path
className="h-55 min-w-130 w-full overflow-visible" key={`area-${l.key}`}
d={buildAreaPath(trend, l.key)}
fill={l.color}
opacity={0.07}
/>
))}
{/* Lines */}
{LINE_COLORS.map((l) => (
<path
key={`line-${l.key}`}
d={buildLinePath(trend, l.key)}
fill="none"
stroke={l.color}
strokeWidth={l.key === "count" ? 2.5 : 1.5}
strokeDasharray={l.dash ?? "none"}
strokeLinejoin="round"
strokeLinecap="round"
className="transition-opacity"
/>
))}
{/* Interactive dots */}
{trend.map((d, i) => {
const x = getX(i);
const y = getY(d.count);
const isActive = tooltip?.date === d.date;
return (
<g key={d.date}>
{/* Invisible hit area */}
<rect
x={x - 28}
y={CHART_PADDING.top}
width={56}
height={plotH}
fill="transparent"
className="cursor-crosshair"
onMouseEnter={() => {
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 */}
<circle
cx={x}
cy={y}
r={isActive ? 5 : 2.5}
fill={isActive ? "#38bdf8" : "#38bdf8"}
stroke="white"
strokeWidth={isActive ? 2 : 0}
className={cn(
"transition-all",
isActive ? "opacity-100" : "opacity-70",
)}
/>
{/* Date label */}
<text
x={x}
y={CHART_PADDING.top + plotH + 16}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={9}
>
{d.date.slice(5)}
</text>
</g>
);
})}
</svg>
{/* Tooltip */}
{tooltip && (
<div
className="pointer-events-none absolute z-10 rounded-lg border border-muted bg-white px-3 py-2 text-xs shadow-lg"
style={{
top: `${CHART_PADDING.top + 4}px`,
left: `${getX(trend.findIndex((d) => d.date === tooltip.date)) + 12}px`,
}}
> >
<defs> <div className="mb-1 font-semibold text-foreground">
<linearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1"> {tooltip.date}
<stop offset="0%" stopColor="#7EC8E3" stopOpacity="0.35" /> </div>
<stop {tooltip.values
offset="100%" .filter((v) => v.value > 0)
stopColor="#7EC8E3" .map((v) => (
stopOpacity="0.02" <div
/> key={v.key}
</linearGradient> className="flex items-center gap-2 text-muted-foreground"
<linearGradient >
id="trendFillPink" <svg width="8" height="8">
x1="0" <circle
x2="0" cx="4"
y1="0" cy="4"
y2="1" r="3"
> fill={v.color}
<stop offset="0%" stopColor="#FF9EBB" stopOpacity="0.3" /> />
<stop </svg>
offset="100%" <span>{v.label}</span>
stopColor="#FF9EBB" <span className="ml-6 font-medium tabular-nums text-foreground">
stopOpacity="0.02" {v.value}
/> </span>
</linearGradient> </div>
</defs> ))}
</div>
<g stroke="#cbd5e1" strokeWidth="1" opacity="0.35"> )}
{Array.from({ length: 4 }, (_, index) => {
const y = 40 + index * 45;
return (
<line
key={index}
x1="0"
x2={Math.max((data.length - 1) * 56, 56)}
y1={y}
y2={y}
/>
);
})}
</g>
<TrendArea
data={data}
keyName="total"
fill="url(#trendFill)"
stroke="#7EC8E3"
/>
<TrendLine
data={data}
keyName="total"
color="#7EC8E3"
strokeWidth={2.5}
/>
<TrendLine
data={data}
keyName="clean"
color="#34d399"
strokeWidth={1.8}
/>
<TrendLine
data={data}
keyName="flagged"
color="#FF9EBB"
strokeWidth={1.8}
/>
<TrendArea
data={data}
keyName="flagged"
fill="url(#trendFillPink)"
stroke="#FF9EBB"
/>
<TrendLine
data={data}
keyName="warned"
color="#facc15"
strokeWidth={1.4}
/>
<TrendLine
data={data}
keyName="error"
color="#fdba74"
strokeWidth={1.2}
/>
{data.map((item, index) => {
const x =
data.length <= 1
? 0
: (index / (data.length - 1)) *
Math.max((data.length - 1) * 56, 56);
return (
<g key={item.date} transform={`translate(${x}, 188)`}>
<circle cx="0" cy="0" r="2.5" fill="#7EC8E3" />
<text
x="0"
y="18"
textAnchor="middle"
className="fill-muted-foreground text-[10px]"
>
{item.date.slice(5)}
</text>
</g>
);
})}
</svg>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -173,97 +292,11 @@ export function TrendChart({ trend, loading }: TrendChartProps) {
); );
} }
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<span className="flex items-center gap-1">
<span className={`h-2 w-2 rounded-full ${color}`} /> {label}
</span>
);
}
function TrendLine({
data,
color,
strokeWidth,
keyName,
}: {
data: Array<Record<string, number | string>>;
color: string;
strokeWidth: number;
keyName: string;
}) {
const path = buildPath(data, keyName, 220, false);
return (
<path
d={path}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
/>
);
}
function TrendArea({
data,
keyName,
fill,
stroke,
}: {
data: Array<Record<string, number | string>>;
keyName: string;
fill: string;
stroke: string;
}) {
const path = buildPath(data, keyName, 220, true);
return <path d={path} fill={fill} stroke={stroke} strokeOpacity={0.2} />;
}
function buildPath(
data: Array<Record<string, number | string>>,
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() { function LoadingBox() {
return ( return (
<Card className="col-span-3"> <Card className="col-span-3">
<CardContent className="flex h-65 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-sm border-2 border-current border-t-transparent" />
<span className="ml-2">Memuat data...</span> <span className="ml-2">Memuat data...</span>
</CardContent> </CardContent>
</Card> </Card>