cf7cc6c885
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>
157 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|