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; +}