From d286220bed79560679ec2dcfc4b564f9047f94cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 01:43:37 +0200 Subject: [PATCH] perf(charts): wire chart-skeleton loading state across dynamic imports --- src/app/insights/blutdruck/page.tsx | 3 +- src/app/insights/bmi/page.tsx | 3 +- src/app/insights/gewicht/page.tsx | 3 +- src/app/insights/puls/page.tsx | 3 +- src/app/insights/stimmung/page.tsx | 3 +- src/app/page.tsx | 7 ++- src/components/charts/chart-skeleton.tsx | 60 +++++++++++++++++++ .../insights/sleep-duration-chart.tsx | 3 +- 8 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 src/components/charts/chart-skeleton.tsx diff --git a/src/app/insights/blutdruck/page.tsx b/src/app/insights/blutdruck/page.tsx index bfde157b7..4bfc3ed77 100644 --- a/src/app/insights/blutdruck/page.tsx +++ b/src/app/insights/blutdruck/page.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -42,7 +43,7 @@ const HealthChart = dynamic( import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); export default function InsightsBlutdruckPage() { diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 5541c921e..40723a4a7 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -35,7 +36,7 @@ const HealthChart = dynamic( import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); const BMI_BANDS = [ diff --git a/src/app/insights/gewicht/page.tsx b/src/app/insights/gewicht/page.tsx index 752f63448..a5ace20a1 100644 --- a/src/app/insights/gewicht/page.tsx +++ b/src/app/insights/gewicht/page.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -36,7 +37,7 @@ const HealthChart = dynamic( import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); export default function InsightsGewichtPage() { diff --git a/src/app/insights/puls/page.tsx b/src/app/insights/puls/page.tsx index 8c4e7633c..060544783 100644 --- a/src/app/insights/puls/page.tsx +++ b/src/app/insights/puls/page.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -37,7 +38,7 @@ const HealthChart = dynamic( import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); interface AnalyticsData { diff --git a/src/app/insights/stimmung/page.tsx b/src/app/insights/stimmung/page.tsx index 09cbf2c95..cc6623e1f 100644 --- a/src/app/insights/stimmung/page.tsx +++ b/src/app/insights/stimmung/page.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -30,7 +31,7 @@ const MoodChart = dynamic( import("@/components/charts/mood-chart").then((mod) => ({ default: mod.MoodChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); interface ComprehensiveMoodData { diff --git a/src/app/page.tsx b/src/app/page.tsx index f825444c4..d4d9c2d1a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -27,6 +27,7 @@ import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends"; import dynamic from "next/dynamic"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { DropdownMenu, @@ -48,21 +49,21 @@ const HealthChart = dynamic( import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); const MoodChart = dynamic( () => import("@/components/charts/mood-chart").then((mod) => ({ default: mod.MoodChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); const MedicationComplianceChart = dynamic( () => import("@/components/charts/medication-compliance-chart").then((mod) => ({ default: mod.MedicationComplianceChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx new file mode 100644 index 000000000..2ce52dc83 --- /dev/null +++ b/src/components/charts/chart-skeleton.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +/** + * v1.4.28 R3d (R1.2 H4) — layout-stable loading shell for every + * `next/dynamic` chart import on the dashboard and the insights + * sub-pages. + * + * The chart components (``, ``, …) live behind + * `dynamic(() => import("…"), { ssr: false })`. Pre-fix, the loading + * slot was empty: the page hydrated, the Recharts chunk fetched, and + * the layout collapsed/expanded as the chart arrived. On a cold visit + * over a slow network the gap was visible for several seconds (R1.2 + * H4). + * + * This skeleton mirrors the same container that `health-chart.tsx` + * paints (rounded card, border, `p-4 md:p-6`) plus a faux header row + * (title + range tabs) and a chart band sized to the CSS variable the + * chart itself reads (`--chart-height` → 240 px mobile / 280 px md+). + * The cumulative box matches the loaded chart's height to within a + * pixel so the page does not jump on hydration. + * + * `prefers-reduced-motion` is honoured automatically — the `` + * primitive's `animate-pulse` is suppressed via Tailwind's + * `motion-reduce:animate-none` modifier. + */ +export function ChartSkeleton({ className }: { className?: string }) { + return ( +
+ Loading chart… + + {/* Header row — title + range tabs match the real chart's + `mb-4 flex flex-col gap-2 sm:flex-row` chrome. */} +
+ +
+ + + + +
+
+ + {/* Chart band — mirrors the same `h-[var(--chart-height,…)]` + contract `health-chart.tsx` paints at line 1087-1089. */} + +
+ ); +} diff --git a/src/components/insights/sleep-duration-chart.tsx b/src/components/insights/sleep-duration-chart.tsx index 6922e7b85..5e5e31095 100644 --- a/src/components/insights/sleep-duration-chart.tsx +++ b/src/components/insights/sleep-duration-chart.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { useTranslations } from "@/lib/i18n/context"; import type { ComparisonBaseline } from "@/lib/dashboard-layout"; @@ -26,7 +27,7 @@ const HealthChart = dynamic( import("@/components/charts/health-chart").then((mod) => ({ default: mod.HealthChart, })), - { ssr: false }, + { ssr: false, loading: () => }, ); export interface SleepDurationChartProps {