From 1a85da0b05547492b79bf4e92564b0273f717e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 16:41:49 +0200 Subject: [PATCH] fix(insights): preserve chart visual parity in deferred bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 … 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 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) --- src/app/insights/page.tsx | 334 +++++------------- .../charts/scatter-correlation-chart.tsx | 140 ++++++++ 2 files changed, 231 insertions(+), 243 deletions(-) create mode 100644 src/components/charts/scatter-correlation-chart.tsx diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index e64e7294c..3030eb979 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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 -// ``/``/``/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: () => ( +
+ ), + }, ); import { getBpTargets } from "@/lib/analytics/bp-targets"; import { @@ -1004,61 +992,25 @@ export default function InsightsPage() { {data.bpMedicationScatterData.length >= 5 ? (
- - - - - - [ - `${value}${name === t("insights.continuityLabel") ? "%" : " mmHg"}`, - name, - ]} - /> - - - + [ + `${value}${name === t("insights.continuityLabel") ? "%" : " mmHg"}`, + name, + ]} + /> {data.bpMedicationCorrelation && (

{data.bpMedicationCorrelation.r < 0 @@ -1108,56 +1060,22 @@ export default function InsightsPage() { {(data?.moodBpScatterData?.length ?? 0) >= 5 ? (

- - - - - - - - - + {data?.moodBpCorrelation && (

{data.moodBpCorrelation.strength === "stark" @@ -1275,62 +1193,26 @@ export default function InsightsPage() { {data.scatterData.length >= 5 ? (

- - - - value.toFixed(1)} - stroke="var(--dracula-comment)" - label={{ - value: t("insights.weightKgLabel"), - position: "bottom", - fontSize: 12, - fill: "var(--dracula-comment)", - }} - /> - - [ - `${value} ${name === t("dashboard.weight") ? "kg" : "mmHg"}`, - name, - ]} - /> - - - + value.toFixed(1), + }} + yAxis={{ + dataKey: "sysBP", + name: "Sys. BP", + unit: " mmHg", + }} + tooltipFormatter={(value, name) => [ + `${value} ${name === t("dashboard.weight") ? "kg" : "mmHg"}`, + name, + ]} + /> {data.weightBpCorrelation && (

{data.weightBpCorrelation.strength === "stark" @@ -1376,56 +1258,22 @@ export default function InsightsPage() { {(data?.moodWeightScatterData?.length ?? 0) >= 5 ? (

- - - - - - - - - + {data?.moodWeightCorrelation && (

{data.moodWeightCorrelation.strength === "stark" diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx new file mode 100644 index 000000000..9a44e08a7 --- /dev/null +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -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, +> { + /** 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` 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 | 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>({ + data, + fill, + xAxis, + yAxis, + tooltipFormatter, + height = 250, +}: ScatterCorrelationChartProps) { + return ( + + + + + + + + + + ); +}