Download src/lib/student-context.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/student-context.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/student-context.ts
-
curl -L -o student-context.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/student-context.ts
15.1 kB
| 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<string, unknown>, | |
| key: string, | |
| ): string | null { | |
| return typeof extra[key] === "string" && extra[key] | |
| ? String(extra[key]) | |
| : null; | |
| } | |
| function extraStrings(extra: Record<string, unknown>, 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<StudentContext> { | |
| 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<StudentContext> { | |
| 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<boolean> { | |
| 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<boolean>; | |
| } | null = null; | |
| export function isOnboardingCompleteEffective(): Promise<boolean> { | |
| 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; | |
| } | |