diff --git a/src/app/mood/__tests__/add-param-record-scope.test.tsx b/src/app/mood/__tests__/add-param-record-scope.test.tsx
new file mode 100644
index 000000000..684dc743c
--- /dev/null
+++ b/src/app/mood/__tests__/add-param-record-scope.test.tsx
@@ -0,0 +1,106 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { ReactNode } from "react";
+
+/**
+ * `/mood?add=1` is the mood reminder's tap target. It opens the entry sheet
+ * only in the caller's own record: a session that is inside a managed
+ * profile or a shared record opens the page without the sheet, so a reminder
+ * meant for the recipient never becomes a write form on somebody else.
+ */
+
+const caps = {
+ current: {
+ inSharedRecord: false,
+ recordSessionPending: undefined as boolean | undefined,
+ accessRefused: undefined as boolean | undefined,
+ canManageDomain: (_d: string) => true,
+ },
+};
+const params = { current: new URLSearchParams("add=1") };
+
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => caps.current,
+}));
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { modules: {} },
+ isAuthenticated: true,
+ isLoading: false,
+ }),
+}));
+vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
+vi.mock("@/hooks/use-pull-to-refresh", () => ({
+ usePullToRefresh: () => ({}),
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ invalidateQueries: () => {} }),
+}));
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ replace: () => {}, push: () => {} }),
+ useSearchParams: () => params.current,
+}));
+vi.mock("@/lib/i18n/context", () => ({
+ useTranslations: () => ({ t: (key: string) => key }),
+}));
+vi.mock("@/components/ui/responsive-sheet", () => ({
+ ResponsiveSheet: ({ open }: { open: boolean; children?: ReactNode }) =>
+ open ?
: null,
+}));
+vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null }));
+vi.mock("@/components/mood/mood-list", () => ({ MoodList: () => null }));
+vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({
+ PullToRefreshIndicator: () => null,
+}));
+
+import MoodPageClient from "../page-client";
+
+function sheetOpen(): boolean {
+ return renderToStaticMarkup().includes(
+ 'data-slot="mood-sheet-open"',
+ );
+}
+
+beforeEach(() => {
+ params.current = new URLSearchParams("add=1");
+ caps.current = {
+ inSharedRecord: false,
+ recordSessionPending: undefined,
+ accessRefused: undefined,
+ canManageDomain: () => true,
+ };
+});
+
+describe("/mood ?add=1 and the record on screen", () => {
+ it("opens the entry sheet in the caller's own record", () => {
+ expect(sheetOpen()).toBe(true);
+ });
+
+ it("keeps the sheet closed without the param", () => {
+ params.current = new URLSearchParams();
+ expect(sheetOpen()).toBe(false);
+ });
+
+ it("does not open the sheet inside a managed profile the caller manages", () => {
+ // A guardian switched into a managed profile holds MANAGE on mind, so
+ // the sheet would otherwise open and save onto that profile.
+ caps.current = { ...caps.current, inSharedRecord: true };
+ expect(sheetOpen()).toBe(false);
+ });
+
+ it("does not open the sheet while the record context is pending or refused", () => {
+ caps.current = {
+ ...caps.current,
+ inSharedRecord: true,
+ recordSessionPending: true,
+ canManageDomain: () => false,
+ };
+ expect(sheetOpen()).toBe(false);
+ caps.current = {
+ ...caps.current,
+ recordSessionPending: undefined,
+ accessRefused: true,
+ };
+ expect(sheetOpen()).toBe(false);
+ });
+});
diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx
index 25464b0b7..5891e6a15 100644
--- a/src/app/mood/page-client.tsx
+++ b/src/app/mood/page-client.tsx
@@ -21,7 +21,12 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPageClient() {
const { user, isAuthenticated, isLoading } = useAuth();
- const { canManageDomain, inSharedRecord } = useRecordCapabilities();
+ const {
+ canManageDomain,
+ inSharedRecord,
+ recordSessionPending,
+ accessRefused,
+ } = useRecordCapabilities();
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canManageDomain("mind");
const mounted = useMounted();
@@ -33,15 +38,35 @@ export default function MoodPageClient() {
// hydration render agree; the effect below strips the one-shot param so the
// back button does not reopen the sheet. A later client-side navigation
// back onto `?add=1` is handled render-side, the measurements-page pattern.
+ //
+ // The request is held apart from the sheet's own open state because it is
+ // honoured only in the caller's own record. A reminder is about the person
+ // who receives it; a session that is currently inside a managed profile or
+ // a shared record would otherwise open a write form there, and an entry
+ // saved from it lands on the wrong person. In that case the page opens
+ // without the sheet and the shell's shared-record banner offers the way
+ // back. The answer is read once the account query settled (`isLoading`
+ // gates the content render), so the request is dropped, not deferred: a
+ // later switch back to the own record must not pop a stale sheet.
const addParam = searchParams.get("add") === "1";
- const [dialogOpen, setDialogOpen] = useState(() => addParam);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [addRequested, setAddRequested] = useState(() => addParam);
const [consumedAdd, setConsumedAdd] = useState(() => addParam);
if (!addParam && consumedAdd) {
setConsumedAdd(false);
} else if (addParam && !consumedAdd) {
setConsumedAdd(true);
- setDialogOpen(true);
+ setAddRequested(true);
}
+ const ownRecord = !inSharedRecord && !recordSessionPending && !accessRefused;
+ if (addRequested && mounted && !isLoading && !ownRecord) {
+ setAddRequested(false);
+ }
+ const sheetOpen = (dialogOpen || (addRequested && ownRecord)) && canAddMood;
+ const setSheetOpen = useCallback((open: boolean) => {
+ setDialogOpen(open);
+ if (!open) setAddRequested(false);
+ }, []);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
@@ -68,7 +93,7 @@ export default function MoodPageClient() {
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
- disabled: dialogOpen,
+ disabled: sheetOpen,
});
useEffect(() => {
@@ -149,14 +174,14 @@ export default function MoodPageClient() {
/>
}
>
setDialogOpen(false)}
- onCancel={() => setDialogOpen(false)}
+ onSuccess={() => setSheetOpen(false)}
+ onCancel={() => setSheetOpen(false)}
footerSlot={footerEl}
/>
diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts
index 10bb30de4..3552e4ace 100644
--- a/src/lib/notifications/tap-target.ts
+++ b/src/lib/notifications/tap-target.ts
@@ -36,7 +36,10 @@ export const NOTIFICATION_TAP_PATHS: Record = {
WITHINGS_SYNC_FAILED: "/settings/integrations",
SYSTEM_ALERT: "/",
PERSONAL_RECORD: "/achievements",
- // `?add=1` opens the mood entry sheet on the first paint.
+ // `?add=1` opens the mood entry sheet on the first paint, and only in the
+ // recipient's own record: a session switched into another record opens the
+ // page without the sheet (`src/app/mood/page-client.tsx`). No other target
+ // here opens a write form.
MOOD_REMINDER: "/mood?add=1",
CYCLE_PERIOD_SOON: "/cycle",
CYCLE_PERIOD_CONFIRM: "/cycle",