Files
pragativeer-v2/src/store/cbtStore.ts

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,
}),
}
)
)