Files
Epicure/apps/web/components/admin/charts/time-series-chart.tsx
T
Arnaud 37739699f9 fix: Admin Insights actual crash — function props across server/client boundary (v0.55.3)
Server logs (thanks to the user pulling them) showed the real error:
"Functions cannot be passed directly to Client Components" — the server
component page was passing formatShortDate/formatMonth as a `formatDate`
prop into TimeSeriesChart ("use client"). Functions aren't serializable
across the RSC boundary; the two previous fixes (query hardening,
Promise.allSettled) were real improvements but not the actual cause of
the reported crash.

TimeSeriesChart now takes a plain `dateFormat: "day" | "month"` string
and formats internally — BarChart was never affected (its formatValue
prop is only ever used via its own default, never passed from the page).

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

136 lines
5.4 KiB
TypeScript

"use client";
import { useState, useRef } from "react";
import { cn } from "@/lib/utils";
export type TimeSeriesPoint = { date: string; value: number };
/** "day" expects "YYYY-MM-DD", "month" expects "YYYY-MM" — kept as a plain
* string enum rather than a formatter function prop, since this chart is
* a "use client" component and functions passed from a server component
* parent aren't serializable across that boundary (React throws: "Functions
* cannot be passed directly to Client Components"). */
function formatPoint(date: string, kind: "day" | "month"): string {
if (kind === "month") {
const [y, m] = date.split("-");
return new Date(Number(y), Number(m) - 1, 1).toLocaleDateString(undefined, { month: "short", year: "2-digit" });
}
return new Date(`${date}T00:00:00Z`).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
}
/** Single-series area/line chart with a hover crosshair+tooltip and a
* table-view fallback — hand-rolled SVG, no charting lib. */
export function TimeSeriesChart({
data,
formatValue = (n) => String(n),
dateFormat = "day",
height = 200,
}: {
data: TimeSeriesPoint[];
formatValue?: (n: number) => string;
dateFormat?: "day" | "month";
height?: number;
}) {
const [hovered, setHovered] = useState<number | null>(null);
const [showTable, setShowTable] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
const formatDate = (d: string) => formatPoint(d, dateFormat);
const width = 600;
const paddingBottom = 24;
const paddingTop = 12;
const plotHeight = height - paddingBottom - paddingTop;
const max = Math.max(1, ...data.map((d) => d.value));
const n = data.length;
if (n === 0) {
return <p className="text-sm text-muted-foreground py-8 text-center">No data yet.</p>;
}
const xFor = (i: number) => (n === 1 ? width / 2 : (i / (n - 1)) * width);
const yFor = (v: number) => height - paddingBottom - (v / max) * plotHeight;
const linePath = data.map((d, i) => `${i === 0 ? "M" : "L"} ${xFor(i)} ${yFor(d.value)}`).join(" ");
const areaPath = `${linePath} L ${xFor(n - 1)} ${height - paddingBottom} L ${xFor(0)} ${height - paddingBottom} Z`;
function handleMove(e: React.MouseEvent<SVGSVGElement>) {
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const relX = (e.clientX - rect.left) / rect.width;
const i = Math.round(relX * (n - 1));
setHovered(Math.max(0, Math.min(n - 1, i)));
}
const labelStep = Math.max(1, Math.ceil(n / 6));
return (
<div className="space-y-2">
{showTable ? (
<div className="overflow-x-auto max-h-64">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground sticky top-0 bg-card">
<th className="py-1.5 pr-4 font-medium">Date</th>
<th className="py-1.5 pr-4 font-medium">Value</th>
</tr>
</thead>
<tbody>
{data.map((d) => (
<tr key={d.date} className="border-b last:border-0">
<td className="py-1.5 pr-4">{formatDate(d.date)}</td>
<td className="py-1.5 pr-4 tabular-nums">{formatValue(d.value)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="relative">
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
onMouseMove={handleMove}
onMouseLeave={() => setHovered(null)}
>
<line x1={0} y1={height - paddingBottom} x2={width} y2={height - paddingBottom} stroke="var(--border)" strokeWidth={1} />
<path d={areaPath} fill="var(--chart-1)" opacity={0.15} />
<path d={linePath} fill="none" stroke="var(--chart-1)" strokeWidth={2} />
{data.map((d, i) => (
i % labelStep === 0 && (
<text key={i} x={xFor(i)} y={height - paddingBottom + 15} textAnchor="middle" fontSize={10} fill="var(--muted-foreground)">
{formatDate(d.date)}
</text>
)
))}
{hovered !== null && (
<>
<line x1={xFor(hovered)} y1={paddingTop} x2={xFor(hovered)} y2={height - paddingBottom} stroke="var(--border)" strokeWidth={1} strokeDasharray="3 3" />
<circle cx={xFor(hovered)} cy={yFor(data[hovered]!.value)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} />
</>
)}
</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: `${(xFor(hovered) / width) * 100}%` }}
>
<p className="font-medium text-popover-foreground">{formatDate(data[hovered]!.date)}</p>
<p className="text-muted-foreground">{formatValue(data[hovered]!.value)}</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>
);
}