diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 9f0324a08..6e1e08f34 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -30,7 +30,7 @@ import { apiGet } from "@/lib/api/api-fetch"; */ const MoodChart = dynamic( () => - import("@/components/charts/mood-chart").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.MoodChart, })), { ssr: false, loading: () => }, diff --git a/src/app/page.tsx b/src/app/page.tsx index a2154cc1d..04da3b69d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -81,7 +81,7 @@ const BATCH_COVERAGE_DAYS = 31; // to ONE error card instead of bubbling to the route-level `error.tsx`. const MoodChartLazy = dynamic( () => - importWithRetry(() => import("@/components/charts/mood-chart")).then( + importWithRetry(() => import("@/components/charts/chart-runtime")).then( (mod) => ({ default: mod.MoodChart }), ), { ssr: false, loading: () => }, @@ -94,7 +94,7 @@ const MoodChart = (props: React.ComponentProps) => ( const MedicationComplianceChartLazy = dynamic( () => importWithRetry( - () => import("@/components/charts/medication-compliance-chart"), + () => import("@/components/charts/chart-runtime"), ).then((mod) => ({ default: mod.MedicationComplianceChart })), { ssr: false, loading: () => }, ); diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 2fff4d5c1..053789cc1 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -32,7 +32,7 @@ import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; // working — same pattern the insights page uses for the scatter chart. const HostMetricsChart = dynamic( () => - import("./host-metrics-chart").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.HostMetricsChart, })), { diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts new file mode 100644 index 000000000..f5498f07d --- /dev/null +++ b/src/components/charts/chart-runtime.ts @@ -0,0 +1,43 @@ +/** + * The ONE async boundary for every recharts-rendering component. + * + * Every `next/dynamic` chart import in the tree points at THIS module + * (`import("@/components/charts/chart-runtime")`), never at the individual + * chart files. Pointing N dynamic boundaries at N different modules made + * Turbopack emit a separate chunk group per boundary, each carrying its own + * full copy of the recharts module graph — the production build shipped + * EIGHT ~312 KB copies of the library, and /insights alone downloaded two + * (~92 KB gz of pure duplication on the worst-LCP route). One shared import + * target ⇒ one shared chunk group ⇒ recharts exists exactly once, cached + * across every route. + * + * Extends the v1.16.7 `mood-charts.ts` precedent (which did this for the + * mood trio) to the whole chart surface; that scoped barrel is folded in + * here. + * + * Rules: + * - A new recharts consumer is added HERE and loaded through + * `dynamic(() => import("@/components/charts/chart-runtime").then(...))` + * at its call site — with the call site's own matching skeleton. + * - No component outside this module's static import graph may import + * "recharts" directly. + * - Types stay importable from the component files directly (type-only + * imports are value-free and don't drag the chunk in). + */ + +export { HealthChart } from "./health-chart"; +export { MoodChart } from "./mood-chart"; +export { MedicationComplianceChart } from "./medication-compliance-chart"; +export { ScatterCorrelationChart } from "./scatter-correlation-chart"; +export { HostMetricsChart } from "@/components/admin/host-metrics-chart"; +export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart"; +export { BbtChart } from "@/components/cycle/bbt-chart"; +export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline"; +export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart"; +export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart"; +export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart"; +export { SleepStageStackedBar } from "@/components/insights/sleep-stage-stacked-bar"; +export { LabBiomarkerChart } from "@/components/labs/lab-biomarker-chart"; +export { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; +export { DrugLevelChart } from "@/components/medications/drug-level-chart"; +export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart"; diff --git a/src/components/charts/health-chart-dynamic.tsx b/src/components/charts/health-chart-dynamic.tsx index 06c92e7b2..9a5f37d72 100644 --- a/src/components/charts/health-chart-dynamic.tsx +++ b/src/components/charts/health-chart-dynamic.tsx @@ -14,7 +14,7 @@ import { importWithRetry } from "@/lib/retry-import"; * Pre-fix, eight call sites (`src/app/page.tsx`, five insights * sub-pages, `sleep-duration-chart.tsx`, `trends-row.tsx`, and * `vo2-max-chart-row.tsx`) duplicated the same - * `dynamic(() => import("@/components/charts/health-chart"), { ... })` + * `dynamic(() => import("@/components/charts/chart-runtime"), { ... })` * incantation. The duplication meant the loading skeleton + `ssr: false` * flag had to be re-wired on every caller. * @@ -31,7 +31,7 @@ import { importWithRetry } from "@/lib/retry-import"; */ const HealthChartLazy = dynamic( () => - importWithRetry(() => import("@/components/charts/health-chart")).then( + importWithRetry(() => import("@/components/charts/chart-runtime")).then( (mod) => ({ default: mod.HealthChart }), ), { ssr: false, loading: () => }, @@ -59,7 +59,7 @@ export function HealthChartDynamic( */ const HealthChartLazyMini = dynamic( () => - importWithRetry(() => import("@/components/charts/health-chart")).then( + importWithRetry(() => import("@/components/charts/chart-runtime")).then( (mod) => ({ default: mod.HealthChart }), ), { ssr: false, loading: () => }, diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index 9caf25593..25d6960aa 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -42,7 +42,7 @@ import type { // off the detail page's first-load JS; the skeleton shell holds the layout. const CustomMetricChartLazy = dynamic( () => - importWithRetry(() => import("./custom-metric-chart")).then((mod) => ({ + importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({ default: mod.CustomMetricChart, })), { ssr: false, loading: () => }, diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 0f9bbfe2b..8665fe1e4 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -1,17 +1,18 @@ "use client"; import { useMemo, useState } from "react"; +import dynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { PageHeader } from "@/components/ui/page-header"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { CycleRing } from "./cycle-ring"; -import { BbtChart } from "./bbt-chart"; import { CycleCalendar } from "./cycle-calendar"; import { CycleHistoryChart } from "./cycle-history-chart"; import { LogDaySheet } from "./log-day-sheet"; @@ -54,6 +55,33 @@ function shiftToday(days: number): string { /** The four cycle tabs, in render order. The deep-link value is validated * against this set so a junk `?tab=` falls back to the calendar tab. */ +// The BBT chart is the only recharts consumer on /cycle — load it through +// the shared chart-runtime boundary so the route stops carrying its own +// copy of the recharts module graph. The loading shell mirrors the card +// the chart paints (header row + caption + 210 px chart band inside the +// standard card chrome) so the tab layout holds still while the shared +// chunk streams. +const BbtChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.BbtChart, + })), + { + ssr: false, + loading: () => ( +
+
+ +
+
+ + +
+
+ ), + }, +); + const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const; type CycleTab = (typeof CYCLE_TABS)[number]; diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index 1fddb563d..6e7ab768e 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -47,10 +47,12 @@ vi.mock("next/dynamic", async () => { const mood = await import("@/components/charts/mood-chart"); return { default: (loader: unknown) => { - // Identify which chart the loader targets by its source text. + // Identify which chart the loader targets by its source text. Every + // loader now resolves the shared chart-runtime boundary and picks a + // named export off it, so the export name is the discriminator. const src = String(loader); const Comp = ( - src.includes("mood-chart") ? mood.MoodChart : health.HealthChart + src.includes("MoodChart") ? mood.MoodChart : health.HealthChart ) as React.ComponentType>; return (props: Record) => ; }, diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx index 12dd518ec..f42e00b87 100644 --- a/src/components/insights/correlation-card.tsx +++ b/src/components/insights/correlation-card.tsx @@ -29,7 +29,7 @@ import { AskCoachAction } from "./ask-coach-action"; const ScatterCorrelationChart = dynamic( () => - import("@/components/charts/scatter-correlation-chart").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.ScatterCorrelationChart, })), { diff --git a/src/components/insights/derived/delta-sparkline.tsx b/src/components/insights/derived/delta-sparkline.tsx new file mode 100644 index 000000000..7d9500ce0 --- /dev/null +++ b/src/components/insights/derived/delta-sparkline.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useId } from "react"; +import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; + +/** + * The recharts body of the `` inline sparkline, + * extracted so the tile itself carries no static recharts import. The tile + * loads this through the shared chart-runtime boundary + * (`@/components/charts/chart-runtime`); its fixed 40 px container is owned + * by the tile, so the async gap paints an empty band of identical size — + * zero layout shift, pixel-identical once mounted. + */ +export function DeltaSparkline({ + data, + strokeVar, +}: { + /** `{ i, v }` points, already length-guarded (≥2) by the tile. */ + data: Array<{ i: number; v: number }>; + /** CSS var expression for the sentiment stroke, e.g. `var(--success)`. */ + strokeVar: string; +}) { + // A stable per-instance id for the gradient . Deriving it from a + // label slug collides when two tiles share a localized label (the gradient + // fill on the second tile would not resolve); useId() is collision-free. + const fillId = useId(); + return ( + + + + + + + + + + + + + ); +} diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index 5e249fe08..f6a3467eb 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -1,8 +1,7 @@ "use client"; -import { useId } from "react"; +import dynamic from "next/dynamic"; import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react"; -import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; import { cn } from "@/lib/utils"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { @@ -28,6 +27,19 @@ import { * Fixed footprint so a tile never reflows its neighbours. */ +// The recharts sparkline body loads through the shared chart-runtime +// boundary so this tile (statically imported by the vitals dashboard) +// carries no recharts in its own chunk group. The tile owns the fixed +// 40 px container, so the async gap is an identically-sized empty band — +// no layout shift, no skeleton needed. +const DeltaSparkline = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.DeltaSparkline, + })), + { ssr: false, loading: () => null }, +); + export interface SparklineDeltaTileProps { label: string; /** Latest reading; `null` renders the empty state. */ @@ -88,10 +100,6 @@ export function SparklineDeltaTile({ }: SparklineDeltaTileProps) { const { t } = useTranslations(); const fmt = useFormatters(); - // A stable per-instance id for the gradient . Deriving it from the - // label slug collides when two tiles share a localized label (the gradient - // fill on the second tile would not resolve); useId() is collision-free. - const fillId = useId(); const arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment); const trendColor = sentimentColorClass(arrowSentiment); @@ -190,29 +198,7 @@ export function SparklineDeltaTile({ className="mt-3 h-10 w-full" data-slot="sparkline-delta-tile-spark" > - - - - - - - - - - - - + ) : null} diff --git a/src/components/insights/mood/mood-charts.ts b/src/components/insights/mood/mood-charts.ts deleted file mode 100644 index a1da1f78d..000000000 --- a/src/components/insights/mood/mood-charts.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * v1.16.7 — single async boundary for the three mood mini-charts. - * - * The distribution / weekday / time-of-day charts were each deferred - * through their own `next/dynamic` import, which produced three separate - * chunk groups: the cards popped in one after another as each chunk - * landed (incoherent reveal on the mood insights page), and each group - * carried its own copy of the Recharts module graph. Funnelling all - * three through this one barrel gives them a single shared chunk — they - * arrive (and reveal) together, and Recharts is bundled once for the - * trio. - * - * Consumers keep importing the chart types directly from the component - * files (type-only imports are value-free and don't drag the chunk in). - */ -export { MoodDistributionChart } from "./mood-distribution-chart"; -export { MoodWeekdayChart } from "./mood-weekday-chart"; -export { MoodTimeOfDayChart } from "./mood-time-of-day-chart"; diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index bb3bd5e9c..062f9fa1d 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -25,7 +25,7 @@ import { MoodExplainerIcon } from "./mood-explainer-icon"; const ScatterCorrelationChart = dynamic( () => - import("@/components/charts/scatter-correlation-chart").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.ScatterCorrelationChart, })), { diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 5fe0d9385..19a5156e0 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -36,12 +36,13 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap"; import type { MoodDistributionRow } from "./mood-distribution-chart"; import type { MoodWeekdayRow } from "./mood-weekday-chart"; import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart"; -// v1.16.7 — all three loaders resolve the ONE `./mood-charts` barrel so -// they share a single chunk: the cards reveal together instead of -// popping in chunk-by-chunk, and Recharts ships once for the trio. +// v1.16.7 established the shared-barrel pattern for the trio; all three +// loaders now resolve the app-wide chart-runtime boundary, so they share +// the ONE recharts chunk with every other chart surface: the cards reveal +// together, and recharts ships exactly once for the whole app. const MoodDistributionChart = dynamic( () => - import("./mood-charts").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.MoodDistributionChart, })), { @@ -53,7 +54,7 @@ const MoodDistributionChart = dynamic( ); const MoodWeekdayChart = dynamic( () => - import("./mood-charts").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.MoodWeekdayChart, })), { @@ -65,7 +66,7 @@ const MoodWeekdayChart = dynamic( ); const MoodTimeOfDayChart = dynamic( () => - import("./mood-charts").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.MoodTimeOfDayChart, })), { diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index f114b637a..f777b9daf 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -35,7 +35,7 @@ import { ConfidenceMeter } from "./confidence-meter"; */ const HealthChartLazy = dynamic( () => - importWithRetry(() => import("@/components/charts/health-chart")).then( + importWithRetry(() => import("@/components/charts/chart-runtime")).then( (mod) => ({ default: mod.HealthChart }), ), { ssr: false, loading: () => }, @@ -48,7 +48,7 @@ const HealthChart = (props: ComponentProps) => ( const MoodChartLazy = dynamic( () => - importWithRetry(() => import("@/components/charts/mood-chart")).then( + importWithRetry(() => import("@/components/charts/chart-runtime")).then( (mod) => ({ default: mod.MoodChart }), ), { ssr: false, loading: () => }, diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index aaf123bcf..4c0c412f2 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -25,7 +25,7 @@ import { SleepDurationChart } from "./sleep-duration-chart"; // matches the card the bar paints so the layout stays stable. const SleepStageStackedBarLazy = dynamic( () => - importWithRetry(() => import("./sleep-stage-stacked-bar")).then((mod) => ({ + importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({ default: mod.SleepStageStackedBar, })), { ssr: false, loading: () => }, diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 26a48a6bc..a5f643745 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -58,7 +58,7 @@ import { const MoodChart = dynamic( () => - import("@/components/charts/mood-chart").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.MoodChart, })), { ssr: false, loading: () => }, diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 329bd622b..cb2162230 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -51,7 +51,7 @@ import type { // loading shell matches the in-card chart footprint so the layout is stable. const LabBiomarkerChartLazy = dynamic( () => - importWithRetry(() => import("./lab-biomarker-chart")).then((mod) => ({ + importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({ default: mod.LabBiomarkerChart, })), { ssr: false, loading: () => }, diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 83a7240ff..f9a803ae7 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -89,14 +89,14 @@ import type { ComplianceDisplay } from "@/lib/analytics/compliance"; // shared chart skeleton standing in while the chunk streams. const DrugLevelChart = dynamic( () => - import("@/components/medications/drug-level-chart").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.DrugLevelChart, })), { ssr: false, loading: () => }, ); const DoseStrengthCurve = dynamic( () => - import("@/components/medications/dose-strength-curve").then((mod) => ({ + import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.DoseStrengthCurve, })), { ssr: false, loading: () => }, diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx index 570e4c568..094d572f4 100644 --- a/src/components/mental-health/assessment-history.tsx +++ b/src/components/mental-health/assessment-history.tsx @@ -38,7 +38,7 @@ import type { AssessmentRow, CrisisSet, InstrumentId } from "./types"; // holds the layout. const AssessmentHistoryChartLazy = dynamic( () => - importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({ + importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({ default: mod.AssessmentHistoryChart, })), { ssr: false, loading: () => },