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";
  }
}