import type { TuitionLessonStepId } from "./classSessionData"; import { tuitionLessonSteps } from "./classSessionData"; import type { TuitionSessionData } from "./sessionData"; /** * Tuition class session progress. * * The 11-step guided class (TuitionClassSessionView) held all progress in * ephemeral React state, so a refresh lost everything. This persists the * resumable parts keyed by the class session, so /tuition restores where the * student left off. localStorage is the offline source of truth; the backend * (classSessionProgressApi) syncs it across devices. */ const VALID_STEP_IDS = new Set(tuitionLessonSteps.map((step) => step.id)); /** Map legacy step ids (e.g. "recap") onto the current continuous flow. */ export function normalizeLessonStepId(stepId: string | undefined | null): TuitionLessonStepId { if (stepId === "recap") return "save-revision"; if (stepId && VALID_STEP_IDS.has(stepId as TuitionLessonStepId)) { return stepId as TuitionLessonStepId; } return tuitionLessonSteps[0].id; } export type ClassSessionProgressState = { started: boolean; activeStepId: TuitionLessonStepId; maxUnlockedIndex: number; recallAnswer: string; recallSubmitted: boolean; practiceAnswer: string; practiceSubmitted: boolean; quizAnswers: Record; quizSubmitted: boolean; revisionSaved: boolean; trickSaved: boolean; assessmentAttemptId?: string | null; lessonProgressId?: string | null; /** Steps the student explicitly completed in the class flow (not mastery). */ completedStepIds?: TuitionLessonStepId[]; /** Authoritative session id/revision when this is an authenticated class. */ tuitionSessionId?: string | null; tuitionSessionRevision?: number | null; writtenAttemptId?: string | null; /** Number of authoritative written submissions for this class. */ writtenAttemptCount?: number; /** Adaptive checkpoint loop state (runId keeps replays idempotent). */ checkpointState?: { runId: string; questions: Record< string, { phase: "answering" | "hint_retry" | "explained" | "transfer" | "done"; attempt: number; correct: boolean | null; transferCorrect: boolean | null; errorCategory: string | null; } >; } | null; }; export type ClassSessionProgress = ClassSessionProgressState & { classSessionId: string; updatedAt: string; }; export const CLASS_SESSION_PROGRESS_PREFIX = "docdoe:tuition-class-progress:"; type SessionIdParts = Pick; /** Stable id for a class session, used as the persistence key. */ export function classSessionId(session: SessionIdParts): string { return [session.board ?? "", session.subject ?? "", session.chapterId ?? "chapter", session.missionId ?? "mission"] .map((part) => part.trim()) .join("|"); } export function readClassSessionProgress(id: string): ClassSessionProgress | null { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(CLASS_SESSION_PROGRESS_PREFIX + id); return raw ? (JSON.parse(raw) as ClassSessionProgress) : null; } catch { return null; } } export function writeClassSessionProgress(id: string, state: ClassSessionProgressState): ClassSessionProgress { const payload: ClassSessionProgress = { ...state, classSessionId: id, updatedAt: new Date().toISOString() }; if (typeof window !== "undefined") { try { window.localStorage.setItem(CLASS_SESSION_PROGRESS_PREFIX + id, JSON.stringify(payload)); } catch { // Storage is a convenience layer; the backend copy remains authoritative. } } return payload; } /** Merge a stored snapshot into the default state (used to seed React state). */ export function applyClassSessionProgress( defaults: ClassSessionProgressState, saved: ClassSessionProgress | null, ): ClassSessionProgressState { if (!saved) return defaults; const activeStepId = normalizeLessonStepId(saved.activeStepId ?? defaults.activeStepId); const maxStepIndex = Math.max(0, tuitionLessonSteps.length - 1); const maxUnlockedIndex = Math.max( 0, Math.min(maxStepIndex, saved.maxUnlockedIndex ?? defaults.maxUnlockedIndex), ); // Empty practice answers are never treated as submitted after refresh. const practiceAnswer = saved.practiceAnswer ?? defaults.practiceAnswer; const practiceSubmitted = Boolean(saved.practiceSubmitted ?? defaults.practiceSubmitted) && practiceAnswer.trim().length > 0; return { started: saved.started ?? defaults.started, activeStepId, maxUnlockedIndex, recallAnswer: saved.recallAnswer ?? defaults.recallAnswer, recallSubmitted: saved.recallSubmitted ?? defaults.recallSubmitted, practiceAnswer, practiceSubmitted, quizAnswers: saved.quizAnswers ?? defaults.quizAnswers, quizSubmitted: saved.quizSubmitted ?? defaults.quizSubmitted, revisionSaved: saved.revisionSaved ?? defaults.revisionSaved, trickSaved: saved.trickSaved ?? defaults.trickSaved, assessmentAttemptId: saved.assessmentAttemptId ?? defaults.assessmentAttemptId ?? null, lessonProgressId: saved.lessonProgressId ?? defaults.lessonProgressId ?? null, completedStepIds: saved.completedStepIds ?? defaults.completedStepIds ?? [], tuitionSessionId: saved.tuitionSessionId ?? defaults.tuitionSessionId ?? null, tuitionSessionRevision: saved.tuitionSessionRevision ?? defaults.tuitionSessionRevision ?? null, writtenAttemptId: saved.writtenAttemptId ?? defaults.writtenAttemptId ?? null, writtenAttemptCount: saved.writtenAttemptCount ?? defaults.writtenAttemptCount ?? 0, checkpointState: saved.checkpointState ?? defaults.checkpointState ?? null, }; }