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 },