elearning/Frontend-Learner/composables/useQuizRunner.ts

255 lines
7 KiB
TypeScript

export type QuestionStatus = 'not_started' | 'in_progress' | 'completed' | 'skipped';
export interface QuizQuestion {
id: number;
title: string;
is_skippable: boolean;
type: 'single' | 'multiple' | 'text';
options?: { id: string; label: string }[];
}
export interface AnswerState {
questionId: number;
value: string | string[] | null;
is_saved: boolean;
status: QuestionStatus;
touched: boolean;
last_saved_at?: string;
}
// Mock Data
const MOCK_QUESTIONS: QuizQuestion[] = [
{
id: 1,
title: 'What is the capital of France?',
is_skippable: true,
type: 'single',
options: [
{ id: 'london', label: 'London' },
{ id: 'paris', label: 'Paris' },
{ id: 'berlin', label: 'Berlin' },
],
},
{
id: 2,
title: 'Explain the concept of closure in JavaScript.',
is_skippable: false,
type: 'text',
},
{
id: 3,
title: 'Which of the following are Vue lifecycle hooks? (Select all that apply)',
is_skippable: true,
type: 'multiple',
options: [
{ id: 'created', label: 'created' },
{ id: 'mounted', label: 'mounted' },
{ id: 'render', label: 'render' },
{ id: 'compute', label: 'compute' },
],
},
{
id: 4,
title: 'What is 2 + 2?',
is_skippable: false,
type: 'single',
options: [
{ id: '3', label: '3' },
{ id: '4', label: '4' },
{ id: '5', label: '5' },
],
}
];
export const useQuizRunner = () => {
// State (using useState for Nuxt SSR safety and persistence across component reloads if needed)
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(quizId: string) {
questions.value = [...MOCK_QUESTIONS];
currentQuestionIndex.value = 0;
answers.value = {};
lastError.value = null;
questions.value.forEach(q => {
answers.value[q.id] = {
questionId: q.id,
value: q.type === 'multiple' ? [] : 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;
// Mark as in_progress if not final state
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 };
// Required and unsaved
if (!a.is_saved) {
return { allowed: false, reason: 'This question is required and must be saved.' };
}
return { allowed: true };
}
function updateAnswer(val: string | string[] | null) {
if (!currentQuestion.value) return;
const qId = currentQuestion.value.id;
answers.value[qId].value = val;
// If modifying a completed answer, revert to in_progress until saved again?
// Yes, to enforce "Green = Saved Successfully" implies current state matches saved state.
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];
// Validation
if (currentQuestion.value.type === 'multiple') {
if (!ans.value || (ans.value as string[]).length === 0) {
lastError.value = "Please select at least one option.";
return false;
}
} else {
if (!ans.value || (ans.value as string).trim() === '') {
lastError.value = "Please provide an answer.";
return false;
}
}
loading.value = true;
lastError.value = null;
try {
await new Promise(resolve => setTimeout(resolve, 800));
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;
}
// Mark current as skipped if leaving without completion
const currQ = currentQuestion.value;
const currAns = answers.value[currQ.id];
// If we leave and it is NOT completed (and implicit skippable check passed), set SKIPPED
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;
}
async function nextQuestion() {
if (isLastQuestion.value) return;
handleLeaveLogic(currentQuestionIndex.value + 1);
}
async function prevQuestion() {
if (isFirstQuestion.value) return;
handleLeaveLogic(currentQuestionIndex.value - 1);
}
async function goToQuestion(index: number) {
if (index < 0 || index >= questions.value.length) return;
handleLeaveLogic(index);
}
async function skipQuestion() {
if (isLastQuestion.value) {
// If last question and skip... maybe just mark skipped?
const currQ = currentQuestion.value;
const currAns = answers.value[currQ.id];
currAns.status = 'skipped';
return;
}
handleLeaveLogic(currentQuestionIndex.value + 1);
}
return {
questions,
answers,
currentQuestionIndex,
loading,
lastError,
currentQuestion,
currentAnswer,
totalQuestions,
isFirstQuestion,
isLastQuestion,
initQuiz,
updateAnswer,
saveCurrentAnswer,
nextQuestion,
prevQuestion,
goToQuestion,
skipQuestion
};
};