elearning/Frontend-Learner/composables/useQuizRunner.ts

176 lines
4.7 KiB
TypeScript
Raw Normal View History

export type QuestionStatus = 'not_started' | 'in_progress' | 'completed' | 'skipped';
export interface QuizQuestion {
id: number;
question: string | { th: string; en: string };
is_skippable: boolean;
type: string;
choices?: { id: number; text: string | { th: string; en: string } }[];
}
export interface AnswerState {
questionId: number;
value: any;
is_saved: boolean;
status: QuestionStatus;
touched: boolean;
last_saved_at?: string;
}
/**
* @composable useQuizRunner
* @description Manages the state and logic for running a quiz activity.
*/
export const useQuizRunner = () => {
// State
const questions = useState<QuizQuestion[]>('quiz-questions', () => []);
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({}));
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0);
const loading = useState<boolean>('quiz-loading', () => false);
const lastError = useState<string | null>('quiz-error', () => null);
// Getters
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]);
const currentAnswer = computed(() => {
if (!currentQuestion.value) return null;
return answers.value[currentQuestion.value.id];
});
const totalQuestions = computed(() => questions.value.length);
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1);
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0);
// Actions
function initQuiz(quizData: any) {
if (!quizData || !quizData.questions) return;
questions.value = quizData.questions;
currentQuestionIndex.value = 0;
answers.value = {};
lastError.value = null;
questions.value.forEach(q => {
answers.value[q.id] = {
questionId: q.id,
value: null,
is_saved: false,
status: 'not_started',
touched: false,
};
});
if (questions.value.length > 0) {
enterQuestion(questions.value[0].id);
}
}
function enterQuestion(qId: number) {
const ans = answers.value[qId];
if (ans) {
ans.touched = true;
if (ans.status === 'not_started' || ans.status === 'skipped') {
ans.status = 'in_progress';
}
}
}
function canLeaveCurrent(): { allowed: boolean; reason?: string } {
if (!currentQuestion.value) return { allowed: true };
const q = currentQuestion.value;
const a = answers.value[q.id];
if (a.status === 'completed' || a.is_saved) return { allowed: true };
if (q.is_skippable) return { allowed: true };
if (!a.is_saved && a.value === null) {
return { allowed: false, reason: 'This question is required.' };
}
return { allowed: true };
}
function updateAnswer(val: any) {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
answers.value[qId].value = val;
if (answers.value[qId].is_saved) {
answers.value[qId].is_saved = false;
answers.value[qId].status = 'in_progress';
}
}
async function saveCurrentAnswer() {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
const ans = answers.value[qId];
if (ans.value === null) {
lastError.value = "Please provide an answer.";
return false;
}
loading.value = true;
lastError.value = null;
try {
ans.is_saved = true;
ans.status = 'completed';
ans.last_saved_at = new Date().toISOString();
return true;
} catch (e) {
lastError.value = "Failed to save answer.";
return false;
} finally {
loading.value = false;
}
}
function handleLeaveLogic(targetIndex: number) {
if (targetIndex === currentQuestionIndex.value) return;
const check = canLeaveCurrent();
if (!check.allowed) {
lastError.value = check.reason || "Required question.";
return false;
}
const currQ = currentQuestion.value;
if (currQ) {
const currAns = answers.value[currQ.id];
if (currAns.status !== 'completed' && !currAns.is_saved) {
currAns.status = 'skipped';
}
}
currentQuestionIndex.value = targetIndex;
lastError.value = null;
if (questions.value[targetIndex]) {
enterQuestion(questions.value[targetIndex].id);
}
return true;
}
return {
questions,
answers,
currentQuestionIndex,
loading,
lastError,
currentQuestion,
currentAnswer,
totalQuestions,
isFirstQuestion,
isLastQuestion,
initQuiz,
updateAnswer,
saveCurrentAnswer,
nextQuestion: () => handleLeaveLogic(currentQuestionIndex.value + 1),
prevQuestion: () => handleLeaveLogic(currentQuestionIndex.value - 1),
goToQuestion: (index: number) => handleLeaveLogic(index)
};
};