DocDoeAI / src /components /tuition /chapter-dashboard /classSessionProgress.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
5.8 kB
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<string, string>;
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<TuitionSessionData, "board" | "subject" | "chapterId" | "missionId">;
/** 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,
};
}