import { getStudyProfileRemote, saveStudyProfileRemote, type StudyProfileRemote, } from "@/lib/api/study-profile"; import { getLearningState, type LearningStateSummary, } from "@/lib/api/learning-state"; import { hasLiveSession } from "@/lib/api/mode"; import { getAuthToken, getStoredUser } from "@/lib/auth/auth-client"; import { readLocalTuitionSetup } from "@/components/tuition/tuitionSetupPersistence"; // ============================================================================= // Single source of truth for "who is this student and what are they studying". // // The app previously had three competing truths — the backend study profile, // the localStorage onboarding payload, and the tuition setup — and pages // disagreed about whether onboarding was complete (Home keyed on // `study_focus.subject`, which onboarding never collects). Every consumer // (Home focus card, StudyChat session context, Progress, Tuition entry) should // read THIS module instead of the raw stores. // // Field-mapping note (legacy, do not "fix" silently): the onboarding flow // stores dailyTime in the profile's `primary_need` column and preferredTime in // `language_preference`. That mapping is quarantined here so consumers only // ever see `dailyTime` / `preferredTime`. // ============================================================================= export const ONBOARDING_COMPLETED_KEY = "docdoe-onboarding-completed"; export const ONBOARDING_PROFILE_KEY = "docdoe-onboarding-profile"; /** * Which account the device-local onboarding cache belongs to. Without this * tag, a shared computer leaks one student's identity into the next: the old * local profile renders in the header AND `repairRemoteProfile` pushes it up * into the newly signed-in account's backend profile. */ export const ONBOARDING_OWNER_KEY = "docdoe-onboarding-owner"; function currentUserId(): string | null { const stored = getStoredUser(); if (stored?.id) return stored.id; const token = getAuthToken(); if (!token) return null; try { const payload = JSON.parse( atob((token.split(".")[1] ?? "").replace(/-/g, "+").replace(/_/g, "/")), ) as { sub?: unknown }; return typeof payload.sub === "string" ? payload.sub : null; } catch { return null; } } /** * Tag the device-local onboarding cache as belonging to the signed-in * account. Call after writing ONBOARDING_PROFILE_KEY on behalf of a user. */ export function claimLocalOnboardingOwnership(): void { if (typeof window === "undefined") return; const uid = currentUserId(); if (uid) window.localStorage.setItem(ONBOARDING_OWNER_KEY, uid); } /** * True when the device-local onboarding cache is safe to use for the current * viewer. Logged out: any local data is the device user's own. Signed in: the * cache must be tagged with this exact account — an untagged or foreign cache * is another student's data and must be ignored (and never pushed remote). */ function localOnboardingUsable(): boolean { if (typeof window === "undefined") return false; const uid = currentUserId(); if (!uid) return true; return window.localStorage.getItem(ONBOARDING_OWNER_KEY) === uid; } export type StudentContext = { /** Where the strongest signal came from. */ source: "remote" | "local" | "none"; /** True when the backend has a saved profile (survives device changes). */ synced: boolean; onboardingComplete: boolean; classLevel: string | null; board: string | null; goal: string | null; examDate: string | null; dailyTime: string | null; preferredTime: string | null; subjects: string[]; languagePreference: string | null; learningStyle: string | null; /** Active subject — from the tuition setup or the saved profile. */ subject: string | null; /** Onboarding "what should DocDoe focus on" picks. Not evidence-based weak topics. */ focusAreas: string[]; }; export const EMPTY_STUDENT_CONTEXT: StudentContext = { source: "none", synced: false, onboardingComplete: false, classLevel: null, board: null, goal: null, examDate: null, dailyTime: null, preferredTime: null, subjects: [], languagePreference: null, learningStyle: null, subject: null, focusAreas: [], }; type LocalOnboarding = { goal?: string; class?: string; board?: string; examDate?: string; dailyTime?: string; preferredTime?: string; subjects?: string[]; languagePreference?: string; learningStyle?: string; focus?: string[]; }; function readLocalOnboarding(): { completed: boolean; data: LocalOnboarding | null; } { if (typeof window === "undefined") return { completed: false, data: null }; const completed = window.localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true"; try { const raw = window.localStorage.getItem(ONBOARDING_PROFILE_KEY); return { completed, data: raw ? (JSON.parse(raw) as LocalOnboarding) : null, }; } catch { return { completed, data: null }; } } function localSubject(): string | null { const setup = readLocalTuitionSetup(); return setup.activeSubject || setup.subjects[0] || null; } /** * Synchronous context from local stores only — safe for first paint. * `synced` is always false here; call loadStudentContext() for the real merge. */ export function getLocalStudentContext(): StudentContext { if (!localOnboardingUsable()) return EMPTY_STUDENT_CONTEXT; const { completed, data } = readLocalOnboarding(); if (!completed && !data) return EMPTY_STUDENT_CONTEXT; return { source: "local", synced: false, onboardingComplete: completed, classLevel: data?.class || null, board: data?.board || null, goal: data?.goal || null, examDate: data?.examDate || null, dailyTime: data?.dailyTime || null, preferredTime: data?.preferredTime || null, subject: data?.subjects?.[0] || localSubject(), subjects: data?.subjects?.filter(Boolean) ?? [], languagePreference: data?.languagePreference || null, learningStyle: data?.learningStyle || null, focusAreas: data?.focus ?? [], }; } function extraString( extra: Record, key: string, ): string | null { return typeof extra[key] === "string" && extra[key] ? String(extra[key]) : null; } function extraStrings(extra: Record, key: string): string[] { return Array.isArray(extra[key]) ? (extra[key] as unknown[]).filter( (value): value is string => typeof value === "string" && Boolean(value), ) : []; } function contextFromRemote( profile: StudyProfileRemote, local: StudentContext, ): StudentContext { const extra = profile.extra ?? {}; const subjects = extraStrings(extra, "subjects"); const focusAreas = extraStrings(extra, "focus_areas"); return { source: "remote", synced: true, // Backend truth wins; a locally-completed onboarding still counts. onboardingComplete: profile.onboarding_completed || local.onboardingComplete, classLevel: profile.grade ?? local.classLevel, board: profile.board ?? local.board, goal: profile.goal ?? local.goal, examDate: profile.time_left ?? local.examDate, dailyTime: extraString(extra, "daily_time") ?? profile.primary_need ?? local.dailyTime, preferredTime: extraString(extra, "preferred_time") ?? local.preferredTime ?? profile.language_preference, subject: profile.subject ?? subjects[0] ?? local.subject, subjects: subjects.length ? subjects : profile.subject ? [profile.subject] : local.subjects, languagePreference: profile.language_preference ?? extraString(extra, "language_preference") ?? local.languagePreference, learningStyle: extraString(extra, "learning_style") ?? local.learningStyle, focusAreas: focusAreas.length ? focusAreas : profile.weak_areas?.length ? profile.weak_areas : local.focusAreas, }; } function dailyTimeFromMinutes(minutes: number): string | null { if (!Number.isFinite(minutes) || minutes <= 0) return null; return minutes === 120 ? "2 hours" : `${minutes} min`; } /** * Phase 4 learning state owns the active planning profile. The older study * profile still carries a few optional preferences, so it remains a fallback * rather than a competing completion signal. */ function contextFromLearningState( state: LearningStateSummary, fallback: StudentContext, ): StudentContext { const profile = state.profile; if (!profile) return fallback; const subjects = state.subjects.map((item) => item.name).filter(Boolean); const currentSubject = state.subjects.find((item) => item.id === profile.current_subject_id)?.name ?? subjects[0] ?? fallback.subject; return { ...fallback, source: "remote", synced: true, onboardingComplete: profile.onboarding_completed || fallback.onboardingComplete, classLevel: profile.class_level || fallback.classLevel, board: profile.board || fallback.board, goal: profile.goal || fallback.goal, examDate: profile.exam_date ?? fallback.examDate, dailyTime: dailyTimeFromMinutes(profile.daily_minutes) ?? fallback.dailyTime, preferredTime: profile.preferred_time ?? fallback.preferredTime, subjects: subjects.length ? subjects : fallback.subjects, subject: currentSubject, }; } function cacheCompletedContext(context: StudentContext): void { if (typeof window === "undefined" || !context.onboardingComplete) return; window.localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true"); claimLocalOnboardingOwnership(); window.localStorage.setItem( ONBOARDING_PROFILE_KEY, JSON.stringify({ goal: context.goal ?? "", class: context.classLevel ?? "", board: context.board ?? "", examDate: context.examDate ?? "", dailyTime: context.dailyTime ?? "", preferredTime: context.preferredTime ?? "", subjects: context.subjects, languagePreference: context.languagePreference ?? "", learningStyle: context.learningStyle ?? "", focus: context.focusAreas, } satisfies LocalOnboarding), ); } /** * The full merge: backend profile (when signed in) over local onboarding. * * Self-heals the split-truth bug: if onboarding was completed on this device * but the backend has no (or an incomplete) profile — e.g. the account * predates the write-through, or the original save failed — the local profile * is pushed up once, fire-and-forget, so every other device converges. */ // Short-lived cache so navigating between pages doesn't refetch the profile // on every mount — the context changes rarely (onboarding, settings), and a // snappy page-to-page feel matters more than 30s-stale sync metadata. const CONTEXT_CACHE_MS = 30_000; let contextCache: { at: number; userId: string | null; value: StudentContext } | null = null; export async function loadStudentContext(): Promise { const local = getLocalStudentContext(); if (!hasLiveSession()) return local; const viewerId = currentUserId(); if ( contextCache && contextCache.userId === viewerId && Date.now() - contextCache.at < CONTEXT_CACHE_MS ) { return contextCache.value; } const merged = await loadStudentContextUncached(local); contextCache = { at: Date.now(), userId: viewerId, value: merged }; return merged; } /** Drop the cache after anything that changes the profile (e.g. onboarding). */ export function invalidateStudentContext(): void { contextCache = null; remoteOnboardingCheck = null; } async function loadStudentContextUncached( local: StudentContext, ): Promise { const [remote, learningState] = await Promise.all([ getStudyProfileRemote().catch(() => null), getLearningState().catch(() => null), ]); const legacyContext = remote ? contextFromRemote(remote, local) : local; const merged = learningState ? contextFromLearningState(learningState, legacyContext) : legacyContext; if (remote || learningState?.profile) { cacheCompletedContext(merged); if (merged.onboardingComplete && !remote?.onboarding_completed) { void repairRemoteProfile(merged); } return merged; } // Signed in but no backend profile: repair from local when we can. if (local.onboardingComplete) { const saved = await repairRemoteProfile(local); return { ...local, synced: saved }; } return local; } async function repairRemoteProfile(local: StudentContext): Promise { return saveStudyProfileRemote({ goal: local.goal, grade: local.classLevel, board: local.board, time_left: local.examDate, primary_need: local.dailyTime, language_preference: local.languagePreference, subject: local.subject, weak_areas: [], onboarding_completed: true, extra: { schema_version: 2, subjects: local.subjects, daily_time: local.dailyTime, preferred_time: local.preferredTime, language_preference: local.languagePreference, learning_style: local.learningStyle, focus_areas: local.focusAreas, }, }); } /** One-line summary for focus cards, e.g. "SSLC / 10th · Kerala State Board". */ export function studentContextHeadline(ctx: StudentContext): string | null { const parts = [ctx.classLevel, ctx.board].filter(Boolean); return parts.length ? parts.join(" · ") : null; } // ── Onboarding gate ────────────────────────────────────────────────────────── // The route guard used to trust localStorage alone, so a signed-in student on // a fresh device (backend profile complete, local flag absent) was forced back // through onboarding. This checks the backend once per page load and backfills // the local cache so the fast path works next time. let remoteOnboardingCheck: { userId: string | null; promise: Promise; } | null = null; export function isOnboardingCompleteEffective(): Promise { if ( typeof window !== "undefined" && window.localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true" && localOnboardingUsable() ) { return Promise.resolve(true); } if (!hasLiveSession()) return Promise.resolve(false); const viewerId = currentUserId(); if (!remoteOnboardingCheck || remoteOnboardingCheck.userId !== viewerId) { const promise = Promise.all([ getStudyProfileRemote().catch(() => null), getLearningState().catch(() => null), ]).then(([legacyProfile, learningState]) => { const local = getLocalStudentContext(); const legacyContext = legacyProfile ? contextFromRemote(legacyProfile, local) : local; const merged = learningState ? contextFromLearningState(learningState, legacyContext) : legacyContext; cacheCompletedContext(merged); return merged.onboardingComplete; }); remoteOnboardingCheck = { userId: viewerId, promise }; } return remoteOnboardingCheck.promise; }