DocDoeAI / src /lib /student-context.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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;
}