From 4ec439492e3ffc604e34e916859e732c37da4e62 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 6 Feb 2024 14:44:22 +0000 Subject: [PATCH] Added the capability for users to resume their previously stopped sessions --- src/components/Exercises/MultipleChoice.tsx | 12 ++- src/components/Medium/SessionCard.tsx | 101 ++++++++++++++++++++ src/exams/Listening.tsx | 10 +- src/exams/Selection.tsx | 48 +++++++++- src/hooks/useSessions.tsx | 2 +- src/pages/(exam)/ExamPage.tsx | 38 ++++++-- src/stores/examStore.ts | 10 +- 7 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 src/components/Medium/SessionCard.tsx diff --git a/src/components/Exercises/MultipleChoice.tsx b/src/components/Exercises/MultipleChoice.tsx index f2847209..6bf1a25e 100644 --- a/src/components/Exercises/MultipleChoice.tsx +++ b/src/components/Exercises/MultipleChoice.tsx @@ -59,12 +59,18 @@ export default function MultipleChoice({ onBack, }: MultipleChoiceExercise & CommonProps) { const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); - const [questionIndex, setQuestionIndex] = useState(0); + const {questionIndex, setQuestionIndex} = useExamStore((state) => state); + const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); const hasExamEnded = useExamStore((state) => state.hasExamEnded); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); + useEffect(() => { + setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [answers]); + useEffect(() => { if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -93,7 +99,7 @@ export default function MultipleChoice({ if (questionIndex === questions.length - 1) { onNext({exercise: id, solutions: answers, score: calculateScore(), type}); } else { - setQuestionIndex((prev) => prev + 1); + setQuestionIndex(questionIndex + 1); } scrollToTop(); @@ -103,7 +109,7 @@ export default function MultipleChoice({ if (questionIndex === 0) { onBack({exercise: id, solutions: answers, score: calculateScore(), type}); } else { - setQuestionIndex((prev) => prev - 1); + setQuestionIndex(questionIndex - 1); } scrollToTop(); diff --git a/src/components/Medium/SessionCard.tsx b/src/components/Medium/SessionCard.tsx new file mode 100644 index 00000000..71e9af97 --- /dev/null +++ b/src/components/Medium/SessionCard.tsx @@ -0,0 +1,101 @@ +import {Session} from "@/hooks/useSessions"; +import useExamStore from "@/stores/examStore"; +import {sortByModuleName} from "@/utils/moduleUtils"; +import axios from "axios"; +import clsx from "clsx"; +import {capitalize} from "lodash"; +import moment from "moment"; +import {useState} from "react"; +import {BsArrowRepeat, BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import {toast} from "react-toastify"; + +export default function SessionCard({ + session, + reload, + loadSession, +}: { + session: Session; + reload: () => void; + loadSession: (session: Session) => Promise; +}) { + const [isLoading, setIsLoading] = useState(false); + + const deleteSession = async () => { + if (!confirm("Are you sure you want to delete this session?")) return; + + setIsLoading(true); + await axios + .delete(`/api/sessions/${session.sessionId}`) + .then(() => { + toast.success(`Successfully delete session "${session.sessionId}"`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later"); + }) + .finally(() => { + reload(); + setIsLoading(false); + }); + }; + + return ( +
+ + ID: + {session.sessionId} + + + Date: + {moment(session.date).format("DD/MM/YYYY - HH:mm")} + +
+
+ {session.selectedModules.sort(sortByModuleName).map((module) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } +
+ ))} +
+
+
+ + +
+
+ ); +} diff --git a/src/exams/Listening.tsx b/src/exams/Listening.tsx index 69d2f698..3189b402 100644 --- a/src/exams/Listening.tsx +++ b/src/exams/Listening.tsx @@ -32,12 +32,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); useEffect(() => { - if (showSolutions) setExerciseIndex(-1); + if (showSolutions) return setExerciseIndex(-1); }, [setExerciseIndex, showSolutions]); - useEffect(() => { - if (exam.variant !== "partial") setPartIndex(-1); - }, [exam.variant, setPartIndex]); + // useEffect(() => { + // if (exam.variant !== "partial") setPartIndex(-1); + // }, [exam.variant, setPartIndex]); useEffect(() => { if (hasExamEnded && exerciseIndex === -1) { @@ -214,7 +214,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props )} - {exerciseIndex === -1 && partIndex === -1 && exam.variant !== "partial" && ( + {partIndex === -1 && exam.variant !== "partial" && ( diff --git a/src/exams/Selection.tsx b/src/exams/Selection.tsx index 74e623b0..0b5fd985 100644 --- a/src/exams/Selection.tsx +++ b/src/exams/Selection.tsx @@ -4,7 +4,7 @@ import {Module} from "@/interfaces"; import clsx from "clsx"; import {User} from "@/interfaces/user"; import ProgressBar from "@/components/Low/ProgressBar"; -import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; +import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {totalExamsByModule} from "@/utils/stats"; import useStats from "@/hooks/useStats"; import Button from "@/components/Low/Button"; @@ -13,6 +13,10 @@ import {sortByModuleName} from "@/utils/moduleUtils"; import {capitalize} from "lodash"; import ProfileSummary from "@/components/ProfileSummary"; import {Variant} from "@/interfaces/exam"; +import useSessions, {Session} from "@/hooks/useSessions"; +import SessionCard from "@/components/Medium/SessionCard"; +import useExamStore from "@/stores/examStore"; +import moment from "moment"; interface Props { user: User; @@ -25,13 +29,32 @@ export default function Selection({user, page, onStart, disableSelection = false const [selectedModules, setSelectedModules] = useState([]); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [variant, setVariant] = useState("full"); + const {stats} = useStats(user?.id); + const {sessions, isLoading, reload} = useSessions(user.id); + + const state = useExamStore((state) => state); const toggleModule = (module: Module) => { const modules = selectedModules.filter((x) => x !== module); setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); }; + const loadSession = async (session: Session) => { + state.setSelectedModules(session.selectedModules); + state.setExam(session.exam); + state.setExams(session.exams); + state.setSessionId(session.sessionId); + state.setAssignment(session.assignment); + state.setExerciseIndex(session.exerciseIndex); + state.setPartIndex(session.partIndex); + state.setModuleIndex(session.moduleIndex); + state.setTimeSpent(session.timeSpent); + state.setUserSolutions(session.userSolutions); + state.setShowSolutions(false); + state.setQuestionIndex(session.questionIndex); + }; + return ( <>
@@ -94,7 +117,28 @@ export default function Selection({user, page, onStart, disableSelection = false )} -
+ + {sessions.length > 0 && ( +
+
+
+ Unfinished Sessions + +
+
+ + {sessions + .sort((a, b) => moment(b.date).diff(moment(a.date))) + .map((session) => ( + + ))} + +
+ )} + +
toggleModule("reading") : undefined} className={clsx( diff --git a/src/hooks/useSessions.tsx b/src/hooks/useSessions.tsx index 3cc89032..8d98ee0b 100644 --- a/src/hooks/useSessions.tsx +++ b/src/hooks/useSessions.tsx @@ -3,7 +3,7 @@ import {ExamState} from "@/stores/examStore"; import axios from "axios"; import {useEffect, useState} from "react"; -export type Session = ExamState & {user: string}; +export type Session = ExamState & {user: string; id: string; date: string}; export default function useSessions(user?: string) { const [sessions, setSessions] = useState([]); diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 82ceebb8..2bc560e5 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -22,6 +22,7 @@ import {useRouter} from "next/router"; import {toast, ToastContainer} from "react-toastify"; import {v4 as uuidv4} from "uuid"; import useSessions from "@/hooks/useSessions"; +import ShortUniqueId from "short-unique-id"; interface Props { page: "exams" | "exercises"; @@ -36,15 +37,17 @@ export default function ExamPage({page}: Props) { const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState([]); const [timeSpent, setTimeSpent] = useState(0); - const partIndex = useExamStore((state) => state.partIndex); + const resetStore = useExamStore((state) => state.reset); const assignment = useExamStore((state) => state.assignment); const initialTimeSpent = useExamStore((state) => state.timeSpent); - const exerciseIndex = useExamStore((state) => state.exerciseIndex); const {exam, setExam} = useExamStore((state) => state); const {exams, setExams} = useExamStore((state) => state); const {sessionId, setSessionId} = useExamStore((state) => state); + const {partIndex, setPartIndex} = useExamStore((state) => state); const {moduleIndex, setModuleIndex} = useExamStore((state) => state); + const {questionIndex, setQuestionIndex} = useExamStore((state) => state); + const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {showSolutions, setShowSolutions} = useExamStore((state) => state); const {selectedModules, setSelectedModules} = useExamStore((state) => state); @@ -52,10 +55,23 @@ export default function ExamPage({page}: Props) { const {user} = useUser({redirectTo: "/login"}); const router = useRouter(); + const reset = () => { + resetStore(); + setVariant("full"); + setAvoidRepeated(false); + setHasBeenUploaded(false); + setShowAbandonPopup(false); + setIsEvaluationLoading(false); + setStatsAwaitingEvaluation([]); + setTimeSpent(0); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps const saveSession = async () => { await axios.post("/api/sessions", { id: sessionId, + sessionId, + date: new Date().toISOString(), userSolutions, moduleIndex, selectedModules, @@ -65,6 +81,7 @@ export default function ExamPage({page}: Props) { exam, partIndex, exerciseIndex, + questionIndex, user: user?.id, }); }; @@ -82,7 +99,7 @@ export default function ExamPage({page}: Props) { if (sessionId.length > 0 && userSolutions.length > 0 && selectedModules.length > 0 && exams.length > 0 && !!exam && timeSpent > 0) saveSession(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex]); + }, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]); useEffect(() => { if (timeSpent % 20 === 0 && timeSpent > 0) saveSession(); @@ -90,10 +107,11 @@ export default function ExamPage({page}: Props) { }, [timeSpent]); useEffect(() => { - if (selectedModules.length > 0) { - setSessionId(uuidv4()); + if (selectedModules.length > 0 && sessionId.length === 0) { + const shortUID = new ShortUniqueId(); + setSessionId(shortUID.randomUUID(8)); } - }, [setSessionId, selectedModules]); + }, [setSessionId, selectedModules, sessionId]); useEffect(() => { if (user?.type === "developer") console.log(exam); }, [exam, user]); @@ -258,6 +276,10 @@ export default function ExamPage({page}: Props) { setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]); setModuleIndex(moduleIndex + 1); + + setPartIndex(0); + setExerciseIndex(-1); + setQuestionIndex(0); }; const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => { @@ -377,7 +399,9 @@ export default function ExamPage({page}: Props) { abandonPopupTitle="Leave Exercise" abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress." abandonConfirmButtonText="Confirm" - onAbandon={() => router.reload()} + onAbandon={() => { + reset(); + }} onCancel={() => setShowAbandonPopup(false)} /> )} diff --git a/src/stores/examStore.ts b/src/stores/examStore.ts index 875a712e..ef253b2b 100644 --- a/src/stores/examStore.ts +++ b/src/stores/examStore.ts @@ -16,6 +16,7 @@ export interface ExamState { exam?: Exam; partIndex: number; exerciseIndex: number; + questionIndex: number; } export interface ExamFunctions { @@ -24,13 +25,14 @@ export interface ExamFunctions { setShowSolutions: (showSolutions: boolean) => void; setHasExamEnded: (hasExamEnded: boolean) => void; setSelectedModules: (modules: Module[]) => void; - setAssignment: (assignment: Assignment) => void; + setAssignment: (assignment?: Assignment) => void; setTimeSpent: (timeSpent: number) => void; setSessionId: (sessionId: string) => void; setModuleIndex: (moduleIndex: number) => void; setExam: (exam?: Exam) => void; setPartIndex: (partIndex: number) => void; setExerciseIndex: (exerciseIndex: number) => void; + setQuestionIndex: (questionIndex: number) => void; reset: () => void; } @@ -46,7 +48,8 @@ export const initialState: ExamState = { exam: undefined, moduleIndex: 0, partIndex: 0, - exerciseIndex: 0, + exerciseIndex: -1, + questionIndex: 0, }; const useExamStore = create((set) => ({ @@ -57,13 +60,14 @@ const useExamStore = create((set) => ({ setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})), setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})), setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})), - setAssignment: (assignment: Assignment) => set(() => ({assignment})), + setAssignment: (assignment?: Assignment) => set(() => ({assignment})), setTimeSpent: (timeSpent) => set(() => ({timeSpent})), setSessionId: (sessionId: string) => set(() => ({sessionId})), setExam: (exam?: Exam) => set(() => ({exam})), setModuleIndex: (moduleIndex: number) => set(() => ({moduleIndex})), setPartIndex: (partIndex: number) => set(() => ({partIndex})), setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})), + setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), reset: () => set(() => initialState), }));