StatusDistributionChart renders a donut chart summarizing library status counts. Filters out zero-value categories and presents totals with accessible labels.

export const StatusDistributionChart: FC<StatusDistributionChartProps> =
React.memo(function StatusDistributionChartMemo({
// eslint-disable-next-line react/prop-types
data,
// eslint-disable-next-line react/prop-types
className,
// eslint-disable-next-line react/prop-types
onDrillDown,
// eslint-disable-next-line react/prop-types
matchResults,
}) {
const chartData = useMemo<ChartDatum[]>(() => {
if (!data) return [];

const fallbackColors = [
"#3b82f6",
"#10b981",
"#f59e0b",
"#ef4444",
"#a855f7",
"#6366f1",
];

let fallbackIndex = 0;
return Object.entries(data)
.map(([statusKey, count]) => {
const normalizedKey = statusKey.toLowerCase();
const label =
STATUS_LABELS[normalizedKey] ??
STATUS_LABELS[normalizedKey.replace("_", "-")] ??
normalizedKey
.split(" ")
.map((segment) =>
segment.length > 0
? segment[0].toUpperCase() + segment.slice(1)
: segment,
)
.join(" ");

const color =
STATUS_COLORS[normalizedKey] ??
STATUS_COLORS[normalizedKey.replace("_", "-")] ??
fallbackColors[fallbackIndex++ % fallbackColors.length];

return {
name: label,
value: count,
color,
rawKey: normalizedKey,
} satisfies ChartDatum;
})
.filter((item) => item.value > 0)
.sort((a, b) => b.value - a.value);
}, [data]);

const totalCount = useMemo(
() => chartData.reduce((acc, item) => acc + item.value, 0),
[chartData],
);

return (
<section
aria-label="Library status distribution"
className={cn(
"rounded-2xl border border-slate-200 bg-white/90 p-6 shadow-sm backdrop-blur-md dark:border-slate-800 dark:bg-slate-900/90",
className,
)}
>
<header className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="bg-linear-to-r inline-flex h-9 min-h-9 w-9 min-w-9 items-center justify-center rounded-full from-blue-500/20 via-purple-500/20 to-fuchsia-500/20 text-blue-500 dark:text-blue-300">
<PieChartIcon className="h-4 w-4" aria-hidden="true" />
</span>
<h2 className="text-foreground text-lg font-semibold">
Library Status Distribution
</h2>
</div>
<p className="text-muted-foreground mt-1 text-sm">
Percentage of manga entries by reading status.
</p>
</div>
{totalCount > 0 && (
<Badge variant="secondary" className="whitespace-nowrap">
{totalCount.toLocaleString()} titles
</Badge>
)}
</header>

{chartData.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-8 text-center dark:border-slate-800 dark:bg-slate-900/60">
<AlertCircle className="mb-3 h-6 w-6" aria-hidden="true" />
<p className="font-medium">No manga imported yet</p>
<p className="mt-1 text-sm">
Import your Kenmei library to unlock status analytics.
</p>
</div>
) : (
<>
{/* Chart with accessible label summarizing key values */}
{(() => {
const chartSummary = chartData
.map(
(item) =>
`${item.name} ${item.value} ${item.value === 1 ? "title" : "titles"}`,
)
.join(", ");
return (
<figure
className="h-[300px] w-full"
aria-label={`Library status distribution: ${chartSummary}`}
>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
labelLine={false}
cursor={onDrillDown ? "pointer" : "default"}
onClick={(entry) => {
if (onDrillDown && matchResults && entry) {
// Filter by kenmeiManga.status (reading status: reading, completed, etc)
// This differs from computeDrillDownData which filters by match status (pending/matched/manual)
const payload = entry as {
payload?: { rawKey?: string };
rawKey?: string;
};
const rawKey =
payload.payload?.rawKey ?? payload.rawKey;
if (!rawKey) return;
// eslint-disable-next-line react/prop-types
const filtered = matchResults.filter(
(match) => match.kenmeiManga.status === rawKey,
);
onDrillDown({
type: "status",
value: rawKey,
data: filtered
.map((match) => ({
title: match.kenmeiManga.title,
chapters: match.kenmeiManga.chaptersRead,
status: match.kenmeiManga.status,
confidence:
match.anilistMatches?.[0]?.confidence,
format: match.selectedMatch?.format,
}))
.sort((a, b) => b.chapters - a.chapters)
.slice(0, 100),
});
}
}}
label={(props: PieLabelPayload) =>
// eslint-disable-next-line react/prop-types
`${Math.round((props.percent ?? 0) * 100)}%`
}
>
{chartData.map((entry) => (
<Cell key={entry.rawKey} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--muted))",
borderRadius: "calc(var(--radius) - 4px)",
padding: "0.5rem 0.75rem",
border: "none",
}}
itemStyle={{ color: "white" }}
formatter={(value: number, name: string) => {
// Recharts payload.percent can be inconsistent across versions/contexts.
// Use the computed totalCount to derive a reliable percentage.
const numeric = Number(value ?? 0);
const percent =
totalCount > 0 ? numeric / totalCount : 0;
return [
`${numeric.toLocaleString()} (${Math.round(percent * 100)}%)`,
name,
];
}}
/>
<Legend
iconType="circle"
verticalAlign="bottom"
height={36}
/>
</PieChart>
</ResponsiveContainer>
</figure>
);
})()}

{/* Visually hidden data table for screen readers */}
<div className="sr-only">
<table>
<caption>Library status distribution data</caption>
<thead>
<tr>
<th>Status</th>
<th>Count</th>
<th>Percentage</th>
</tr>
</thead>
<tbody>
{chartData.map((item) => (
<tr key={item.rawKey}>
<td>{item.name}</td>
<td>{item.value.toLocaleString()}</td>
<td>
{totalCount > 0
? Math.round((item.value / totalCount) * 100)
: 0}
%
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</section>
);
});