Browse Source

fix: sections

master
mortezaei 7 days ago
parent
commit
28a907b3da
  1. 11
      src/app/providers.tsx
  2. 21
      src/app/questions-list/[slug]/loading.tsx
  3. 69
      src/app/questions-list/[slug]/question-detail-client.tsx
  4. 172
      src/app/questions-list/questions-list-client.tsx
  5. 99
      src/components/Componentes/auth-data-boundary.tsx
  6. 57
      src/components/Componentes/dev-tap-instrumentation.tsx
  7. 217
      src/components/Componentes/question-answer-storage.tsx
  8. 1
      src/components/Componentes/question-card.tsx
  9. 18
      src/components/Componentes/test-questions-flow.tsx
  10. 12
      src/hooks/use-current-profile-id.ts
  11. 285
      src/lib/user-scoped-storage.ts

11
src/app/providers.tsx

@ -5,6 +5,7 @@ import {
QueryClientProvider, QueryClientProvider,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import AuthDataBoundary from "@/components/Componentes/auth-data-boundary";
import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync"; import FlutterLocaleSync from "@/components/Componentes/flutter-locale-sync";
import HardwareBackBridge from "@/components/Componentes/hardware-back-bridge"; import HardwareBackBridge from "@/components/Componentes/hardware-back-bridge";
import SilentReloader from "@/components/Componentes/silent-reloader"; import SilentReloader from "@/components/Componentes/silent-reloader";
@ -43,10 +44,12 @@ export default function Providers({ children }: ProvidersProps) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SilentReloader> <SilentReloader>
<ViewPaddingsProvider />
<FlutterLocaleSync />
<HardwareBackBridge />
{children}
<AuthDataBoundary>
<ViewPaddingsProvider />
<FlutterLocaleSync />
<HardwareBackBridge />
{children}
</AuthDataBoundary>
</SilentReloader> </SilentReloader>
</QueryClientProvider> </QueryClientProvider>
); );

21
src/app/questions-list/[slug]/loading.tsx

@ -0,0 +1,21 @@
export default function Loading() {
return (
<main className="-mx-[17px] flex h-svh flex-col overflow-hidden bg-[#F7F1F0]">
{/* Header placeholder */}
<div
style={{ paddingTop: "max(12px, calc(var(--safe-top) + 4px))" }}
className="shrink-0 px-[17px] pb-3"
>
<div className="h-10" />
</div>
{/* Center spinner */}
<div className="flex min-h-0 flex-1 items-center justify-center">
<span
role="status"
aria-label="Loading section"
className="size-5 animate-spin rounded-full border-2 border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none"
/>
</div>
</main>
);
}

69
src/app/questions-list/[slug]/question-detail-client.tsx

@ -45,6 +45,12 @@ import {
} from "@/lib/schema-adapter"; } from "@/lib/schema-adapter";
import { defaultLocale, type Locale } from "@/translations/config"; import { defaultLocale, type Locale } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useCurrentProfileId } from "@/hooks/use-current-profile-id";
import {
getScopedAssessmentDraftKey,
readScopedAssessmentDraft,
removeScopedAssessmentDraft,
} from "@/lib/user-scoped-storage";
type QuestionDetailClientProps = { type QuestionDetailClientProps = {
closeLabel: string; closeLabel: string;
@ -68,12 +74,14 @@ type StoredAnswers = {
fields?: StoredQuestionField[]; 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({ function QuestionFlowWrapper({
@ -166,6 +174,7 @@ export default function QuestionDetailClient({
const [isTestStarted, setIsTestStarted] = useState(false); const [isTestStarted, setIsTestStarted] = useState(false);
const [hasTestProgress, setHasTestProgress] = useState(false); const [hasTestProgress, setHasTestProgress] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const profileId = useCurrentProfileId();
// Hardware back in the detail page = navigate back to questions list. // Hardware back in the detail page = navigate back to questions list.
// QuestionAnswersProvider's pagehide/unmount safety net will flush // QuestionAnswersProvider's pagehide/unmount safety net will flush
@ -178,26 +187,20 @@ export default function QuestionDetailClient({
useHardwareBackHandler(handleHardwareBack); useHardwareBackHandler(handleHardwareBack);
useEffect(() => { 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); setHasTestProgress(false);
} }
}, [itemSlug, isTestStarted]);
}, [itemSlug, isTestStarted, profileId]);
const isCattellSlug = itemSlug === "personality_test"; const isCattellSlug = itemSlug === "personality_test";
const isGlasserSlug = itemSlug === "glasser_5_needs_test"; const isGlasserSlug = itemSlug === "glasser_5_needs_test";
@ -570,10 +573,13 @@ export default function QuestionDetailClient({
})); }));
await submitCattellMutation.mutateAsync({ responses }); await submitCattellMutation.mutateAsync({ responses });
try { 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 {} } catch {}
} else if (isGlasserSlug) { } else if (isGlasserSlug) {
const responses = Object.entries(answers).map(([qNum, score]) => ({ const responses = Object.entries(answers).map(([qNum, score]) => ({
@ -582,10 +588,13 @@ export default function QuestionDetailClient({
})); }));
await submitGlasserMutation.mutateAsync({ responses }); await submitGlasserMutation.mutateAsync({ responses });
try { 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 {} } catch {}
} }
}; };
@ -598,7 +607,7 @@ export default function QuestionDetailClient({
informationLabel={informationLabel} informationLabel={informationLabel}
onClose={() => setIsTestStarted(false)} onClose={() => setIsTestStarted(false)}
onFinish={handleTestFinish} onFinish={handleTestFinish}
draftStorageKey={getTestDraftStorageKey(item.slug)}
draftStorageKey={getTestDraftStorageKey(item.slug, profileId)}
/> />
); );
} }

172
src/app/questions-list/questions-list-client.tsx

@ -30,6 +30,11 @@ import {
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { getAssessmentLocalProgress } from "@/lib/assessment-progress"; import { getAssessmentLocalProgress } from "@/lib/assessment-progress";
import {
readScopedAssessmentDraft,
readScopedSectionDraft,
removeScopedSectionDraft,
} from "@/lib/user-scoped-storage";
import { import {
getSubmitPath, getSubmitPath,
hasCompletedMarriageProfileBasics, hasCompletedMarriageProfileBasics,
@ -44,6 +49,7 @@ import { convertOverviewToFrontendItems } from "@/lib/schema-adapter";
import { localizePath } from "@/translations/config"; import { localizePath } from "@/translations/config";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
export default function QuestionsListClient() { export default function QuestionsListClient() {
@ -115,10 +121,17 @@ export default function QuestionsListClient() {
useEffect(() => { useEffect(() => {
const next = new Map<string, number>(); const next = new Map<string, number>();
const profileId = profile?.id;
for (const slug of ["personality_test", "glasser_5_needs_test"]) { for (const slug of ["personality_test", "glasser_5_needs_test"]) {
try { 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); const progress = getAssessmentLocalProgress(draft, false);
if (progress > 0) next.set(slug, progress); if (progress > 0) next.set(slug, progress);
} catch { } catch {
@ -126,7 +139,7 @@ export default function QuestionsListClient() {
} }
} }
setLocalAssessmentProgress(next); setLocalAssessmentProgress(next);
}, [overview]);
}, [overview, profile?.id]);
const sectionProgressBySlug = useMemo(() => { const sectionProgressBySlug = useMemo(() => {
const progressBySlug = new Map<string, number>(); const progressBySlug = new Map<string, number>();
@ -237,23 +250,19 @@ export default function QuestionsListClient() {
const syncPromiseRef = useRef<Promise<void> | null>(null); const syncPromiseRef = useRef<Promise<void> | null>(null);
const syncPendingAnswers = useCallback(async () => { const syncPendingAnswers = useCallback(async () => {
if (!overview) return;
if (!overview || !profile?.id || profile?.can_edit_profile === false) return;
if (syncPromiseRef.current) { if (syncPromiseRef.current) {
return syncPromiseRef.current; return syncPromiseRef.current;
} }
const profileId = profile.id;
const task = (async () => { const task = (async () => {
const pendingSections: Array<{ const pendingSections: Array<{
storageKey: string;
storedValue: {
current_step: number;
fields: MarriageField[];
pending_keys: string[];
pending_sync: boolean;
};
fields: MarriageField[];
slug: string; slug: string;
fields: MarriageField[];
}> = []; }> = [];
for (const item of questionListItems) { for (const item of questionListItems) {
if ( if (
item.slug === "personality_test" || item.slug === "personality_test" ||
@ -261,60 +270,36 @@ export default function QuestionsListClient() {
) { ) {
continue; 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<string>(
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; 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 { } finally {
syncPromiseRef.current = null; syncPromiseRef.current = null;
} }
}, [locale, overview, queryClient, questionListItems]);
}, [locale, overview, profile?.id, profile?.can_edit_profile, queryClient, questionListItems]);
const prefetchSection = useCallback( const prefetchSection = useCallback(
(item: QuestionListItem) => { (item: QuestionListItem) => {
const href = localizePath(`/questions-list/${item.slug}`, locale);
router.prefetch(href);
if ( if (
item.slug === "personality_test" || item.slug === "personality_test" ||
item.slug === "glasser_5_needs_test" item.slug === "glasser_5_needs_test"
@ -342,36 +325,6 @@ export default function QuestionsListClient() {
staleTime: 30 * 1000, staleTime: 30 * 1000,
}); });
}, },
[locale, queryClient, router],
);
const viewportPrefetchChain = useRef(Promise.resolve());
const viewportPrefetchSlugs = useRef(new Set<string>());
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], [locale, queryClient],
); );
@ -400,18 +353,27 @@ export default function QuestionsListClient() {
if (profileSections.length === 0) return; if (profileSections.length === 0) return;
prefetchQueueStarted.current = true; prefetchQueueStarted.current = true;
let cancelled = false; 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 () => { return () => {
cancelled = true; cancelled = true;
if (typeof cancelIdleCallback === "function" && typeof idle === "number") {
cancelIdleCallback(idle);
}
}; };
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]); }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]);
@ -685,6 +647,7 @@ export default function QuestionsListClient() {
/> />
) : null} ) : null}
<SectionsRequest sections={overview?.sections} /> <SectionsRequest sections={overview?.sections} />
{process.env.NODE_ENV === "development" ? <DevTapInstrumentation /> : null}
<PageBackground disabled /> <PageBackground disabled />
<main <main
@ -739,7 +702,6 @@ export default function QuestionsListClient() {
item={item} item={item}
progress={sectionProgressBySlug.get(item.slug) ?? null} progress={sectionProgressBySlug.get(item.slug) ?? null}
onInfoClick={(section) => setSelectedSection(section)} onInfoClick={(section) => setSelectedSection(section)}
onNearViewport={enqueueViewportPrefetch}
onPrefetch={prefetchSection} onPrefetch={prefetchSection}
/> />
))} ))}

99
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}</>;
}

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

217
src/components/Componentes/question-answer-storage.tsx

@ -27,6 +27,13 @@ import {
} from "@/hooks/marriage/use-section-data"; } from "@/hooks/marriage/use-section-data";
import { getApiRequestUrl } from "@/lib/http"; import { getApiRequestUrl } from "@/lib/http";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import {
getScopedSectionDraftKey,
readScopedSectionDraft,
removeScopedSectionDraft,
writeScopedSectionDraft,
type ScopedPendingField,
} from "@/lib/user-scoped-storage";
const STORAGE_VERSION = 2; const STORAGE_VERSION = 2;
@ -343,53 +350,105 @@ export function QuestionAnswersProvider({
storageKeyRef.current = storageKey; storageKeyRef.current = storageKey;
slugRef.current = slug; 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) { if (serverSectionData?.data) {
// Step 1: Server is canonical
const serverAnswers = fieldsToAnswers(serverSectionData.data); 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<string>();
// 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<string, ScopedPendingField> = {};
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<NodeJS.Timeout | null>(null); const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -426,15 +485,26 @@ export function QuestionAnswersProvider({
hasPendingSyncRef.current = true; hasPendingSyncRef.current = true;
answersRevisionRef.current += 1; answersRevisionRef.current += 1;
dirtyKeysRef.current.add(field.key); 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<string, ScopedPendingField> = {};
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) { if (syncTimeoutRef.current !== null) {
clearTimeout(syncTimeoutRef.current); clearTimeout(syncTimeoutRef.current);
@ -552,17 +622,13 @@ export function QuestionAnswersProvider({
hasPendingSyncRef.current = false; hasPendingSyncRef.current = false;
setHasPendingSync(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); const flushAnswersRef = useRef(flushAnswers);
@ -638,15 +704,30 @@ export function QuestionAnswersProvider({
const stillPending = dirtyKeysRef.current.size > 0; const stillPending = dirtyKeysRef.current.size > 0;
hasPendingSyncRef.current = stillPending; hasPendingSyncRef.current = stillPending;
setHasPendingSync(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<string, ScopedPendingField> = {};
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({ void queryClient.invalidateQueries({
queryKey: marriageQueryKeys.profile(), queryKey: marriageQueryKeys.profile(),

1
src/components/Componentes/question-card.tsx

@ -72,7 +72,6 @@ export function QuestionCard({
aria-label={t["Open {title}"].replace("{title}", item.title)} 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]" className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]"
onFocus={() => onPrefetch?.(item)} onFocus={() => onPrefetch?.(item)}
onPointerDown={() => onPrefetch?.(item)}
onPointerEnter={() => onPrefetch?.(item)} onPointerEnter={() => onPrefetch?.(item)}
> >
<article <article

18
src/components/Componentes/test-questions-flow.tsx

@ -169,12 +169,26 @@ export default function TestQuestionsFlow({
useEffect(() => { useEffect(() => {
if (!draftStorageKey) return; if (!draftStorageKey) return;
try { 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( window.localStorage.setItem(
draftStorageKey, draftStorageKey,
JSON.stringify({ answers, currentIndex, totalQuestions }),
JSON.stringify({
answers,
currentIndex,
totalQuestions,
...(ownerProfileId !== undefined ? { ownerProfileId } : {}),
...(version !== undefined ? { version } : {}),
...(slug !== undefined ? { slug } : {}),
}),
); );
} catch {} } catch {}
}, [answers, currentIndex, draftStorageKey]);
}, [answers, currentIndex, draftStorageKey, totalQuestions]);
const handleOptionSelect = (value: string | number) => { const handleOptionSelect = (value: string | number) => {
if (!currentQuestion) return; if (!currentQuestion) return;

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

285
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<string, ScopedPendingField>;
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<number, string | number>;
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<string, ScopedPendingField>,
): 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<number, string | number>,
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;
}
Loading…
Cancel
Save