diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 7be85c1..689d5b0 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -5,6 +5,7 @@ import {
QueryClientProvider,
} from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
+import AuthDataBoundary from "@/components/Componentes/auth-data-boundary";
import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync";
import HardwareBackBridge from "@/components/Componentes/hardware-back-bridge";
import SilentReloader from "@/components/Componentes/silent-reloader";
@@ -43,10 +44,12 @@ export default function Providers({ children }: ProvidersProps) {
return (
-
-
-
- {children}
+
+
+
+
+ {children}
+
);
diff --git a/src/app/questions-list/[slug]/loading.tsx b/src/app/questions-list/[slug]/loading.tsx
new file mode 100644
index 0000000..f14b0ab
--- /dev/null
+++ b/src/app/questions-list/[slug]/loading.tsx
@@ -0,0 +1,21 @@
+export default function Loading() {
+ return (
+
+ {/* Header placeholder */}
+
+ {/* Center spinner */}
+
+
+
+
+ );
+}
diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx
index f92e846..c224bef 100644
--- a/src/app/questions-list/[slug]/question-detail-client.tsx
+++ b/src/app/questions-list/[slug]/question-detail-client.tsx
@@ -45,6 +45,12 @@ import {
} from "@/lib/schema-adapter";
import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider";
+import { useCurrentProfileId } from "@/hooks/use-current-profile-id";
+import {
+ getScopedAssessmentDraftKey,
+ readScopedAssessmentDraft,
+ removeScopedAssessmentDraft,
+} from "@/lib/user-scoped-storage";
type QuestionDetailClientProps = {
closeLabel: string;
@@ -68,12 +74,14 @@ type StoredAnswers = {
fields?: StoredQuestionField[];
};
-function getTestDraftStorageKey(slug: string) {
- return `marriage:tests:${slug}:draft`;
+function getTestDraftStorageKey(slug: string, profileId: number | null) {
+ if (!profileId) return null;
+ return getScopedAssessmentDraftKey(profileId, slug);
}
-function getQuestionStorageKey(slug: string) {
- return `marriage:sections:${slug}:answers`;
+function getQuestionStorageKey(slug: string, profileId: number | null) {
+ if (!profileId) return null;
+ return `marriage:user:${profileId}:sections:${slug}:completed`;
}
function QuestionFlowWrapper({
@@ -166,6 +174,7 @@ export default function QuestionDetailClient({
const [isTestStarted, setIsTestStarted] = useState(false);
const [hasTestProgress, setHasTestProgress] = useState(false);
const queryClient = useQueryClient();
+ const profileId = useCurrentProfileId();
// Hardware back in the detail page = navigate back to questions list.
// QuestionAnswersProvider's pagehide/unmount safety net will flush
@@ -178,26 +187,20 @@ export default function QuestionDetailClient({
useHardwareBackHandler(handleHardwareBack);
useEffect(() => {
- if (typeof window !== "undefined") {
- const draftKey = `marriage:tests:${itemSlug}:draft`;
- const draftRaw = window.localStorage.getItem(draftKey);
- if (draftRaw) {
- try {
- const parsed = JSON.parse(draftRaw);
- if (
- parsed &&
- typeof parsed.answers === "object" &&
- parsed.answers !== null &&
- Object.keys(parsed.answers).length > 0
- ) {
- setHasTestProgress(true);
- return;
- }
- } catch {}
+ if (typeof window !== "undefined" && profileId) {
+ const draft = readScopedAssessmentDraft(profileId, itemSlug);
+ if (
+ draft &&
+ typeof draft.answers === "object" &&
+ draft.answers !== null &&
+ Object.keys(draft.answers).length > 0
+ ) {
+ setHasTestProgress(true);
+ return;
}
setHasTestProgress(false);
}
- }, [itemSlug, isTestStarted]);
+ }, [itemSlug, isTestStarted, profileId]);
const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test";
@@ -570,10 +573,13 @@ export default function QuestionDetailClient({
}));
await submitCattellMutation.mutateAsync({ responses });
try {
- window.localStorage.setItem(
- getQuestionStorageKey(item.slug),
- JSON.stringify({ completed: true }),
- );
+ const completionKey = getQuestionStorageKey(item.slug, profileId);
+ if (completionKey) {
+ window.localStorage.setItem(
+ completionKey,
+ JSON.stringify({ completed: true }),
+ );
+ }
} catch {}
} else if (isGlasserSlug) {
const responses = Object.entries(answers).map(([qNum, score]) => ({
@@ -582,10 +588,13 @@ export default function QuestionDetailClient({
}));
await submitGlasserMutation.mutateAsync({ responses });
try {
- window.localStorage.setItem(
- getQuestionStorageKey(item.slug),
- JSON.stringify({ completed: true }),
- );
+ const completionKey = getQuestionStorageKey(item.slug, profileId);
+ if (completionKey) {
+ window.localStorage.setItem(
+ completionKey,
+ JSON.stringify({ completed: true }),
+ );
+ }
} catch {}
}
};
@@ -598,7 +607,7 @@ export default function QuestionDetailClient({
informationLabel={informationLabel}
onClose={() => setIsTestStarted(false)}
onFinish={handleTestFinish}
- draftStorageKey={getTestDraftStorageKey(item.slug)}
+ draftStorageKey={getTestDraftStorageKey(item.slug, profileId)}
/>
);
}
diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx
index f92ca89..1253e88 100644
--- a/src/app/questions-list/questions-list-client.tsx
+++ b/src/app/questions-list/questions-list-client.tsx
@@ -30,6 +30,11 @@ import {
} from "@/hooks/marriage/use-section-data";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
+import {
+ readScopedAssessmentDraft,
+ readScopedSectionDraft,
+ removeScopedSectionDraft,
+} from "@/lib/user-scoped-storage";
import {
getSubmitPath,
hasCompletedMarriageProfileBasics,
@@ -44,6 +49,7 @@ import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
+import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
import SectionsRequest from "./sections-request";
export default function QuestionsListClient() {
@@ -115,10 +121,17 @@ export default function QuestionsListClient() {
useEffect(() => {
const next = new Map();
+ const profileId = profile?.id;
for (const slug of ["personality_test", "glasser_5_needs_test"]) {
try {
- const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`);
- const draft = raw ? JSON.parse(raw) : null;
+ let draft: any = null;
+ if (profileId) {
+ draft = readScopedAssessmentDraft(profileId, slug);
+ }
+ if (!draft) {
+ const raw = window.localStorage.getItem(`marriage:tests:${slug}:draft`);
+ draft = raw ? JSON.parse(raw) : null;
+ }
const progress = getAssessmentLocalProgress(draft, false);
if (progress > 0) next.set(slug, progress);
} catch {
@@ -126,7 +139,7 @@ export default function QuestionsListClient() {
}
}
setLocalAssessmentProgress(next);
- }, [overview]);
+ }, [overview, profile?.id]);
const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map();
@@ -237,23 +250,19 @@ export default function QuestionsListClient() {
const syncPromiseRef = useRef | null>(null);
const syncPendingAnswers = useCallback(async () => {
- if (!overview) return;
+ if (!overview || !profile?.id || profile?.can_edit_profile === false) return;
if (syncPromiseRef.current) {
return syncPromiseRef.current;
}
+ const profileId = profile.id;
+
const task = (async () => {
const pendingSections: Array<{
- storageKey: string;
- storedValue: {
- current_step: number;
- fields: MarriageField[];
- pending_keys: string[];
- pending_sync: boolean;
- };
- fields: MarriageField[];
slug: string;
+ fields: MarriageField[];
}> = [];
+
for (const item of questionListItems) {
if (
item.slug === "personality_test" ||
@@ -261,60 +270,36 @@ export default function QuestionsListClient() {
) {
continue;
}
- const storageKey = getQuestionAnswersStorageKey(item.slug);
- const rawValue = window.localStorage.getItem(storageKey);
- if (!rawValue) continue;
-
- let storedValue: any;
- try {
- storedValue = JSON.parse(rawValue);
- } catch {
- continue;
- }
- if (
- !storedValue ||
- !storedValue.pending_sync ||
- !Array.isArray(storedValue.fields)
- ) {
- continue;
+ // 1. Read scoped draft for current profile
+ const scopedDraft = readScopedSectionDraft(profileId, item.slug);
+ if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) {
+ const fields: MarriageField[] = Object.values(scopedDraft.pending).map(
+ (p) =>
+ ({
+ key: p.key,
+ label: p.label,
+ type: p.type,
+ value: p.value,
+ option_id: p.option_id ?? undefined,
+ private: p.private,
+ }) as MarriageField,
+ );
+ if (fields.length > 0) {
+ pendingSections.push({ slug: item.slug, fields });
+ }
}
- const pendingKeys = new Set(
- Array.isArray(storedValue.pending_keys)
- ? storedValue.pending_keys
- : storedValue.fields.map((field: { key: string }) => field.key),
- );
- const pendingFields = storedValue.fields.filter(
- (field: { key: string }) => pendingKeys.has(field.key),
- );
- if (pendingFields.length === 0) continue;
- pendingSections.push({
- storageKey,
- storedValue,
- fields: pendingFields,
- slug: item.slug,
- });
}
if (pendingSections.length === 0) return;
- const result = await updateMarriageSectionData(pendingSections[0].slug, {
- current_step: pendingSections[0].storedValue.current_step,
- fields: pendingSections.flatMap((section) => section.fields),
- });
- applyProfilePatchResultToCache(queryClient, locale, result);
- const cleared = new Set(result.cleared_answer_ids ?? []);
-
- for (const { storageKey, storedValue } of pendingSections) {
- storedValue.fields = storedValue.fields.filter(
- (field: { key: string }) => !cleared.has(field.key),
- );
- storedValue.pending_sync = false;
- storedValue.pending_keys = [];
- if (storedValue.fields.length === 0) {
- window.localStorage.removeItem(storageKey);
- } else {
- window.localStorage.setItem(storageKey, JSON.stringify(storedValue));
- }
+
+ for (const section of pendingSections) {
+ const result = await updateMarriageSectionData(section.slug, {
+ current_step: 0,
+ fields: section.fields,
+ });
+ applyProfilePatchResultToCache(queryClient, locale, result);
+ removeScopedSectionDraft(profileId, section.slug);
}
})();
@@ -325,12 +310,10 @@ export default function QuestionsListClient() {
} finally {
syncPromiseRef.current = null;
}
- }, [locale, overview, queryClient, questionListItems]);
+ }, [locale, overview, profile?.id, profile?.can_edit_profile, queryClient, questionListItems]);
const prefetchSection = useCallback(
(item: QuestionListItem) => {
- const href = localizePath(`/questions-list/${item.slug}`, locale);
- router.prefetch(href);
if (
item.slug === "personality_test" ||
item.slug === "glasser_5_needs_test"
@@ -342,36 +325,6 @@ export default function QuestionsListClient() {
staleTime: 30 * 1000,
});
},
- [locale, queryClient, router],
- );
-
- const viewportPrefetchChain = useRef(Promise.resolve());
- const viewportPrefetchSlugs = useRef(new Set());
- const enqueueViewportPrefetch = useCallback(
- (item: QuestionListItem) => {
- if (
- item.slug === "personality_test" ||
- item.slug === "glasser_5_needs_test" ||
- viewportPrefetchSlugs.current.has(item.slug)
- ) {
- return;
- }
- viewportPrefetchSlugs.current.add(item.slug);
- viewportPrefetchChain.current = viewportPrefetchChain.current
- .catch(() => undefined)
- .then(() =>
- queryClient.fetchQuery({
- queryKey: marriageQueryKeys.formSection(
- "profile",
- item.slug,
- locale,
- ),
- queryFn: () => getFormSection("profile", item.slug, locale),
- staleTime: 30 * 1000,
- }),
- )
- .then(() => undefined);
- },
[locale, queryClient],
);
@@ -400,18 +353,27 @@ export default function QuestionsListClient() {
if (profileSections.length === 0) return;
prefetchQueueStarted.current = true;
let cancelled = false;
- void prefetchSectionsWithBoundedConcurrency(
- profileSections,
- (item) =>
- queryClient.fetchQuery({
- queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
- queryFn: () => getFormSection("profile", item.slug, locale),
- staleTime: 30 * 1000,
- }),
- () => cancelled,
- );
+ const startPrefetch = () => {
+ if (cancelled) return;
+ void prefetchSectionsWithBoundedConcurrency(
+ profileSections,
+ (item) =>
+ queryClient.fetchQuery({
+ queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
+ queryFn: () => getFormSection("profile", item.slug, locale),
+ staleTime: 30 * 1000,
+ }),
+ () => cancelled,
+ );
+ };
+ const idle = typeof requestIdleCallback === "function"
+ ? requestIdleCallback(startPrefetch)
+ : setTimeout(startPrefetch, 200);
return () => {
cancelled = true;
+ if (typeof cancelIdleCallback === "function" && typeof idle === "number") {
+ cancelIdleCallback(idle);
+ }
};
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]);
@@ -685,6 +647,7 @@ export default function QuestionsListClient() {
/>
) : null}
+ {process.env.NODE_ENV === "development" ? : null}
setSelectedSection(section)}
- onNearViewport={enqueueViewportPrefetch}
onPrefetch={prefetchSection}
/>
))}
diff --git a/src/components/Componentes/auth-data-boundary.tsx b/src/components/Componentes/auth-data-boundary.tsx
new file mode 100644
index 0000000..c6f8087
--- /dev/null
+++ b/src/components/Componentes/auth-data-boundary.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useRef, type ReactNode } from "react";
+import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
+import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge";
+import {
+ isLegacyMigrationDone,
+ migrateLegacyStorageKeys,
+} from "@/lib/user-scoped-storage";
+
+/**
+ * Central auth-data boundary that resets user-specific React Query caches
+ * and localStorage drafts when the authenticated identity changes.
+ *
+ * Mount this inside (or near) the QueryClientProvider.
+ *
+ * On `habib:auth-token-changed`:
+ * 1. Cancels all in-flight user queries (profile, sections, assessments)
+ * 2. Removes previous-user data from the query cache
+ * 3. Keeps config/public queries intact
+ *
+ * This prevents Account A's cached data from being served to Account B
+ * after a token switch in the same WebView.
+ */
+export default function AuthDataBoundary({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ const queryClient = useQueryClient();
+ const queryClientRef = useRef(queryClient);
+ queryClientRef.current = queryClient;
+
+ // Run V3 legacy migration once on mount
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ if (!isLegacyMigrationDone()) {
+ migrateLegacyStorageKeys();
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const handleAuthChange = () => {
+ const qc = queryClientRef.current;
+
+ // 1. Cancel all in-flight user-specific queries
+ void qc.cancelQueries({ queryKey: marriageQueryKeys.profile() });
+ void qc.cancelQueries({ queryKey: marriageQueryKeys.sections() });
+ void qc.cancelQueries({
+ queryKey: marriageQueryKeys.all,
+ predicate: (query) => {
+ const key = query.queryKey;
+ // Preserve config queries — they're user-independent
+ if (
+ Array.isArray(key) &&
+ key[0] === "marriage" &&
+ key[1] === "config"
+ ) {
+ return false;
+ }
+ return true;
+ },
+ });
+
+ // 2. Remove all user-owned query data from cache
+ // Remove profile
+ qc.removeQueries({ queryKey: marriageQueryKeys.profile() });
+
+ // Remove form overview + sections
+ qc.removeQueries({
+ queryKey: marriageQueryKeys.all,
+ predicate: (query) => {
+ const key = query.queryKey;
+ if (!Array.isArray(key) || key[0] !== "marriage") return false;
+
+ const kind = key[1];
+ // Remove: profile, form-overview, form-section, sections,
+ // cattell, glasser, advisors, cases (contact-info)
+ // Keep: config
+ return kind !== "config";
+ },
+ });
+ };
+
+ window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleAuthChange);
+
+ return () => {
+ window.removeEventListener(
+ HABIB_AUTH_TOKEN_CHANGED_EVENT,
+ handleAuthChange,
+ );
+ };
+ }, []);
+
+ return <>{children}>;
+}
diff --git a/src/components/Componentes/dev-tap-instrumentation.tsx b/src/components/Componentes/dev-tap-instrumentation.tsx
new file mode 100644
index 0000000..9423a5a
--- /dev/null
+++ b/src/components/Componentes/dev-tap-instrumentation.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useEffect } from "react";
+
+/**
+ * Development-only capture-phase instrumentation for debugging section-card
+ * tap responsiveness. Records pointerdown → pointerup → click timing and
+ * whether the event reaches the DOM at all (vs being swallowed by a native
+ * overlay such as Flutter's loading cover).
+ *
+ * Mount this inside the questions-list page during development. Remove or
+ * gate behind process.env.NODE_ENV check for production.
+ */
+export default function DevTapInstrumentation() {
+ useEffect(() => {
+ if (process.env.NODE_ENV !== "development") return;
+
+ const events = ["pointerdown", "pointerup", "pointercancel", "click"] as const;
+ const startTime = performance.now();
+ let sequenceId = 0;
+
+ const handler = (event: Event) => {
+ const e = event as PointerEvent;
+ const target = e.target as HTMLElement | null;
+ const anchor = target?.closest?.("a");
+ const elapsed = (performance.now() - startTime).toFixed(1);
+ sequenceId += 1;
+
+ console.debug(
+ `[tap-debug #${sequenceId}] %c${e.type}%c @ ${elapsed}ms`,
+ "color: #E03950; font-weight: bold",
+ "color: inherit",
+ {
+ pointerType: (e as PointerEvent).pointerType || "n/a",
+ target: target?.tagName,
+ targetId: target?.id,
+ closestAnchorHref: anchor?.getAttribute("href") || null,
+ defaultPrevented: e.defaultPrevented,
+ pathname: window.location.pathname,
+ timestamp: performance.now(),
+ },
+ );
+ };
+
+ for (const eventName of events) {
+ document.addEventListener(eventName, handler, { capture: true });
+ }
+
+ return () => {
+ for (const eventName of events) {
+ document.removeEventListener(eventName, handler, { capture: true });
+ }
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/components/Componentes/question-answer-storage.tsx b/src/components/Componentes/question-answer-storage.tsx
index 83d63de..98660b0 100644
--- a/src/components/Componentes/question-answer-storage.tsx
+++ b/src/components/Componentes/question-answer-storage.tsx
@@ -27,6 +27,13 @@ import {
} from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter";
+import {
+ getScopedSectionDraftKey,
+ readScopedSectionDraft,
+ removeScopedSectionDraft,
+ writeScopedSectionDraft,
+ type ScopedPendingField,
+} from "@/lib/user-scoped-storage";
const STORAGE_VERSION = 2;
@@ -343,53 +350,105 @@ export function QuestionAnswersProvider({
storageKeyRef.current = storageKey;
slugRef.current = slug;
- const stored = readStoredAnswers(storageKey, slug);
- let finalAnswers = stored.answers;
- let finalPendingSync = stored.pendingSync;
- dirtyKeysRef.current = new Set(stored.pendingKeys);
+ const profileId = profile?.id;
+
+ // ── NEW RECONCILIATION (V3): Server is canonical ──────────────
+ //
+ // displayAnswers = canonicalServerAnswers
+ // THEN overlay ONLY local fields explicitly listed as pending/dirty
+ // for the CURRENT user.
+ //
+ // A single pending question must never make every historical local
+ // answer override server data. A successful server response containing
+ // no answers must render an empty section unless the current user has
+ // genuine unsynced pending fields.
if (serverSectionData?.data) {
+ // Step 1: Server is canonical
const serverAnswers = fieldsToAnswers(serverSectionData.data);
- if (stored.pendingSync && canEdit) {
- // Merge: local answers override server answers for unsynced changes
- finalAnswers = { ...serverAnswers, ...stored.answers };
- } else {
- // A section response can temporarily omit fields (most notably while
- // the combined family section is being refreshed). Keep locally known
- // fields that the response did not include, while letting explicit
- // server values, including null/empty values, win for matching keys.
- finalAnswers = { ...stored.answers, ...serverAnswers };
- finalPendingSync = false;
+ let finalAnswers: QuestionAnswersByKey = { ...serverAnswers };
+ let finalPendingSync = false;
+ const nextDirtyKeys = new Set();
+
+ // Step 2: If we have a valid profile, read the user-scoped draft
+ if (profileId && canEdit) {
+ const scopedDraft = readScopedSectionDraft(profileId, slug);
+ if (scopedDraft && Object.keys(scopedDraft.pending).length > 0) {
+ // Overlay ONLY the pending dirty fields from the current user
+ for (const [key, pendingField] of Object.entries(scopedDraft.pending)) {
+ finalAnswers[key] = {
+ key: pendingField.key,
+ label: pendingField.label,
+ type: pendingField.type,
+ value: pendingField.value,
+ option_id: pendingField.option_id ?? undefined,
+ private: pendingField.private,
+ } as MarriageField;
+ nextDirtyKeys.add(key);
+ }
+ finalPendingSync = true;
+ }
}
- }
- answersRef.current = finalAnswers;
- hasPendingSyncRef.current = finalPendingSync;
-
- setAnswers((prev) => {
- const prevKeys = Object.keys(prev);
- const nextKeys = Object.keys(finalAnswers);
- if (prevKeys.length === nextKeys.length) {
- const isSame = prevKeys.every(
- (k) => prev[k]?.value === finalAnswers[k]?.value,
- );
- if (isSame) return prev;
+ // Also check the old V2 storage for backward compatibility during
+ // the transition period. Any old V2 pending edits are treated as
+ // a one-time overlay but NOT migrated (ownership can't be proven).
+ // After this render they won't be re-read because the V2 key is
+ // cleaned up by the legacy migration in AuthDataBoundary.
+ if (nextDirtyKeys.size === 0) {
+ const legacyStored = readStoredAnswers(storageKey, slug);
+ if (legacyStored.pendingSync && legacyStored.pendingKeys.length > 0 && canEdit) {
+ for (const pendingKey of legacyStored.pendingKeys) {
+ const pendingField = legacyStored.answers[pendingKey];
+ if (pendingField) {
+ finalAnswers[pendingKey] = pendingField;
+ nextDirtyKeys.add(pendingKey);
+ }
+ }
+ if (nextDirtyKeys.size > 0) {
+ finalPendingSync = true;
+ // Migrate these to scoped storage if we have a profile
+ if (profileId) {
+ const pendingMap: Record = {};
+ for (const key of nextDirtyKeys) {
+ const field = finalAnswers[key];
+ if (field) {
+ pendingMap[key] = {
+ key: field.key,
+ label: field.label,
+ type: field.type,
+ value: field.value,
+ option_id: (field as any).option_id,
+ private: field.private,
+ };
+ }
+ }
+ writeScopedSectionDraft(profileId, slug, pendingMap);
+ }
+ // Remove old V2 key
+ try { window.localStorage.removeItem(storageKey); } catch {}
+ }
+ }
}
- return finalAnswers;
- });
- setHasPendingSync(finalPendingSync);
- // Update localStorage to stay in sync
- writeStoredAnswers(
- getQuestionAnswersStorageKey(slug),
- slug,
- questions,
- finalAnswers,
- finalPendingSync,
- serverSectionData?.data || undefined,
- [...dirtyKeysRef.current],
- );
- }, [slug, storageKey, serverSectionData, questions, canEdit]);
+ dirtyKeysRef.current = nextDirtyKeys;
+ answersRef.current = finalAnswers;
+ hasPendingSyncRef.current = finalPendingSync;
+
+ setAnswers((prev) => {
+ const prevKeys = Object.keys(prev);
+ const nextKeys = Object.keys(finalAnswers);
+ if (prevKeys.length === nextKeys.length) {
+ const isSame = prevKeys.every(
+ (k) => prev[k]?.value === finalAnswers[k]?.value,
+ );
+ if (isSame) return prev;
+ }
+ return finalAnswers;
+ });
+ setHasPendingSync(finalPendingSync);
+ }
+ }, [slug, storageKey, serverSectionData, questions, canEdit, profile?.id]);
const syncTimeoutRef = useRef(null);
@@ -426,15 +485,26 @@ export function QuestionAnswersProvider({
hasPendingSyncRef.current = true;
answersRevisionRef.current += 1;
dirtyKeysRef.current.add(field.key);
- writeStoredAnswers(
- storageKeyRef.current,
- slugRef.current,
- questionsRef.current,
- nextAnswers,
- true,
- undefined,
- [...dirtyKeysRef.current],
- );
+
+ // Write only dirty fields to profile-scoped localStorage
+ const currentProfileId = profile?.id;
+ if (currentProfileId) {
+ const pendingMap: Record = {};
+ for (const dirtyKey of dirtyKeysRef.current) {
+ const dirtyField = nextAnswers[dirtyKey];
+ if (dirtyField) {
+ pendingMap[dirtyKey] = {
+ key: dirtyField.key,
+ label: dirtyField.label,
+ type: dirtyField.type,
+ value: dirtyField.value,
+ option_id: (dirtyField as any).option_id,
+ private: dirtyField.private,
+ };
+ }
+ }
+ writeScopedSectionDraft(currentProfileId, slugRef.current, pendingMap);
+ }
if (syncTimeoutRef.current !== null) {
clearTimeout(syncTimeoutRef.current);
@@ -552,17 +622,13 @@ export function QuestionAnswersProvider({
hasPendingSyncRef.current = false;
setHasPendingSync(false);
- writeStoredAnswers(
- storageKeyRef.current,
- slugRef.current,
- questionsRef.current,
- answersRef.current,
- false,
- undefined,
- [],
- );
+ // All dirty fields acknowledged — remove the draft entirely
+ const currentProfileId = profile?.id;
+ if (currentProfileId) {
+ removeScopedSectionDraft(currentProfileId, slugRef.current);
+ }
},
- [mutateAsync, canEdit, locale, queryClient],
+ [mutateAsync, canEdit, locale, queryClient, profile?.id],
);
const flushAnswersRef = useRef(flushAnswers);
@@ -638,15 +704,30 @@ export function QuestionAnswersProvider({
const stillPending = dirtyKeysRef.current.size > 0;
hasPendingSyncRef.current = stillPending;
setHasPendingSync(stillPending);
- writeStoredAnswers(
- storageKeyRef.current,
- slugRef.current,
- questionsRef.current,
- answersRef.current,
- stillPending,
- undefined,
- [...dirtyKeysRef.current],
- );
+
+ // Update scoped draft: remove acknowledged fields or delete draft
+ const currentProfileId = profile?.id;
+ if (currentProfileId) {
+ if (stillPending) {
+ const remainingPending: Record = {};
+ for (const dirtyKey of dirtyKeysRef.current) {
+ const field = answersRef.current[dirtyKey];
+ if (field) {
+ remainingPending[dirtyKey] = {
+ key: field.key,
+ label: field.label,
+ type: field.type,
+ value: field.value,
+ option_id: (field as any).option_id,
+ private: field.private,
+ };
+ }
+ }
+ writeScopedSectionDraft(currentProfileId, slugRef.current, remainingPending);
+ } else {
+ removeScopedSectionDraft(currentProfileId, slugRef.current);
+ }
+ }
void queryClient.invalidateQueries({
queryKey: marriageQueryKeys.profile(),
diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx
index 029584d..5229a13 100644
--- a/src/components/Componentes/question-card.tsx
+++ b/src/components/Componentes/question-card.tsx
@@ -72,7 +72,6 @@ export function QuestionCard({
aria-label={t["Open {title}"].replace("{title}", item.title)}
className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]"
onFocus={() => onPrefetch?.(item)}
- onPointerDown={() => onPrefetch?.(item)}
onPointerEnter={() => onPrefetch?.(item)}
>
{
if (!draftStorageKey) return;
try {
+ const match = draftStorageKey.match(
+ /^marriage:user:(\d+):tests:([^:]+):draft:v(\d+)$/,
+ );
+ const ownerProfileId = match ? Number(match[1]) : undefined;
+ const version = match ? Number(match[3]) : undefined;
+ const slug = match ? match[2] : undefined;
+
window.localStorage.setItem(
draftStorageKey,
- JSON.stringify({ answers, currentIndex, totalQuestions }),
+ JSON.stringify({
+ answers,
+ currentIndex,
+ totalQuestions,
+ ...(ownerProfileId !== undefined ? { ownerProfileId } : {}),
+ ...(version !== undefined ? { version } : {}),
+ ...(slug !== undefined ? { slug } : {}),
+ }),
);
} catch {}
- }, [answers, currentIndex, draftStorageKey]);
+ }, [answers, currentIndex, draftStorageKey, totalQuestions]);
const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return;
diff --git a/src/hooks/use-current-profile-id.ts b/src/hooks/use-current-profile-id.ts
new file mode 100644
index 0000000..105b6fd
--- /dev/null
+++ b/src/hooks/use-current-profile-id.ts
@@ -0,0 +1,12 @@
+"use client";
+
+import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main";
+
+/**
+ * Returns the current authenticated user's profile ID.
+ * Returns null when profile hasn't loaded yet or user is unauthenticated.
+ */
+export function useCurrentProfileId(): number | null {
+ const { data: profile } = useMarriageProfileQuery();
+ return profile?.id ?? null;
+}
diff --git a/src/lib/user-scoped-storage.ts b/src/lib/user-scoped-storage.ts
new file mode 100644
index 0000000..1754649
--- /dev/null
+++ b/src/lib/user-scoped-storage.ts
@@ -0,0 +1,285 @@
+/**
+ * User-scoped localStorage utilities.
+ *
+ * All draft/answer data is keyed by the authenticated user's profile ID
+ * so that Account A's unsynced edits can never be displayed or synced
+ * for Account B.
+ *
+ * The raw auth token is NEVER used as a key component.
+ */
+
+export const SCOPED_STORAGE_VERSION = 3;
+
+// ─── Types ───────────────────────────────────────────────────────────
+
+export interface ScopedSectionDraft {
+ version: typeof SCOPED_STORAGE_VERSION;
+ ownerProfileId: number;
+ slug: string;
+ pending: Record;
+ updatedAt: string;
+}
+
+export interface ScopedPendingField {
+ key: string;
+ label: string;
+ type: string;
+ value: unknown;
+ option_id?: string | string[] | null;
+ private?: boolean;
+}
+
+export interface ScopedAssessmentDraft {
+ version: typeof SCOPED_STORAGE_VERSION;
+ ownerProfileId: number;
+ slug: string;
+ answers: Record;
+ currentIndex: number;
+ totalQuestions: number;
+}
+
+// ─── Key Generators ──────────────────────────────────────────────────
+
+export function getScopedSectionDraftKey(
+ profileId: number,
+ slug: string,
+): string {
+ return `marriage:user:${profileId}:sections:${slug}:draft:v${SCOPED_STORAGE_VERSION}`;
+}
+
+export function getScopedAssessmentDraftKey(
+ profileId: number,
+ slug: string,
+): string {
+ return `marriage:user:${profileId}:tests:${slug}:draft:v${SCOPED_STORAGE_VERSION}`;
+}
+
+// ─── Ownership Verification ──────────────────────────────────────────
+
+export function isDraftOwnedBy(
+ draft: { ownerProfileId?: number } | null | undefined,
+ profileId: number,
+): boolean {
+ if (!draft || typeof draft !== "object") return false;
+ return draft.ownerProfileId === profileId;
+}
+
+// ─── Reading ─────────────────────────────────────────────────────────
+
+export function readScopedSectionDraft(
+ profileId: number,
+ slug: string,
+): ScopedSectionDraft | null {
+ try {
+ const key = getScopedSectionDraftKey(profileId, slug);
+ const raw = window.localStorage.getItem(key);
+ if (!raw) return null;
+
+ const parsed = JSON.parse(raw);
+ if (
+ !parsed ||
+ parsed.version !== SCOPED_STORAGE_VERSION ||
+ parsed.ownerProfileId !== profileId ||
+ parsed.slug !== slug
+ ) {
+ // Corrupted or mismatched — remove it
+ window.localStorage.removeItem(key);
+ return null;
+ }
+
+ return parsed as ScopedSectionDraft;
+ } catch {
+ return null;
+ }
+}
+
+export function readScopedAssessmentDraft(
+ profileId: number,
+ slug: string,
+): ScopedAssessmentDraft | null {
+ try {
+ const key = getScopedAssessmentDraftKey(profileId, slug);
+ const raw = window.localStorage.getItem(key);
+ if (!raw) return null;
+
+ const parsed = JSON.parse(raw);
+ if (
+ !parsed ||
+ parsed.version !== SCOPED_STORAGE_VERSION ||
+ parsed.ownerProfileId !== profileId
+ ) {
+ window.localStorage.removeItem(key);
+ return null;
+ }
+
+ return parsed as ScopedAssessmentDraft;
+ } catch {
+ return null;
+ }
+}
+
+// ─── Writing ─────────────────────────────────────────────────────────
+
+export function writeScopedSectionDraft(
+ profileId: number,
+ slug: string,
+ pending: Record,
+): void {
+ try {
+ const key = getScopedSectionDraftKey(profileId, slug);
+
+ if (Object.keys(pending).length === 0) {
+ window.localStorage.removeItem(key);
+ return;
+ }
+
+ const draft: ScopedSectionDraft = {
+ version: SCOPED_STORAGE_VERSION,
+ ownerProfileId: profileId,
+ slug,
+ pending,
+ updatedAt: new Date().toISOString(),
+ };
+
+ window.localStorage.setItem(key, JSON.stringify(draft));
+ } catch {
+ // localStorage can fail in private mode or when storage quota is exhausted.
+ }
+}
+
+export function writeScopedAssessmentDraft(
+ profileId: number,
+ slug: string,
+ answers: Record,
+ currentIndex: number,
+ totalQuestions: number,
+): void {
+ try {
+ const key = getScopedAssessmentDraftKey(profileId, slug);
+
+ if (Object.keys(answers).length === 0) {
+ window.localStorage.removeItem(key);
+ return;
+ }
+
+ const draft: ScopedAssessmentDraft = {
+ version: SCOPED_STORAGE_VERSION,
+ ownerProfileId: profileId,
+ slug,
+ answers,
+ currentIndex,
+ totalQuestions,
+ };
+
+ window.localStorage.setItem(key, JSON.stringify(draft));
+ } catch {
+ // localStorage can fail.
+ }
+}
+
+export function removeScopedSectionDraft(
+ profileId: number,
+ slug: string,
+): void {
+ try {
+ window.localStorage.removeItem(
+ getScopedSectionDraftKey(profileId, slug),
+ );
+ } catch {}
+}
+
+export function removeScopedAssessmentDraft(
+ profileId: number,
+ slug: string,
+): void {
+ try {
+ window.localStorage.removeItem(
+ getScopedAssessmentDraftKey(profileId, slug),
+ );
+ } catch {}
+}
+
+// ─── Legacy Migration ────────────────────────────────────────────────
+
+/**
+ * Remove old V2 unscoped answer keys. Since they have no owner metadata,
+ * ownership cannot be proven, so we do NOT migrate them.
+ * We also do NOT call localStorage.clear() because the application stores
+ * unrelated device/UI flags.
+ */
+export function migrateLegacyStorageKeys(): void {
+ try {
+ const keysToRemove: string[] = [];
+
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const key = window.localStorage.key(i);
+ if (!key) continue;
+
+ // Old V2 normal section keys: marriage:sections:{slug}:answers:v2
+ if (key.match(/^marriage:sections:[^:]+:answers:v2$/)) {
+ keysToRemove.push(key);
+ }
+
+ // Old unscoped assessment draft keys: marriage:tests:{slug}:draft
+ if (key.match(/^marriage:tests:[^:]+:draft$/) && !key.includes(":user:")) {
+ keysToRemove.push(key);
+ }
+
+ // Old unscoped completion markers: marriage:sections:{slug}:answers
+ // (used by assessments to store {completed: true})
+ if (key.match(/^marriage:sections:[^:]+:answers$/) && !key.includes(":user:")) {
+ keysToRemove.push(key);
+ }
+ }
+
+ for (const key of keysToRemove) {
+ window.localStorage.removeItem(key);
+ }
+
+ // Mark migration as done so we don't re-scan every page load
+ window.localStorage.setItem("marriage:storage-migration:v3", "done");
+ } catch {
+ // Non-critical; will retry next page load.
+ }
+}
+
+/**
+ * Check if the V3 migration has already been performed.
+ */
+export function isLegacyMigrationDone(): boolean {
+ try {
+ return window.localStorage.getItem("marriage:storage-migration:v3") === "done";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Enumerate all scoped section draft keys for a given profile.
+ */
+export function getAllScopedSectionDraftKeys(
+ profileId: number,
+): string[] {
+ const prefix = `marriage:user:${profileId}:sections:`;
+ const suffix = `:draft:v${SCOPED_STORAGE_VERSION}`;
+ const keys: string[] = [];
+
+ try {
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const key = window.localStorage.key(i);
+ if (key && key.startsWith(prefix) && key.endsWith(suffix)) {
+ keys.push(key);
+ }
+ }
+ } catch {}
+
+ return keys;
+}
+
+/**
+ * Extract the slug from a scoped section draft key.
+ */
+export function extractSlugFromScopedKey(key: string): string | null {
+ const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/);
+ return match ? match[1] : null;
+}