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 ( + + + + + + + + + + ); +}