284 lines
8.9 KiB
TypeScript
284 lines
8.9 KiB
TypeScript
import { create } from 'zustand'
|
|
import { persist, StateStorage, createJSONStorage } from 'zustand/middleware'
|
|
import { get, set, del } from 'idb-keyval'
|
|
|
|
// Custom IndexedDB storage engine for Zustand
|
|
const idbStorage: StateStorage = {
|
|
getItem: async (name: string): Promise<string | null> => {
|
|
return (await get(name)) || null
|
|
},
|
|
setItem: async (name: string, value: string): Promise<void> => {
|
|
await set(name, value)
|
|
},
|
|
removeItem: async (name: string): Promise<void> => {
|
|
await del(name)
|
|
},
|
|
}
|
|
|
|
export type QuestionStatus = 'UNVISITED' | 'NOT_ANSWERED' | 'ANSWERED' | 'MARKED' | 'ANSWERED_MARKED'
|
|
|
|
export interface AnswerState {
|
|
question_id: string
|
|
selected_answer: string | null
|
|
status: QuestionStatus
|
|
time_spent: number // in seconds
|
|
}
|
|
|
|
export interface ExamAttemptPayload {
|
|
id: string
|
|
user_id: string
|
|
source_type: 'DPS' | 'Test' | 'MicroTest'
|
|
source_id: string
|
|
started_at: string
|
|
submitted_at: string
|
|
time_taken: number
|
|
answers: Array<{
|
|
question_id: string
|
|
selected_answer: string | null
|
|
is_correct: boolean // Typically evaluated on server, but requested in payload structure
|
|
time_spent: number
|
|
}>
|
|
score: number
|
|
total_questions: number
|
|
accuracy: number
|
|
status: 'In_Progress' | 'Submitted' | 'Timed_Out'
|
|
}
|
|
|
|
interface CBTState {
|
|
examId: string | null
|
|
status: 'IN_PROGRESS' | 'SUBMITTED' | 'TIMED_OUT'
|
|
startTime: number | null
|
|
totalDuration: number // in seconds
|
|
questions: any[]
|
|
answers: Record<string, AnswerState>
|
|
currentQuestionIndex: number
|
|
|
|
// Actions
|
|
initExam: (examId: string, questions: any[], duration: number) => void
|
|
selectAnswer: (questionId: string, answer: string) => void
|
|
clearResponse: (questionId: string) => void
|
|
markForReview: (questionId: string) => void
|
|
goToQuestion: (index: number) => void
|
|
submitExam: (reason?: 'SUBMITTED' | 'TIMED_OUT') => void
|
|
addTimeSpent: (questionId: string, seconds: number) => void
|
|
getAttemptPayload: (userId: string) => ExamAttemptPayload | null
|
|
submitAttemptToServer: (userId: string) => Promise<void>
|
|
resetExamState: () => void
|
|
}
|
|
|
|
export const useCBTStore = create<CBTState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
examId: null,
|
|
status: 'IN_PROGRESS',
|
|
startTime: null,
|
|
totalDuration: 0,
|
|
questions: [],
|
|
answers: {},
|
|
currentQuestionIndex: 0,
|
|
|
|
initExam: (examId, questions, duration) => {
|
|
const currentExamId = get().examId;
|
|
const currentQuestions = get().questions;
|
|
|
|
// Force reset if it's a new exam or the number of questions has changed
|
|
const needsReset = currentExamId !== examId || currentQuestions.length !== questions.length;
|
|
|
|
if (needsReset) {
|
|
const initialAnswers: Record<string, AnswerState> = {};
|
|
questions.forEach((q, idx) => {
|
|
initialAnswers[q.id] = {
|
|
question_id: q.id,
|
|
selected_answer: null,
|
|
status: idx === 0 ? 'NOT_ANSWERED' : 'UNVISITED',
|
|
time_spent: 0
|
|
}
|
|
})
|
|
|
|
set({
|
|
examId,
|
|
status: 'IN_PROGRESS',
|
|
startTime: Date.now(), // Server-synced timestamp ideally
|
|
totalDuration: duration,
|
|
questions,
|
|
answers: initialAnswers,
|
|
currentQuestionIndex: 0
|
|
})
|
|
} else {
|
|
// Just update questions array in case text/options changed
|
|
set({ questions, totalDuration: duration })
|
|
}
|
|
},
|
|
|
|
selectAnswer: (questionId, answer) => set((state) => {
|
|
const current = state.answers[questionId]
|
|
if (!current) return state;
|
|
|
|
let newStatus: QuestionStatus = 'ANSWERED';
|
|
if (current.status === 'MARKED' || current.status === 'ANSWERED_MARKED') {
|
|
newStatus = 'ANSWERED_MARKED';
|
|
}
|
|
|
|
return {
|
|
answers: {
|
|
...state.answers,
|
|
[questionId]: { ...current, selected_answer: answer, status: newStatus }
|
|
}
|
|
}
|
|
}),
|
|
|
|
clearResponse: (questionId) => set((state) => {
|
|
const current = state.answers[questionId]
|
|
if (!current) return state;
|
|
|
|
let newStatus: QuestionStatus = 'NOT_ANSWERED';
|
|
if (current.status === 'ANSWERED_MARKED' || current.status === 'MARKED') {
|
|
newStatus = 'MARKED';
|
|
}
|
|
|
|
return {
|
|
answers: {
|
|
...state.answers,
|
|
[questionId]: { ...current, selected_answer: null, status: newStatus }
|
|
}
|
|
}
|
|
}),
|
|
|
|
markForReview: (questionId) => set((state) => {
|
|
const current = state.answers[questionId]
|
|
if (!current) return state;
|
|
|
|
let newStatus: QuestionStatus = current.selected_answer ? 'ANSWERED_MARKED' : 'MARKED';
|
|
|
|
// If already marked, unmark it
|
|
if (current.status === 'MARKED') newStatus = 'NOT_ANSWERED';
|
|
if (current.status === 'ANSWERED_MARKED') newStatus = 'ANSWERED';
|
|
|
|
return {
|
|
answers: {
|
|
...state.answers,
|
|
[questionId]: { ...current, status: newStatus }
|
|
}
|
|
}
|
|
}),
|
|
|
|
goToQuestion: (index) => set((state) => {
|
|
if (index < 0 || index >= state.questions.length) return state;
|
|
|
|
const targetQId = state.questions[index].id;
|
|
const targetState = state.answers[targetQId];
|
|
|
|
// Update status of the target question to NOT_ANSWERED if it was UNVISITED
|
|
const newAnswers = { ...state.answers };
|
|
if (targetState.status === 'UNVISITED') {
|
|
newAnswers[targetQId] = { ...targetState, status: 'NOT_ANSWERED' };
|
|
}
|
|
|
|
return {
|
|
currentQuestionIndex: index,
|
|
answers: newAnswers
|
|
}
|
|
}),
|
|
|
|
addTimeSpent: (questionId, seconds) => set((state) => {
|
|
const current = state.answers[questionId]
|
|
if (!current) return state;
|
|
return {
|
|
answers: {
|
|
...state.answers,
|
|
[questionId]: { ...current, time_spent: current.time_spent + seconds }
|
|
}
|
|
}
|
|
}),
|
|
|
|
submitExam: (reason = 'SUBMITTED') => set({ status: reason }),
|
|
|
|
getAttemptPayload: (userId: string) => {
|
|
const state = get();
|
|
if (!state.examId || !state.startTime) return null;
|
|
|
|
const timeTaken = Math.floor((Date.now() - state.startTime) / 1000);
|
|
const answerList = Object.values(state.answers);
|
|
|
|
// Mock evaluation (In reality, server does this)
|
|
let correctCount = 0;
|
|
const finalAnswers = answerList.map(a => {
|
|
// Assuming 'A' is correct for dummy data
|
|
const isCorrect = a.selected_answer === 'A';
|
|
if (isCorrect) correctCount++;
|
|
return {
|
|
question_id: a.question_id,
|
|
selected_answer: a.selected_answer,
|
|
is_correct: isCorrect,
|
|
time_spent: a.time_spent
|
|
}
|
|
});
|
|
|
|
const totalQ = state.questions.length;
|
|
const payload: ExamAttemptPayload = {
|
|
id: crypto.randomUUID(), // Mock Attempt ID
|
|
user_id: userId,
|
|
source_type: 'Test', // Or DPS based on exam metadata
|
|
source_id: state.examId,
|
|
started_at: new Date(state.startTime).toISOString(),
|
|
submitted_at: new Date().toISOString(),
|
|
time_taken: timeTaken,
|
|
answers: finalAnswers,
|
|
score: correctCount * 4, // Assuming +4 logic
|
|
total_questions: totalQ,
|
|
accuracy: totalQ > 0 ? (correctCount / totalQ) * 100 : 0,
|
|
status: state.status === 'IN_PROGRESS' ? 'Submitted' : (state.status === 'TIMED_OUT' ? 'Timed_Out' : 'Submitted')
|
|
}
|
|
|
|
return payload;
|
|
},
|
|
|
|
submitAttemptToServer: async (userId: string) => {
|
|
const payload = get().getAttemptPayload(userId);
|
|
if (!payload) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/attempts/submit`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
console.error("Failed to submit attempt to server", await res.text());
|
|
} else {
|
|
console.log("Successfully submitted attempt to server");
|
|
}
|
|
} catch (error) {
|
|
console.error("Network error during submission:", error);
|
|
}
|
|
},
|
|
|
|
resetExamState: () => {
|
|
set({
|
|
examId: null,
|
|
status: 'IN_PROGRESS',
|
|
startTime: null,
|
|
totalDuration: 0,
|
|
questions: [],
|
|
answers: {},
|
|
currentQuestionIndex: 0
|
|
});
|
|
}
|
|
}),
|
|
{
|
|
name: 'cbt-storage', // unique name
|
|
storage: createJSONStorage(() => idbStorage), // use custom IndexedDB storage
|
|
partialize: (state) => ({
|
|
examId: state.examId,
|
|
status: state.status,
|
|
startTime: state.startTime,
|
|
totalDuration: state.totalDuration,
|
|
questions: state.questions,
|
|
answers: state.answers,
|
|
currentQuestionIndex: state.currentQuestionIndex,
|
|
}),
|
|
}
|
|
)
|
|
)
|