Files
Epicure/apps/web/components/admin/charts/bar-chart.tsx
T
Arnaud cf7cc6c885 feat: Admin Insights page — charts for signups, recipes, tiers, usage (v0.55.0)
New Admin > Insights: signups/day and recipes-created/day (manual vs AI)
over the last 30 days, users by tier, recipes by visibility, monthly AI
call totals (6 months), and support tickets by status.

Two hand-rolled SVG chart components (bar-chart.tsx, time-series-chart.tsx)
instead of pulling in a charting library — avoids a new dependency and
any React 19 peer-dep risk. Both ship a hover tooltip (crosshair for the
time series, per-bar for the bar chart) and a "show as table" toggle per
the dataviz method's accessibility requirement.

Repurposed the app's existing (previously unused, grayscale-only)
shadcn --chart-1..5 CSS variables with a validated categorical palette —
run through the dataviz skill's CVD/contrast validator for both light and
dark surfaces (both pass; three light-mode slots fall under 3:1 contrast
by design, mitigated by the charts' direct value/axis labels).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:02:58 +02:00

157 lines
6.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"];
export type BarChartGroup = {
label: string;
values: number[];
};
/** Grouped vertical bar chart — hand-rolled SVG (no charting lib), series
* colors assigned by fixed index (never repainted if a filter changes
* which groups are visible). Includes a hover tooltip and a table-view
* toggle for the low-contrast-slot / no-color accessibility path. */
export function BarChart({
data,
seriesLabels,
formatValue = (n) => String(n),
height = 220,
}: {
data: BarChartGroup[];
seriesLabels: string[];
formatValue?: (n: number) => string;
height?: number;
}) {
const [hovered, setHovered] = useState<number | null>(null);
const [showTable, setShowTable] = useState(false);
const max = Math.max(1, ...data.flatMap((d) => d.values));
const width = 600;
const paddingBottom = 28;
const paddingTop = 12;
const plotHeight = height - paddingBottom - paddingTop;
const groupWidth = width / Math.max(1, data.length);
const barGap = 2;
const barWidth = Math.max(4, (groupWidth - 16 - barGap * (seriesLabels.length - 1)) / seriesLabels.length);
if (data.length === 0) {
return <p className="text-sm text-muted-foreground py-8 text-center">No data yet.</p>;
}
return (
<div className="space-y-2">
{seriesLabels.length > 1 && (
<div className="flex flex-wrap gap-3">
{seriesLabels.map((label, i) => (
<div key={label} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{label}
</div>
))}
</div>
)}
{showTable ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-1.5 pr-4 font-medium">Label</th>
{seriesLabels.map((label) => (
<th key={label} className="py-1.5 pr-4 font-medium">{label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((d) => (
<tr key={d.label} className="border-b last:border-0">
<td className="py-1.5 pr-4">{d.label}</td>
{d.values.map((v, i) => (
<td key={i} className="py-1.5 pr-4 tabular-nums">{formatValue(v)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="relative">
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto">
<line x1={0} y1={height - paddingBottom} x2={width} y2={height - paddingBottom} stroke="var(--border)" strokeWidth={1} />
{data.map((group, gi) => {
const gx = gi * groupWidth + 8;
return (
<g key={group.label} onMouseEnter={() => setHovered(gi)} onMouseLeave={() => setHovered(null)}>
<rect x={gi * groupWidth} y={paddingTop} width={groupWidth} height={plotHeight} fill="transparent" />
{group.values.map((v, si) => {
const barHeight = Math.max(0, (v / max) * plotHeight);
const bx = gx + si * (barWidth + barGap);
const by = height - paddingBottom - barHeight;
return (
<rect
key={si}
x={bx}
y={by}
width={barWidth}
height={barHeight}
rx={3}
fill={SERIES_COLORS[si % SERIES_COLORS.length]}
opacity={hovered === null || hovered === gi ? 1 : 0.45}
/>
);
})}
<text
x={gx + (barWidth + barGap) * seriesLabels.length / 2 - barGap / 2}
y={height - paddingBottom + 16}
textAnchor="middle"
fontSize={10}
fill="var(--muted-foreground)"
>
{group.label}
</text>
{seriesLabels.length === 1 && group.values[0]! > 0 && (
<text
x={gx + barWidth / 2}
y={height - paddingBottom - Math.max(0, (group.values[0]! / max) * plotHeight) - 4}
textAnchor="middle"
fontSize={10}
fill="var(--foreground)"
>
{formatValue(group.values[0]!)}
</text>
)}
</g>
);
})}
</svg>
{hovered !== null && (
<div
className="pointer-events-none absolute top-0 -translate-x-1/2 rounded-md border bg-popover px-2 py-1 text-xs shadow-md whitespace-nowrap"
style={{ left: `${((hovered + 0.5) / data.length) * 100}%` }}
>
<p className="font-medium text-popover-foreground">{data[hovered]!.label}</p>
{data[hovered]!.values.map((v, i) => (
<p key={i} className="text-muted-foreground flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-sm shrink-0" style={{ backgroundColor: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{seriesLabels[i]}: {formatValue(v)}
</p>
))}
</div>
)}
</div>
)}
<button
type="button"
onClick={() => setShowTable((s) => !s)}
className={cn("text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2")}
>
{showTable ? "Show chart" : "Show as table"}
</button>
</div>
);
}