/** * Pure helpers for the continuous class step flow. * Step ids stay stable for localStorage resume; labels match product language. */ import type { TuitionLessonStepId } from "./classSessionData"; import { tuitionLessonSteps } from "./classSessionData"; export type ClassStepGateState = { practiceAnswer: string; practiceSubmitted: boolean; quizAnswers: Record; quizSubmitted: boolean; }; /** Marks lost must never exceed maxMarks. */ export function clampMarksLost(score: number, maxMarks: number): number { if (!Number.isFinite(maxMarks) || maxMarks <= 0) return 0; if (!Number.isFinite(score)) return maxMarks; const safeScore = Math.max(0, Math.min(maxMarks, score)); return Math.max(0, Math.min(maxMarks, maxMarks - safeScore)); } /** Score displayed to the student is always within [0, maxMarks]. */ export function clampScoreEstimate(score: number, maxMarks: number): number { if (!Number.isFinite(maxMarks) || maxMarks <= 0) return 0; if (!Number.isFinite(score)) return 0; return Math.max(0, Math.min(maxMarks, Math.round(score))); } /** * Required-answer gate: Your Turn cannot be treated complete without a real answer. * Empty or whitespace-only answers never count as submitted. */ export function isPracticeAnswerComplete(answer: string, submitted: boolean): boolean { return submitted && answer.trim().length > 0; } /** * Whether the student may leave the current step for the next one. * Session Plan / Teach / Video / Visual / Methods / Notes / Exam are free to continue. * Your Turn requires a submitted non-empty practice answer. * Quiz requires submitted, non-empty answers before it can be completed. */ export function canAdvanceFromStep(stepId: TuitionLessonStepId, state: ClassStepGateState): boolean { if (stepId === "your-turn") { return isPracticeAnswerComplete(state.practiceAnswer, state.practiceSubmitted); } if (stepId === "quiz") { const answers = Object.values(state.quizAnswers); return state.quizSubmitted && answers.length > 0 && answers.every((answer) => answer.trim().length > 0); } return true; } export function advanceBlockedReason(stepId: TuitionLessonStepId, state: ClassStepGateState): string | null { if (stepId === "your-turn" && !isPracticeAnswerComplete(state.practiceAnswer, state.practiceSubmitted)) { return "Write and submit your answer before continuing. Required answers cannot be skipped."; } if (stepId === "quiz" && !canAdvanceFromStep(stepId, state)) { return "Answer and submit every quiz question before continuing."; } return null; } /** Weak-area tags must come only from real mistakes (missing keywords / failed points). */ export function weakAreasFromMistakes(missingKeywords: string[], isCorrect: boolean): string[] { if (isCorrect) return []; return missingKeywords.map((tag) => tag.trim()).filter(Boolean); } export function stepIndex(stepId: TuitionLessonStepId): number { return tuitionLessonSteps.findIndex((step) => step.id === stepId); } export function productStepLabels(): string[] { return tuitionLessonSteps.map((step) => step.label); } /** True when every selected plan day subject is in the student's selected subjects. */ export function weekPlanSubjectsAreBound( daySubjects: string[], selectedSubjects: string[], ): boolean { if (selectedSubjects.length === 0) return daySubjects.length === 0; const allowed = new Set(selectedSubjects.map((s) => s.trim().toLowerCase())); return daySubjects.every((subject) => allowed.has(subject.trim().toLowerCase())); } /** Chapter context lock: practice/mission selection must not silently switch chapter. */ export function isSameChapterContext( expected: { chapterId?: string; chapter?: string }, actual: { chapterId?: string; chapter?: string }, ): boolean { if (expected.chapterId && actual.chapterId) { return expected.chapterId === actual.chapterId; } if (expected.chapter && actual.chapter) { return expected.chapter.trim().toLowerCase() === actual.chapter.trim().toLowerCase(); } return true; } /** Normalize video pipeline status for student UI — never invent progress. */ export type HonestVideoUiStatus = | "queued" | "rendering" | "ready" | "failed" | "unavailable" | "plan_only"; export function mapVideoJobStatus(status: string | null | undefined): HonestVideoUiStatus { const value = (status ?? "").toLowerCase().trim(); if (!value || value === "unavailable" || value === "not_available" || value === "not_generated") return "unavailable"; if (value === "plan_only" || value === "renderer_coming_later") return "plan_only"; if (value === "ready" || value === "complete" || value === "completed") return "ready"; if (value === "failed" || value === "error" || value === "qa_failed") return "failed"; if (value === "package_ready") return "queued"; if ( value === "rendering" || value === "running" || value === "audio_generating" || value === "visuals_generating" || value === "uploading" || value === "planning" || value === "script_ready" ) { return "rendering"; } if (value === "queued") return "queued"; return "unavailable"; } export function videoStatusLabel(status: HonestVideoUiStatus): string { switch (status) { case "queued": return "Queued"; case "rendering": return "Rendering"; case "ready": return "Ready"; case "failed": return "Failed"; case "plan_only": return "Plan only"; case "unavailable": default: return "Unavailable"; } }