From 6f8a342edf87d02ecef3e02912c20f5cb6762967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 15:11:40 +0100 Subject: [PATCH] fix(insights): offer no metric pill inside a switched-to record Inside a managed child's record, or as a delegate with manage access, the tab strip showed Weight, Pulse, Blood pressure and the other metric pills, and every one of them opened an error. The strip now reads the record's own presence answer, but the metric pages read /api/analytics, which serves only the signed-in account's own record and refuses a switched session. The strip again shows those pills only in the account's own record, as before; mood, medication, workout and ECG pills keep following the presence answer. --- .../insights-layout-shell-reads.test.tsx | 55 +++++++++++++++++-- .../insights/insights-layout-shell.tsx | 7 ++- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx index d50c4da31..8de111cf5 100644 --- a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx +++ b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx @@ -13,12 +13,25 @@ import { renderToStaticMarkup } from "react-dom/server"; */ const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = []; -const stripProps: Array<{ onRegenerate?: unknown }> = []; +const stripProps: Array<{ + onRegenerate?: unknown; + availability?: { summaries?: unknown; hasMood?: boolean }; +}> = []; +/** What the presence read answers, and the record the session is inside. */ +const answer: { presence: unknown; active: unknown } = { + presence: undefined, + active: null, +}; vi.mock("@tanstack/react-query", () => ({ useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => { queries.push(options); - return { data: undefined, isLoading: false, isError: false }; + const presence = options.queryKey.join("/") === "insights/presence"; + return { + data: presence ? answer.presence : undefined, + isLoading: false, + isError: false, + }; }, useMutation: () => ({ mutate: vi.fn(), isPending: false, data: undefined }), useQueryClient: () => ({ @@ -26,7 +39,10 @@ vi.mock("@tanstack/react-query", () => ({ }), })); vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }), + useAuth: () => ({ + isAuthenticated: true, + user: { modules: {}, accountAccess: { active: answer.active } }, + }), })); vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); vi.mock("@/hooks/use-ai-capability", () => ({ @@ -41,7 +57,7 @@ vi.mock("@/hooks/use-insights-layout", () => ({ }, })); vi.mock("@/components/insights/insights-tab-strip", () => ({ - InsightsTabStrip: (props: { onRegenerate?: unknown }) => { + InsightsTabStrip: (props: (typeof stripProps)[number]) => { stripProps.push(props); return null; }, @@ -50,7 +66,7 @@ vi.mock("@/components/insights/insights-tab-strip", () => ({ import { queryKeys } from "@/lib/query-keys"; import { InsightsLayoutShell } from "../insights-layout-shell"; -describe("InsightsLayoutShell — what it reads on entry", () => { +describe("InsightsLayoutShell: what it reads on entry", () => { it("keeps the regenerate button and sends no briefing read", () => { renderToStaticMarkup( @@ -82,4 +98,33 @@ describe("InsightsLayoutShell — what it reads on entry", () => { expect(sent).toEqual([JSON.stringify(queryKeys.insightsPresence())]); expect(layoutReads).toEqual([true]); }); + + it("offers no metric pill inside a switched-to record", () => { + // The metric pages read `/api/analytics`, which serves only the caller's + // own record; the mood and medication pills read their own routes. + answer.presence = { + metrics: { WEIGHT: { count: 12 } }, + hasMood: true, + hasMedication: false, + hasWorkouts: false, + hasEcgRecordings: false, + }; + const render = (active: unknown) => { + answer.active = active; + stripProps.length = 0; + renderToStaticMarkup( + +
+ , + ); + return stripProps[0]?.availability; + }; + + expect(render(null)?.summaries).toEqual({ WEIGHT: { count: 12 } }); + const switched = render({ accountId: "record-2" }); + expect(switched?.summaries).toBeUndefined(); + expect(switched?.hasMood).toBe(true); + answer.presence = undefined; + answer.active = null; + }); }); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 4e0481c73..36f08a00b 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -72,19 +72,22 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { // on a first row as well left that page unreachable before data existed. const hasNutrients = user?.modules?.nutrients === true; const hasEcgRecordings = presence?.hasEcgRecordings === true; + // Inside a switched-to record the metric pages read `/api/analytics`, which + // serves only the caller's own record, so their pills would open an error. + const switched = user?.accountAccess?.active != null; // Memoised so an unchanged answer keeps the same reference: the strip is // wrapped in `React.memo` and reruns `buildTabs` on a new object. const availability: InsightInputs | undefined = useMemo(() => { if (!isAuthenticated) return undefined; return { - summaries: presence?.metrics, + summaries: switched ? undefined : presence?.metrics, hasMood: presence?.hasMood === true, hasMedication: presence?.hasMedication === true, hasWorkouts: presence?.hasWorkouts === true, hasNutrients, }; - }, [isAuthenticated, presence, hasNutrients]); + }, [isAuthenticated, presence, hasNutrients, switched]); // v1.15.14 — couple the tab strip to the saved insights layout. // A sub-page pill now shows iff its metric has data AND its slug is