asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
5.57 kB
/**
* 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<string, string>;
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";
}
}