File size: 5,565 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | /**
* 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";
}
}
|