fix(insights): preserve chart visual parity in deferred bundle

bb2b1de wrapped seven Recharts primitives (ScatterChart, Scatter, XAxis,
YAxis, CartesianGrid, Tooltip, ResponsiveContainer) in next/dynamic to
push the ~108 KiB Recharts chunk off the /insights initial bundle. That
seam is one level too low: ScatterChart discovers its children by
comparing child.type against the imported component identity
(findAllByType). Once each child is wrapped in a next/dynamic HOC the
identity check fails and the chart renders an empty plot area —
no axes, no scatter points, no tooltip, even after Recharts lands.

Fix: extract the inline <ResponsiveContainer><ScatterChart>…</ScatterChart>
block into src/components/charts/scatter-correlation-chart.tsx, a
self-contained wrapper that statically imports every Recharts symbol it
needs. The insights page now next/dynamics that wrapper instead, matching
the existing HealthChart / MoodChart pattern in the same file. Recharts
still ships only in the deferred chunk; child-type detection keeps
working because primitives stay statically related to each other.

All four scatter cards (BP-medication continuity, mood-vs-BP,
weight-vs-BP, mood-vs-weight) rewritten as declarative
<ScatterCorrelationChart> calls. Identical height={250}, margins, grid
colour (hsl(var(--border))), axis colours (var(--dracula-fg) /
var(--dracula-comment)), per-chart fills (--dracula-orange / --cyan /
--lavender), tooltip styling, units, domains, ticks, labels, and
formatters. No color tokens, animations, or chart options changed.

Also adds a loading skeleton (h-[250px] w-full bg-muted/40 animate-pulse
motion-reduce:animate-none) that matches the chart footprint exactly so
the first paint of each card no longer shifts layout while Recharts
hydrates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 16:41:49 +02:00
1 parent ad8fe5a60d
commit 1a85da0b05
2 files changed
+231 -243

No files matched your search

+91 -243
View File
@@ -39,39 +39,27 @@ import {
TrendingUp,
} from "lucide-react";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
// Recharts is ~108 KiB Brotli — defer-import the scatter primitives so they
// only land in the bundle once a correlation card actually renders. Every
// `<ResponsiveContainer>`/`<ScatterChart>`/`<Scatter>`/axis/grid/tooltip use
// site sits inside a `length >= 5` gate and is below the fold, so the first
// paint never waits on Recharts. v1.5 perf audit (docs/audit/v15-performance.md).
const ScatterChart = dynamic(
() => import("recharts").then((mod) => ({ default: mod.ScatterChart })),
{ ssr: false },
);
const Scatter = dynamic(
() => import("recharts").then((mod) => ({ default: mod.Scatter })),
{ ssr: false },
);
const XAxis = dynamic(
() => import("recharts").then((mod) => ({ default: mod.XAxis })),
{ ssr: false },
);
const YAxis = dynamic(
() => import("recharts").then((mod) => ({ default: mod.YAxis })),
{ ssr: false },
);
const CartesianGrid = dynamic(
() => import("recharts").then((mod) => ({ default: mod.CartesianGrid })),
{ ssr: false },
);
const Tooltip = dynamic(
() => import("recharts").then((mod) => ({ default: mod.Tooltip })),
{ ssr: false },
);
const ResponsiveContainer = dynamic(
// Recharts is ~108 KiB Brotli — defer-load it via a self-contained scatter
// wrapper so the bundle only lands once a correlation card actually renders.
// Every scatter card sits inside a `length >= 5` gate and below the fold,
// matching the existing HealthChart / MoodChart pattern. The earlier attempt
// (v1.5 phase 4) wrapped each Recharts primitive (XAxis/YAxis/etc.) in
// next/dynamic individually, which broke Recharts' internal child-type
// detection (`findAllByType` compares against the original component
// identity, not the dynamic HOC). Splitting at the wrapper level keeps the
// bundle savings while preserving visual parity. See
// docs/audit/v15-performance.md (v1.5 phase-4 perf audit).
const ScatterCorrelationChart = dynamic(
() =>
import("recharts").then((mod) => ({ default: mod.ResponsiveContainer })),
{ ssr: false },
import("@/components/charts/scatter-correlation-chart").then((mod) => ({
default: mod.ScatterCorrelationChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[250px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
),
},
);
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
@@ -1004,61 +992,25 @@ export default function InsightsPage() {
<CardContent>
{data.bpMedicationScatterData.length >= 5 ? (
<div className="space-y-2">
<ResponsiveContainer width="100%" height={250}>
<ScatterChart
margin={{ top: 10, right: 20, bottom: 36, left: 12 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
dataKey="continuityPct"
type="number"
name={t("insights.continuityLabel")}
unit="%"
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
tickMargin={8}
height={52}
interval="preserveStartEnd"
padding={{ left: 8, right: 8 }}
stroke="var(--dracula-comment)"
label={{
value: t("insights.continuityBpLabel"),
position: "bottom",
fontSize: 12,
fill: "var(--dracula-comment)",
}}
/>
<YAxis
dataKey="sysBP"
type="number"
name="Sys. BP"
unit=" mmHg"
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(value, name) => [
`${value}${name === t("insights.continuityLabel") ? "%" : " mmHg"}`,
name,
]}
/>
<Scatter
data={data.bpMedicationScatterData}
fill="var(--dracula-orange)"
opacity={0.8}
/>
</ScatterChart>
</ResponsiveContainer>
<ScatterCorrelationChart
data={data.bpMedicationScatterData}
fill="var(--dracula-orange)"
xAxis={{
dataKey: "continuityPct",
name: t("insights.continuityLabel"),
unit: "%",
label: t("insights.continuityBpLabel"),
}}
yAxis={{
dataKey: "sysBP",
name: "Sys. BP",
unit: " mmHg",
}}
tooltipFormatter={(value, name) => [
`${value}${name === t("insights.continuityLabel") ? "%" : " mmHg"}`,
name,
]}
/>
{data.bpMedicationCorrelation && (
<p className="text-muted-foreground text-center text-xs">
{data.bpMedicationCorrelation.r < 0
@@ -1108,56 +1060,22 @@ export default function InsightsPage() {
<CardContent>
{(data?.moodBpScatterData?.length ?? 0) >= 5 ? (
<div className="space-y-2">
<ResponsiveContainer width="100%" height={250}>
<ScatterChart
margin={{ top: 10, right: 20, bottom: 36, left: 12 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
dataKey="mood"
type="number"
name={t("insights.moodScoreLabel")}
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
tickMargin={8}
height={52}
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
stroke="var(--dracula-comment)"
label={{
value: t("insights.moodScoreLabel"),
position: "bottom",
fontSize: 12,
fill: "var(--dracula-comment)",
}}
/>
<YAxis
dataKey="sysBP"
type="number"
name="Sys. BP"
unit=" mmHg"
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
/>
<Scatter
data={data?.moodBpScatterData}
fill="var(--dracula-lavender)"
opacity={0.8}
/>
</ScatterChart>
</ResponsiveContainer>
<ScatterCorrelationChart
data={data?.moodBpScatterData}
fill="var(--dracula-lavender)"
xAxis={{
dataKey: "mood",
name: t("insights.moodScoreLabel"),
label: t("insights.moodScoreLabel"),
domain: [1, 5],
ticks: [1, 2, 3, 4, 5],
}}
yAxis={{
dataKey: "sysBP",
name: "Sys. BP",
unit: " mmHg",
}}
/>
{data?.moodBpCorrelation && (
<p className="text-muted-foreground text-center text-xs">
{data.moodBpCorrelation.strength === "stark"
@@ -1275,62 +1193,26 @@ export default function InsightsPage() {
<CardContent>
{data.scatterData.length >= 5 ? (
<div className="space-y-2">
<ResponsiveContainer width="100%" height={250}>
<ScatterChart
margin={{ top: 10, right: 20, bottom: 36, left: 12 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
dataKey="weight"
type="number"
name={t("dashboard.weight")}
unit=" kg"
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
tickMargin={8}
height={52}
interval="preserveStartEnd"
padding={{ left: 8, right: 8 }}
tickFormatter={(value: number) => value.toFixed(1)}
stroke="var(--dracula-comment)"
label={{
value: t("insights.weightKgLabel"),
position: "bottom",
fontSize: 12,
fill: "var(--dracula-comment)",
}}
/>
<YAxis
dataKey="sysBP"
type="number"
name="Sys. BP"
unit=" mmHg"
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(value, name) => [
`${value} ${name === t("dashboard.weight") ? "kg" : "mmHg"}`,
name,
]}
/>
<Scatter
data={data.scatterData}
fill="var(--dracula-cyan)"
opacity={0.8}
/>
</ScatterChart>
</ResponsiveContainer>
<ScatterCorrelationChart
data={data.scatterData}
fill="var(--dracula-cyan)"
xAxis={{
dataKey: "weight",
name: t("dashboard.weight"),
unit: " kg",
label: t("insights.weightKgLabel"),
tickFormatter: (value: number) => value.toFixed(1),
}}
yAxis={{
dataKey: "sysBP",
name: "Sys. BP",
unit: " mmHg",
}}
tooltipFormatter={(value, name) => [
`${value} ${name === t("dashboard.weight") ? "kg" : "mmHg"}`,
name,
]}
/>
{data.weightBpCorrelation && (
<p className="text-muted-foreground text-center text-xs">
{data.weightBpCorrelation.strength === "stark"
@@ -1376,56 +1258,22 @@ export default function InsightsPage() {
<CardContent>
{(data?.moodWeightScatterData?.length ?? 0) >= 5 ? (
<div className="space-y-2">
<ResponsiveContainer width="100%" height={250}>
<ScatterChart
margin={{ top: 10, right: 20, bottom: 36, left: 12 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
/>
<XAxis
dataKey="mood"
type="number"
name={t("insights.moodScoreLabel")}
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
tickMargin={8}
height={52}
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
stroke="var(--dracula-comment)"
label={{
value: t("insights.moodScoreLabel"),
position: "bottom",
fontSize: 12,
fill: "var(--dracula-comment)",
}}
/>
<YAxis
dataKey="weight"
type="number"
name={t("dashboard.weight")}
unit=" kg"
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
/>
<Scatter
data={data?.moodWeightScatterData}
fill="var(--dracula-lavender)"
opacity={0.8}
/>
</ScatterChart>
</ResponsiveContainer>
<ScatterCorrelationChart
data={data?.moodWeightScatterData}
fill="var(--dracula-lavender)"
xAxis={{
dataKey: "mood",
name: t("insights.moodScoreLabel"),
label: t("insights.moodScoreLabel"),
domain: [1, 5],
ticks: [1, 2, 3, 4, 5],
}}
yAxis={{
dataKey: "weight",
name: t("dashboard.weight"),
unit: " kg",
}}
/>
{data?.moodWeightCorrelation && (
<p className="text-muted-foreground text-center text-xs">
{data.moodWeightCorrelation.strength === "stark"
@@ -0,0 +1,140 @@
"use client";
import type { ReactNode } from "react";
import {
CartesianGrid,
ResponsiveContainer,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
/**
* Self-contained correlation scatter chart used by /insights.
*
* The insights page renders four near-identical scatter cards (weight vs BP,
* BP vs medication continuity, mood vs BP, mood vs weight) inside `length >= 5`
* gates below the fold. Recharts is ~108 KiB Brotli, so we want the bundle
* split — but the previous attempt (v1.5 phase 4) split at the *primitive*
* level (each `XAxis`/`YAxis`/`Scatter`/etc. wrapped in `next/dynamic`). That
* breaks Recharts because the chart relies on `findAllByType(child)` against
* the original component identity to discover its own children — once each
* primitive is wrapped in a `next/dynamic` HOC the identity check fails and
* the chart renders empty axes / no data points.
*
* The correct seam is one level higher: a single self-contained component
* that statically imports every Recharts symbol it needs internally. The
* insights page then dynamic-imports *this wrapper* (matching the existing
* HealthChart / MoodChart pattern) so Recharts still ships in the deferred
* chunk while child-type detection keeps working.
*/
export interface ScatterAxisConfig {
/** Object key in `data` that supplies the value. */
dataKey: string;
/** Tooltip / a11y name for the axis. */
name: string;
/** Optional unit suffix shown by Recharts in tooltip / axis. */
unit?: string;
/** Optional axis label rendered below (X) the plot area. */
label?: string;
/** Optional fixed numeric domain (e.g. mood 1..5). */
domain?: [number, number];
/** Optional explicit tick values. */
ticks?: number[];
/** Optional formatter for tick labels. */
tickFormatter?: (value: number) => string;
}
export interface ScatterCorrelationChartProps<
T extends Record<string, number>,
> {
/** Data array. May be undefined while a parent query is loading; the
* insights cards already gate rendering behind a `length >= 5` check. */
data: T[] | undefined;
/** Marker fill colour, e.g. "var(--dracula-cyan)". */
fill: string;
xAxis: ScatterAxisConfig;
yAxis: ScatterAxisConfig;
/**
* Optional tooltip value formatter. Mirrors Recharts' `Formatter<ValueType,
* NameType>` signature; values may be string/number/ReadonlyArray and
* either side may be undefined while the tooltip is hydrating. Recharts
* also passes (item, index, payload) — we keep the rest-args open so
* existing inline formatters (which only read value+name) work as-is.
*/
tooltipFormatter?: (
value: number | string | ReadonlyArray<number | string> | undefined,
name: number | string | undefined,
...rest: unknown[]
) => [ReactNode, number | string] | ReactNode;
/** Plot height in pixels. Defaults to 250 to match existing layout. */
height?: number;
}
export function ScatterCorrelationChart<T extends Record<string, number>>({
data,
fill,
xAxis,
yAxis,
tooltipFormatter,
height = 250,
}: ScatterCorrelationChartProps<T>) {
return (
<ResponsiveContainer width="100%" height={height}>
<ScatterChart margin={{ top: 10, right: 20, bottom: 36, left: 12 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey={xAxis.dataKey}
type="number"
name={xAxis.name}
unit={xAxis.unit}
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
tickMargin={8}
height={52}
interval="preserveStartEnd"
padding={{ left: 8, right: 8 }}
stroke="var(--dracula-comment)"
domain={xAxis.domain}
ticks={xAxis.ticks}
tickFormatter={xAxis.tickFormatter}
label={
xAxis.label
? {
value: xAxis.label,
position: "bottom",
fontSize: 12,
fill: "var(--dracula-comment)",
}
: undefined
}
/>
<YAxis
dataKey={yAxis.dataKey}
type="number"
name={yAxis.name}
unit={yAxis.unit}
tick={{ fontSize: 12, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
domain={yAxis.domain}
ticks={yAxis.ticks}
tickFormatter={yAxis.tickFormatter}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={tooltipFormatter}
/>
<Scatter data={data} fill={fill} opacity={0.8} />
</ScatterChart>
</ResponsiveContainer>
);
}