From cf678f6ec4c310c5a045463b02b8214c200273b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 2 Oct 2026 21:38:06 +0200 Subject: [PATCH] fix(dashboard): gate every add-menu entry on its module, as the capture picker does The dashboard add menu asked only whether the record's section was writable, so a switched-off mood or medications module still offered its entry here while the bottom bar's picker hid it. The menu now renders from the picker's own visibleCaptureKinds, so the two cannot disagree. Refs #1085 --- .../__tests__/dashboard-header.test.tsx | 63 +++++++++++++++++++ src/components/dashboard/dashboard-header.tsx | 34 ++++++---- src/lib/workouts/manual-entry.ts | 5 +- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/components/dashboard/__tests__/dashboard-header.test.tsx b/src/components/dashboard/__tests__/dashboard-header.test.tsx index 5ac00b99d..df1e6b7a6 100644 --- a/src/components/dashboard/__tests__/dashboard-header.test.tsx +++ b/src/components/dashboard/__tests__/dashboard-header.test.tsx @@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { DashboardHeader } from "../dashboard-header"; +import { + CAPTURE_KIND_ORDER, + visibleCaptureKinds, +} from "@/components/layout/capture-picker"; function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") { return renderToStaticMarkup( @@ -117,3 +121,62 @@ describe(" — Log workout", () => { expect(html).toContain("Log measurement"); }); }); + +describe(" — every entry follows its module, as the capture picker does", () => { + beforeEach(() => { + modulesRef.value = undefined; + }); + + it("withholds Log mood when the mood module is off", () => { + modulesRef.value = { mood: false }; + const html = renderSSR( + undefined} />, + "en", + ); + expect(html).not.toContain("Log mood"); + expect(html).toContain("Log medication intake"); + }); + + it("withholds Log medication intake when the medications module is off", () => { + modulesRef.value = { medications: false }; + const html = renderSSR( + undefined} />, + "en", + ); + expect(html).not.toContain("Log medication intake"); + expect(html).toContain("Log mood"); + }); + + it("offers exactly what the capture picker offers for the same modules", () => { + for (const modules of [ + undefined, + { mood: false }, + { medications: false }, + { workouts: false }, + { mood: false, medications: false, workouts: false }, + ]) { + modulesRef.value = modules; + const html = renderSSR( + undefined} />, + "en", + ); + const picker = visibleCaptureKinds( + { inSharedRecord: false, canWriteDomain: () => true }, + CAPTURE_KIND_ORDER, + modules, + ); + const label = { + measurement: "Log measurement", + medication: "Log medication intake", + mood: "Log mood", + workout: "Log workout", + } as const; + for (const kind of CAPTURE_KIND_ORDER) { + expect( + html.includes(label[kind]), + `${kind} ${JSON.stringify(modules)}`, + ).toBe(picker.includes(kind)); + } + } + }); +}); diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index cb2842708..db6ad7cc0 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -26,7 +26,10 @@ import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { hourInTz } from "@/lib/tz/format"; -import { canLogWorkout } from "@/lib/workouts/manual-entry"; +import { + CAPTURE_KIND_ORDER, + visibleCaptureKinds, +} from "@/components/layout/capture-picker"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -37,23 +40,30 @@ export function DashboardHeader({ const { t } = useTranslations(); const { user } = useAuth(); const mounted = useMounted(); - // The quick-add offers three kinds, and each one is asked about its own + // The quick-add offers four kinds, and each one is asked about its own // section: a reading under `measurements`, a dose under `medications`, a - // mood entry under `mind`. The coarse `canAdd` this used to gate the whole + // mood entry under `mind`, a workout in one's own record only. The coarse `canAdd` this used to gate the whole // menu on carries the grant's LEVEL and no scope, so it answered true for a // grant that opens neither of the two sections the menu never asked about. // The customize shortcut points at a settings page sharing does not cover at // all. const capabilities = useRecordCapabilities(); - const { canWriteDomain, inSharedRecord } = capabilities; - const canAddMeasurement = canWriteDomain("measurements"); - const canAddIntake = canWriteDomain("medications"); - // A mood entry is created at MANAGE under the mind section. - const canAddMood = canWriteDomain("mind"); - // A workout: own record only, workouts module on (`canLogWorkout`). - const canAddWorkout = canLogWorkout(capabilities, user?.modules); - const canAddAnything = - canAddMeasurement || canAddIntake || canAddMood || canAddWorkout; + const { inSharedRecord } = capabilities; + // The same answer the bottom bar's capture picker renders from: per kind, + // the record's section (a delegated grant) AND the kind's module + // (`capture:`). Asking the picker's own function is what keeps the two + // menus from disagreeing — this one used to check the section alone, so a + // switched-off mood module still offered "Log mood" here. + const offered = visibleCaptureKinds( + capabilities, + CAPTURE_KIND_ORDER, + user?.modules, + ); + const canAddMeasurement = offered.includes("measurement"); + const canAddIntake = offered.includes("medication"); + const canAddMood = offered.includes("mood"); + const canAddWorkout = offered.includes("workout"); + const canAddAnything = offered.length > 0; // The pre-hero greeting derivation, kept hydration-safe: `user` comes // from the auth query, which can resolve before this boundary diff --git a/src/lib/workouts/manual-entry.ts b/src/lib/workouts/manual-entry.ts index 5246eb6a2..d356ecc47 100644 --- a/src/lib/workouts/manual-entry.ts +++ b/src/lib/workouts/manual-entry.ts @@ -95,8 +95,9 @@ const ERR = "insights.workouts.manual.errors"; * the `capture:workout` surface; an absent module map (the account still * loading) offers it, the gate's default-on contract. * - * The dashboard add menu, the capture picker and the workouts page all ask - * this one question. + * The workouts page asks this; the dashboard add menu and the capture picker + * ask `visibleCaptureKinds`, which applies the same two terms to the + * `workout` kind. */ export function canLogWorkout( caps: { inSharedRecord: boolean },