From 85058716f66ab31c18e18d49baa51a3026360509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:02:43 +0200 Subject: [PATCH 01/14] fix(pwa): stop treating network failure on the auth probe as session end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed /api/auth/me now only counts as a session END on an explicit 401/403. Network-level failures (offline relaunch fetch TypeError, the service worker's synthetic 503 offline envelope, abort/timeout) and 5xx carry no session verdict: the hook holds the last-known auth state via a non-sensitive localStorage marker instead of flipping unauthenticated. Pre-fix, an airplane-mode relaunch of the installed PWA redirected to /auth/login and ran clearCachesForSessionEnd(), wiping the SW data/page caches and the IndexedDB query snapshot — the entire offline read path was unreachable in exactly the scenario it exists for. - use-auth: isAuthVerdictUnknown() classification, was-authenticated marker (set on every successful /me, cleared on session end), isAuthUnknown exposed for the shell - auth-shell: the wipe runs only on a real verdict; a non-verdict failure without a prior-session marker redirects without wiping - explicit logout and true 401/403 expiry wipe exactly as before H1 --- src/components/layout/auth-shell.tsx | 38 +++-- .../__tests__/use-auth-offline-gate.test.ts | 144 ++++++++++++++++++ src/hooks/use-auth.ts | 88 ++++++++++- 3 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 src/hooks/__tests__/use-auth-offline-gate.test.ts diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 829a742ce..23fb7b3a7 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -48,7 +48,7 @@ export function AuthShell({ */ demoMode?: boolean; }) { - const { user, isAuthenticated, isLoading } = useAuth(); + const { user, isAuthenticated, isAuthUnknown, isLoading } = useAuth(); const { t } = useTranslations(); const pathname = usePathname(); const router = useRouter(); @@ -129,17 +129,35 @@ export function AuthShell({ useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { - // Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe - // the in-memory query cache (the cross-user guard — the QueryClient - // outlives the navigation on this SPA and the health-data families are - // not user-scoped) plus the persisted IndexedDB + SW caches, so nothing - // leaks to the next account on a shared browser. Logout does the same - // from `useLogout`; this covers the expiry path logout never runs - // through. Best-effort; the redirect proceeds regardless. - clearCachesForSessionEnd(queryClient); + // Session END (expired / invalidated cookie → `/api/auth/me` 401/403). + // Wipe the in-memory query cache (the cross-user guard — the + // QueryClient outlives the navigation on this SPA and the health-data + // families are not user-scoped) plus the persisted IndexedDB + SW + // caches, so nothing leaks to the next account on a shared browser. + // Logout does the same from `useLogout`; this covers the expiry path + // logout never runs through. Best-effort; the redirect proceeds + // regardless. + // + // `isAuthUnknown` = the auth probe failed WITHOUT a session verdict + // (offline relaunch, network blip, 5xx) and no prior-session marker + // exists. Nothing to protect and nothing to render — send the visitor + // to the login gate, but never run the wipe off a non-verdict: the + // wipe on a network failure is exactly what used to destroy the + // offline caches. (With the marker present, `isAuthenticated` holds + // the last-known state and this branch never runs.) + if (!isAuthUnknown) { + clearCachesForSessionEnd(queryClient); + } router.replace("/auth/login"); } - }, [isLoading, isAuthenticated, isPublicPage, router, queryClient]); + }, [ + isLoading, + isAuthenticated, + isAuthUnknown, + isPublicPage, + router, + queryClient, + ]); // Redirect non-admins away from /admin useEffect(() => { diff --git a/src/hooks/__tests__/use-auth-offline-gate.test.ts b/src/hooks/__tests__/use-auth-offline-gate.test.ts new file mode 100644 index 000000000..cb5b54533 --- /dev/null +++ b/src/hooks/__tests__/use-auth-offline-gate.test.ts @@ -0,0 +1,144 @@ +/** + * Offline auth gate — a failed `/api/auth/me` is only a session END when the + * server said so (401/403). + * + * Pre-fix, ANY auth-probe error read as "logged out": an airplane-mode + * relaunch redirected to /auth/login and `clearCachesForSessionEnd()` wiped + * the SW data/page caches and the IndexedDB query snapshot — destroying + * exactly the installed-PWA-opened-offline scenario the offline plumbing + * exists for. These tests pin the classification (`isAuthVerdictUnknown`), + * the last-known-state fallback (`readWasAuthenticated` marker), and the + * marker's session-end lifecycle. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +const clearOfflineCachesForSessionEnd = vi.fn().mockResolvedValue(undefined); +vi.mock("@/lib/pwa/query-persister", () => ({ + clearOfflineCachesForSessionEnd: () => clearOfflineCachesForSessionEnd(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +// Drivable query state so the hook's classification can be exercised for +// success / network-error / 401 without a fetch layer. +interface MockQueryState { + data: unknown; + isError: boolean; + error: unknown; + isLoading: boolean; +} +let queryState: MockQueryState; +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual< + typeof import("@tanstack/react-query") + >("@tanstack/react-query"); + return { + ...actual, + useQuery: () => ({ ...queryState, refetch: vi.fn() }), + useQueryClient: () => ({}), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + }; +}); + +import { ApiError } from "@/lib/api/api-fetch"; +import { + clearCachesForSessionEnd, + isAuthVerdictUnknown, + readWasAuthenticated, + useAuth, +} from "../use-auth"; + +// Node test env has no localStorage — back it with a Map. +const store = new Map(); +beforeEach(() => { + store.clear(); + queryState = { data: undefined, isError: false, error: null, isLoading: false }; + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + clearOfflineCachesForSessionEnd.mockClear(); +}); + +describe("isAuthVerdictUnknown — failure classification", () => { + it("treats a fetch TypeError (offline / DNS) as no verdict", () => { + expect(isAuthVerdictUnknown(new TypeError("Failed to fetch"))).toBe(true); + }); + + it("treats the SW's synthetic 503 offline envelope as no verdict", () => { + expect( + isAuthVerdictUnknown(new ApiError("offline", 503, { offline: true })), + ).toBe(true); + }); + + it("treats an abort/timeout as no verdict", () => { + expect( + isAuthVerdictUnknown(new DOMException("timeout", "TimeoutError")), + ).toBe(true); + }); + + it("treats a 5xx as no verdict — a server hiccup is not a logout", () => { + expect(isAuthVerdictUnknown(new ApiError("boom", 500))).toBe(true); + }); + + it("treats 401 and 403 as the real session-end verdict", () => { + expect(isAuthVerdictUnknown(new ApiError("unauthorized", 401))).toBe(false); + expect(isAuthVerdictUnknown(new ApiError("forbidden", 403))).toBe(false); + }); +}); + +describe("useAuth — last-known state on a non-verdict failure", () => { + it("stays authenticated on a network failure when a prior session marker exists", () => { + store.set("healthlog-was-authenticated", "1"); + queryState.isError = true; + queryState.error = new TypeError("Failed to fetch"); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(true); + expect(auth.isAuthUnknown).toBe(true); + expect(auth.user).toBeNull(); + }); + + it("reads unauthenticated-but-unknown on a network failure without a marker", () => { + queryState.isError = true; + queryState.error = new TypeError("Failed to fetch"); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(false); + // The shell must NOT run the session-end wipe off this state. + expect(auth.isAuthUnknown).toBe(true); + }); + + it("reads a true 401 as session end — wipe and redirect may proceed", () => { + store.set("healthlog-was-authenticated", "1"); + queryState.isError = true; + queryState.error = new ApiError("unauthorized", 401); + + const auth = useAuth(); + expect(auth.isAuthenticated).toBe(false); + expect(auth.isAuthUnknown).toBe(false); + }); +}); + +describe("session-end marker lifecycle", () => { + it("clearCachesForSessionEnd drops the marker so the next cold offline launch lands on the login gate", () => { + store.set("healthlog-was-authenticated", "1"); + expect(readWasAuthenticated()).toBe(true); + + clearCachesForSessionEnd(new QueryClient()); + + expect(readWasAuthenticated()).toBe(false); + expect(clearOfflineCachesForSessionEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 7330c70c1..c4bb806f7 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -9,7 +9,7 @@ import { import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch"; +import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; import type { @@ -149,6 +149,64 @@ export interface AuthUser { moduleAvailability?: Partial>; } +/** + * Non-sensitive "a session existed here" marker (localStorage boolean, no + * identity data). A cold offline launch cannot reach `/api/auth/me`, so the + * auth query errors without ever answering the session question — the marker + * is the last-known state that lets the shell render the cached app chrome + * instead of bouncing to the login gate and wiping the offline caches. Set on + * every successful `/api/auth/me`, cleared on every session END (logout and + * true 401/403). + */ +const WAS_AUTHENTICATED_KEY = "healthlog-was-authenticated"; + +export function readWasAuthenticated(): boolean { + try { + return localStorage.getItem(WAS_AUTHENTICATED_KEY) === "1"; + } catch { + return false; + } +} + +function storeWasAuthenticated(): void { + try { + localStorage.setItem(WAS_AUTHENTICATED_KEY, "1"); + } catch { + /* storage unavailable — the marker is best-effort */ + } +} + +function clearWasAuthenticated(): void { + try { + localStorage.removeItem(WAS_AUTHENTICATED_KEY); + } catch { + /* best effort */ + } +} + +/** + * True when an `/api/auth/me` failure carries NO verdict on the session — + * a network-level failure (fetch `TypeError` offline, abort/timeout), the + * service worker's synthetic 503 offline envelope (`meta.offline`), the + * browser reporting itself offline, or a server-side 5xx. None of these mean + * "logged out"; only an explicit 401/403 does. Treating them as session end + * used to redirect an offline relaunch to /auth/login and wipe the offline + * caches — destroying exactly the installed-PWA-opened-offline scenario the + * SW + query persister exist for. + */ +export function isAuthVerdictUnknown(error: unknown): boolean { + if (error instanceof ApiError) { + // 5xx (incl. the SW's synthetic 503 with `meta.offline: true`) and any + // other non-auth status: the server never ruled on the session. Only an + // explicit 401/403 is a verdict. + return error.status !== 401 && error.status !== 403; + } + // Everything below the HTTP layer is transport-level by construction: + // fetch `TypeError` (offline / DNS / connection refused), `AbortError` / + // `TimeoutError` DOMException from the 15 s default signal. + return true; +} + async function fetchMe(): Promise { // v1.16.4 — routed through the typed wrapper; a non-OK /me (401) // throws `ApiError`, which `useAuth` treats as "not authenticated" @@ -183,6 +241,8 @@ async function fetchMe(): Promise { ? data.dateFormat : "AUTO"; storeDateFormat(dateFormat); + // A confirmed session: refresh the non-sensitive offline-relaunch marker. + storeWasAuthenticated(); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, @@ -227,10 +287,30 @@ export function useAuth() { staleTime: 5 * 60 * 1000, }); + // v1.27.x offline fix — classify a failed `/api/auth/me` before acting on + // it. Only an explicit 401/403 means "session ended"; a network-level + // failure (offline relaunch, SW 503 offline envelope, timeout) or a 5xx + // carries no verdict, so the hook holds the LAST-KNOWN auth state (the + // localStorage marker) instead of flipping to unauthenticated. Pre-fix, an + // airplane-mode relaunch read as a logout: the shell redirected to + // /auth/login and wiped every offline cache (data 7→0 entries, pages 1→0 + // in the audit measurement), making the whole offline story unreachable. + const verdictUnknown = query.isError && isAuthVerdictUnknown(query.error); + const isAuthenticated = + !!query.data || (verdictUnknown && readWasAuthenticated()); + return { user: query.data ?? null, isLoading: query.isLoading, - isAuthenticated: !!query.data, + isAuthenticated, + /** + * True while the session question is genuinely unanswered — the auth + * probe failed at the transport level (or 5xx) and no cached `/me` + * payload exists. The shell uses it to suppress the session-end cache + * wipe: a wipe without a 401/403 verdict would destroy the offline + * caches on every network blip. + */ + isAuthUnknown: !query.data && verdictUnknown, error: query.error, refetch: query.refetch, }; @@ -257,6 +337,10 @@ export function useAuth() { */ export function clearCachesForSessionEnd(queryClient: QueryClient): void { queryClient.clear(); + // The offline-relaunch marker is session state too: after a real session + // end the next cold offline launch must land on the login gate, not the + // cached shell. + clearWasAuthenticated(); void clearOfflineCachesForSessionEnd(); } From f0b16c1a3956be07a81f213eb628dd3641c5b6e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:03:15 +0200 Subject: [PATCH 02/14] fix(charts): light-theme chart text uses semantic tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recharts tick/label/tooltip colours in the scatter-correlation chart, the three mood charts, and the admin host-metrics chart referenced the raw Dracula primitives (--dracula-fg / --dracula-comment), which the light theme never overrides — near-white text on a near-white card (~1.0:1) and an AA-failing comment gray (~2.5:1). Swap all 23 sites to --foreground / --muted-foreground. Dark theme is pixel-identical: the semantic tokens alias the same Dracula values there (#f8f8f2 / #9aa3b3); light resolves to the AA-checked soft-neutral values. H5 --- src/components/admin/host-metrics-chart.tsx | 4 ++-- .../charts/scatter-correlation-chart.tsx | 14 +++++++------- .../insights/mood/mood-distribution-chart.tsx | 8 ++++---- .../insights/mood/mood-time-of-day-chart.tsx | 10 +++++----- .../insights/mood/mood-weekday-chart.tsx | 10 +++++----- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 0ef11da16..784120dcc 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -267,8 +267,8 @@ export function HostMetricsChart() { borderRadius: "0.5rem", fontSize: "0.75rem", }} - labelStyle={{ color: "var(--dracula-fg)" }} - itemStyle={{ color: "var(--dracula-fg)" }} + labelStyle={{ color: "var(--foreground)" }} + itemStyle={{ color: "var(--foreground)" }} formatter={(value, name) => { if (typeof value !== "number") return [String(value), name]; if (name === t("admin.hostMetrics.load1")) { diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx index a75d3d7ac..033a8ba11 100644 --- a/src/components/charts/scatter-correlation-chart.tsx +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -128,12 +128,12 @@ export function ScatterCorrelationChart>({ type="number" name={xAxis.name} unit={xAxis.unit} - tick={{ fontSize: 12, fill: "var(--dracula-fg)" }} + tick={{ fontSize: 12, fill: "var(--foreground)" }} tickMargin={8} height={52} interval="preserveStartEnd" padding={{ left: 8, right: 8 }} - stroke="var(--dracula-comment)" + stroke="var(--muted-foreground)" domain={xAxis.domain} ticks={xAxis.ticks} tickFormatter={xAxis.tickFormatter} @@ -143,7 +143,7 @@ export function ScatterCorrelationChart>({ value: xAxis.label, position: "bottom", fontSize: 12, - fill: "var(--dracula-comment)", + fill: "var(--muted-foreground)", } : undefined } @@ -153,8 +153,8 @@ export function ScatterCorrelationChart>({ type="number" name={yAxis.name} unit={yAxis.unit} - tick={{ fontSize: 12, fill: "var(--dracula-fg)" }} - stroke="var(--dracula-comment)" + tick={{ fontSize: 12, fill: "var(--foreground)" }} + stroke="var(--muted-foreground)" domain={yAxis.domain} ticks={yAxis.ticks} tickFormatter={yAxis.tickFormatter} @@ -166,8 +166,8 @@ export function ScatterCorrelationChart>({ borderRadius: "0.5rem", fontSize: "0.75rem", }} - itemStyle={{ color: "var(--dracula-fg)" }} - labelStyle={{ color: "var(--dracula-fg)" }} + itemStyle={{ color: "var(--foreground)" }} + labelStyle={{ color: "var(--foreground)" }} formatter={tooltipFormatter} /> diff --git a/src/components/insights/mood/mood-distribution-chart.tsx b/src/components/insights/mood/mood-distribution-chart.tsx index cd11da3cb..6074baf80 100644 --- a/src/components/insights/mood/mood-distribution-chart.tsx +++ b/src/components/insights/mood/mood-distribution-chart.tsx @@ -71,7 +71,7 @@ export function MoodDistributionChart({ // unchanged. The mood-hue bar Cells stay on `--dracula-*` — only the // axis text legibility changes here. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} - stroke="var(--dracula-comment)" + stroke="var(--muted-foreground)" interval={0} /> [ `${value ?? 0} ${t("insights.mood.distributionDaysUnit")}`, diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index fd3a02ec7..481f25aa4 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -90,19 +90,19 @@ export function MoodTimeOfDayChart({ @@ -114,8 +114,8 @@ export function MoodTimeOfDayChart({ borderRadius: "0.5rem", fontSize: "0.75rem", }} - itemStyle={{ color: "var(--dracula-fg)" }} - labelStyle={{ color: "var(--dracula-fg)" }} + itemStyle={{ color: "var(--foreground)" }} + labelStyle={{ color: "var(--foreground)" }} formatter={(_value, _name, item) => { const payload = item?.payload as { avgScore: number | null; count: number } | undefined; diff --git a/src/components/insights/mood/mood-weekday-chart.tsx b/src/components/insights/mood/mood-weekday-chart.tsx index f66233759..3f72af6b4 100644 --- a/src/components/insights/mood/mood-weekday-chart.tsx +++ b/src/components/insights/mood/mood-weekday-chart.tsx @@ -90,7 +90,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { // to the shared `--muted-foreground` axis-tick token. Bar mood // hues stay `--dracula-*`; dark mode is unchanged. tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} - stroke="var(--dracula-comment)" + stroke="var(--muted-foreground)" interval={0} /> {/* v1.15.14 — calmer target guide: a muted comment-grey dashed @@ -109,7 +109,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { reference rather than competing with the bars. */} @@ -121,8 +121,8 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) { borderRadius: "0.5rem", fontSize: "0.75rem", }} - itemStyle={{ color: "var(--dracula-fg)" }} - labelStyle={{ color: "var(--dracula-fg)" }} + itemStyle={{ color: "var(--foreground)" }} + labelStyle={{ color: "var(--foreground)" }} formatter={(_value, _name, item) => { const payload = item?.payload as { avgScore: number | null; count: number } | undefined; From 7db97eabca3f58fa3f5039745cd5c60ef636af7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:06:36 +0200 Subject: [PATCH 03/14] fix(insights): keep mood correlation captions inside their tiles on phone widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The correlation-card header's right slot kept its icon-era shrink-0 after v1.22 replaced the info glyph with an inline caption — the unshrinkable badge+caption row escaped the card's right edge by 14–39 px at 390/360 across all five cards and squeezed the title to a 2-line 36–42 % sliver. The caption now stacks under the title below sm (only the compact strength badge stays pinned right); on >=sm the inline layout is unchanged. Same surface: the discovered-relations stat caption ('{n} gepaarte Tage · r · q') stacks below its finding sentence on phone widths instead of wrapping to a 4-line sliver at ~25 % of the row, and the subsection caveat caption stacks under the subheading instead of wrapping 9 lines beside it. The phone-width density guard now sweeps /insights/mood too, with mocked correlation + discovery payloads pinning the populated worst case (five cards, three mood pairs) so the guard cannot pass vacuously. H6 --- e2e/mobile-density.spec.ts | 19 ++- e2e/utils/mock-mood-insights.ts | 119 ++++++++++++++++++ .../insights/mood/mood-correlation-cards.tsx | 21 +++- .../mood/mood-discovered-relations.tsx | 14 ++- 4 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 e2e/utils/mock-mood-insights.ts diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index 5478a0919..9a56f140c 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -7,6 +7,7 @@ import { mockDashboardSnapshot, POPULATED_SUMMARIES, } from "./utils/mock-dashboard-snapshot"; +import { mockMoodInsights } from "./utils/mock-mood-insights"; /** * Phone-width density guard (390×844 iPhone class, 360×780 small @@ -36,7 +37,7 @@ const VIEWPORTS = [ { width: 360, height: 780 }, ] as const; -const ROUTES = ["/", "/insights"] as const; +const ROUTES = ["/", "/insights", "/insights/mood"] as const; interface TileEscape { tile: string; @@ -116,6 +117,9 @@ test.describe("phone-width density guard", () => { briefing: LONG_HEADLINE_BRIEFING, scoreRings: MOCK_SCORE_RINGS, }); + if (route === "/insights/mood") { + await mockMoodInsights(page); + } await page.goto(route, { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle"); @@ -130,6 +134,19 @@ test.describe("phone-width density guard", () => { ).toHaveCount(3); } + if (route === "/insights/mood") { + // Non-vacuous: the five populated correlation cards and the + // discovered-relations list — the audit's only user-facing + // tile-escape cluster — must actually render before the guard + // sweeps them. + await expect( + page.locator('[data-slot="mood-correlation-card"]'), + ).toHaveCount(5); + await expect( + page.locator('[data-slot="mood-discovered-relations"]'), + ).toBeVisible(); + } + const dims = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, diff --git a/e2e/utils/mock-mood-insights.ts b/e2e/utils/mock-mood-insights.ts new file mode 100644 index 000000000..e8dc21e8e --- /dev/null +++ b/e2e/utils/mock-mood-insights.ts @@ -0,0 +1,119 @@ +import type { Page } from "@playwright/test"; + +/** + * Route mocks for the /insights/mood density guard. + * + * The correlation cards and the discovered-relations list only render with + * data, and the seeded e2e account has none — an unmocked run would pass the + * tile-escape guard vacuously against empty states. These payloads pin the + * measured worst case: five populated correlation cards whose header caption + * ("269 gepaarte Tage · r = 0.37") used to escape the card edge by 14–39 px + * at 390/360, plus mood-involving discovered pairs whose stat caption wrapped + * to a 4-line sliver beside the finding sentence. + */ + +function correlation(r: number, strength: string, n: number) { + return { + result: { r, strength, n }, + n, + points: Array.from({ length: 24 }, (_, i) => ({ + x: 1 + (i % 5), + y: 40 + ((i * 7) % 55), + })), + }; +} + +const MOOD_INSIGHTS_PAYLOAD = { + summary: { totalEntries: 269, inTargetPct: 62 }, + heatmap: { + windowDays: 90, + cells: [ + { date: "2026-07-01", score: 4, samples: 2 }, + { date: "2026-07-02", score: 3, samples: 1 }, + ], + }, + distribution: [], + weekday: [], + timeOfDay: { reliable: false }, + stability: null, + tags: [], + structuredTags: [], + tagInfluence: { flat: [], structured: [] }, + betterDays: [], + tagMetricCrosstab: [], + factorCrosstab: [], + narratives: [], + correlations: { + sleep: correlation(0.37, "schwach", 269), + steps: correlation(0.52, "moderat", 231), + pulse: correlation(-0.44, "moderat", 258), + weight: correlation(0.21, "schwach", 244), + bloodPressureSystolic: correlation(-0.73, "stark", 212), + }, +}; + +const CORRELATION_DISCOVERY_PAYLOAD = { + discovered: [ + { + behaviour: "SLEEP_DURATION", + outcome: "MOOD", + n: 269, + r: 0.37, + pValue: 0.001, + qValue: 0.012, + interpretation: "", + lagDays: 1, + }, + { + behaviour: "MOOD", + outcome: "RESTING_HEART_RATE", + n: 231, + r: -0.41, + pValue: 0.002, + qValue: 0.031, + interpretation: "", + lagDays: 1, + }, + { + behaviour: "TIME_IN_DAYLIGHT", + outcome: "MOOD", + n: 187, + r: 0.29, + pValue: 0.004, + qValue: 0.048, + interpretation: "", + lagDays: 0, + }, + ], + pairsTested: 42, + fdrQ: 0.1, + minPairs: 20, +}; + +/** Serve populated mood-insights + discovery payloads for the density guard. */ +export async function mockMoodInsights(page: Page): Promise { + await page.route(/\/api\/insights\/comprehensive(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { moodSummary: { count: 269 } }, + error: null, + }), + }); + }); + await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: MOOD_INSIGHTS_PAYLOAD, error: null }), + }); + }); + await page.route(/\/api\/insights\/correlations(\?|$)/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: CORRELATION_DISCOVERY_PAYLOAD, error: null }), + }); + }); +} diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index 4efb3f351..bb3bd5e9c 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -105,7 +105,13 @@ function MoodCorrelationCard({ title={t(TITLE_KEY[kind])} right={ hasResult && data.result ? ( -
+ // No `shrink-0` here: the slot kept its icon-era rigidity after + // v1.22 swapped the info glyph for an inline caption, and the + // unshrinkable badge+caption row escaped the card's right edge + // by 14–39 px on phone viewports. The caption itself moves + // below the title on {t(STRENGTH_KEY[data.result.strength])} @@ -113,6 +119,7 @@ function MoodCorrelationCard({ scatter as a half-empty row. Move it into an explainer icon in the header so the card footprint stays tight. */} + {/* + ) : null} {hasResult && data.result ? ( diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index b56b70d1e..7440d8c36 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -90,7 +90,10 @@ export function MoodDiscoveredRelations({ // observational disclaimer fold into the one tooltip) so the surface stops // spending a muted paragraph on a footnote.
-
+ {/* {t("insights.mood.discovery.subheading")} {/* The {n} paired days · r · q numeric detail rides an explainer - icon on the same line as the finding so the list stays one - row per pair. The finding itself reads in the standard - foreground colour. */} - + caption beside the finding on ≥sm; on phone widths it stacks + below the sentence instead of squeezing to a 4-line sliver + at ~25 % of the row. The finding itself reads in the + standard foreground colour. */} + {t(sentenceKey, { factor: factorLabel })} Date: Tue, 7 Jul 2026 12:07:23 +0200 Subject: [PATCH 04/14] fix(a11y): extend the row-checkbox touch target to 32 px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 16×16 checkbox on the measurement/mood/values row selectors sits below the WCAG 2.5.8 24 px minimum. Reuse the switch's ::before hit-area extension: the visual box stays 16 px, the pointer target grows to 32×32 without any layout shift. --- src/components/ui/checkbox.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 3fa35c4c5..9ff06273e 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -25,7 +25,14 @@ function Checkbox({ Date: Tue, 7 Jul 2026 12:20:23 +0200 Subject: [PATCH 05/14] perf(charts): one shared async boundary for the whole recharts surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every next/dynamic chart import used to point at its own module, so Turbopack emitted a separate chunk group per boundary — the production build carried EIGHT ~312 KB copies of the recharts module graph, and /insights (the worst-LCP route) downloaded two of them (~92 KB gz of pure duplication, one fully unused at load). All chart loaders now resolve the single chart-runtime barrel (src/components/charts/chart-runtime.ts), which owns the only path to recharts: one shared chunk group, recharts exists exactly once in the build (verified: 8 -> 1 recharts-fingerprinted chunks), cached across routes. Charts stay recharts and stay pixel-identical; every call site keeps its own matching skeleton and ssr:false flag. Riders of the same rule: - the mood-trio barrel (mood-charts.ts, the v1.16.7 precedent this generalises) folds into the runtime and is removed - the vitals sparkline body extracts to delta-sparkline.tsx and loads through the runtime; the tile keeps the fixed 40 px band, so the async gap is an identically-sized empty box - /cycle's BBT chart (the last static recharts import on a live route) defers through the runtime with a card-matching skeleton H3 --- src/app/insights/mood/page.tsx | 2 +- src/app/page.tsx | 4 +- .../admin/system-status-section.tsx | 2 +- src/components/charts/chart-runtime.ts | 43 ++++++++++++++++ .../charts/health-chart-dynamic.tsx | 6 +-- .../custom-metrics/custom-metric-detail.tsx | 2 +- src/components/cycle/cycle-view.tsx | 30 +++++++++++- .../__tests__/recommendation-card.test.tsx | 6 ++- src/components/insights/correlation-card.tsx | 2 +- .../insights/derived/delta-sparkline.tsx | 49 +++++++++++++++++++ .../insights/derived/sparkline-delta-tile.tsx | 44 ++++++----------- src/components/insights/mood/mood-charts.ts | 18 ------- .../insights/mood/mood-correlation-cards.tsx | 2 +- .../insights/mood/mood-insights-sections.tsx | 13 ++--- .../insights/recommendation-card.tsx | 4 +- src/components/insights/sleep-overview.tsx | 2 +- src/components/insights/trends-row.tsx | 2 +- src/components/labs/lab-biomarker-detail.tsx | 2 +- .../detail/medication-detail-tabs.tsx | 4 +- .../mental-health/assessment-history.tsx | 2 +- 20 files changed, 165 insertions(+), 74 deletions(-) create mode 100644 src/components/charts/chart-runtime.ts create mode 100644 src/components/insights/derived/delta-sparkline.tsx delete mode 100644 src/components/insights/mood/mood-charts.ts 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: () => }, From e24db8a8897021fdfaf3300d6189d18e6c37d80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:34:26 +0200 Subject: [PATCH 06/14] perf(i18n): stop shipping two full catalogs on every page load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent every-load costs, one per layer: 1. The static EN import in load-locale.ts put the full en.json catalog (334 KB raw / ~106 KB gz) into the client JS of EVERY route, for every user in every language. No catalog is statically imported by client code anymore: the EN fallback floor of the t() chain loads lazily on the first actual key miss (the locale-integrity guard keeps every locale key-complete, so the miss path is drift-only), and the labs biomarker canonical-name lookup fetches EN on demand. 2. The root layout serialized the ENTIRE active-locale catalog into the RSC flight payload of every document (392 KB of a 505 KB dashboard HTML, re-downloaded on every hard load, uncacheable). The handoff is now a versioned, immutable-cacheable boot script (/i18n/?v=, new route handler) that assigns self.__HL_I18N before the hydration scripts run, plus a server-side cache seed in load-locale.ts behind a typeof-window branch that Turbopack compiles out of the browser bundle (verified: zero catalog bytes in any client chunk, zero route manifests referencing one). SSR text stays correct and the first client render holds the same bundle the server rendered with — no EN flash, no hydration drift. The boot script is public (proxy allowlist: the login page needs it pre-auth; the body is the same catalog JSON that ships in the public repo) and cache-first in the service worker, so an offline relaunch finds it next to the cached shell. Measured (client-reference manifests, gzip): route JS baseline / 391->287 KB gz, /insights 377->273, /measurements 379->275, /insights/mood 394->290 — the ~104 KB gz EN catalog left every route. H2 --- public/sw.js | 11 +++ src/app/i18n/[locale]/route.ts | 51 ++++++++++ src/app/layout.tsx | 30 +++--- src/components/labs/lab-biomarker-detail.tsx | 43 ++++++--- src/components/providers.tsx | 8 +- src/lib/i18n/context.tsx | 49 ++++++++-- src/lib/i18n/load-locale.ts | 97 +++++++++++++++----- src/proxy.ts | 4 + 8 files changed, 232 insertions(+), 61 deletions(-) create mode 100644 src/app/i18n/[locale]/route.ts diff --git a/public/sw.js b/public/sw.js index 8c8141415..c1de63368 100644 --- a/public/sw.js +++ b/public/sw.js @@ -168,6 +168,17 @@ self.addEventListener("fetch", (event) => { return; } + // Locale-catalog boot script — cache-first. The URL is versioned + // (`/i18n/?v=`) and served immutable, so cache-first is + // correct and keeps the catalog available on an offline relaunch (the + // cached shell HTML references the same versioned URL it was rendered + // with). Carries no user data — same public strings as the repository's + // messages/*.json. + if (url.pathname.startsWith("/i18n/")) { + event.respondWith(cacheFirst(request, STATIC_CACHE)); + return; + } + // Static files (fonts, icons, images) — cache-first with long TTL if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) { event.respondWith(cacheFirst(request, STATIC_CACHE)); diff --git a/src/app/i18n/[locale]/route.ts b/src/app/i18n/[locale]/route.ts new file mode 100644 index 000000000..244829fcb --- /dev/null +++ b/src/app/i18n/[locale]/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { allMessages } from "@/lib/i18n/shared-resolve"; + +/** + * Locale-catalog boot script. + * + * The root layout emits `