mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
ad8fe5a60d
commit
1a85da0b05
2 files changed
+231
-243
No files matched your search
+91
-243
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user