From 8ea97ee944dc9db53af36a65a1442125b0e112d2 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 4 Jun 2024 22:18:45 +0100 Subject: [PATCH] Added a new feature to check for and register inactivity during an exam --- src/exams/Finish.tsx | 45 ++++++- src/exams/Reading.tsx | 2 +- src/interfaces/user.ts | 229 +++++++++++++++------------------- src/pages/(exam)/ExamPage.tsx | 65 +++++++++- src/pages/record.tsx | 31 +++-- src/stores/examStore.ts | 4 + 6 files changed, 232 insertions(+), 144 deletions(-) diff --git a/src/exams/Finish.tsx b/src/exams/Finish.tsx index 3cdf7a41..1c3820c4 100644 --- a/src/exams/Finish.tsx +++ b/src/exams/Finish.tsx @@ -9,10 +9,21 @@ import clsx from "clsx"; import Link from "next/link"; import {useRouter} from "next/router"; import {Fragment, useEffect, useState} from "react"; -import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; +import { + BsArrowCounterclockwise, + BsBook, + BsClipboard, + BsClipboardFill, + BsEyeFill, + BsHeadphones, + BsMegaphone, + BsPen, + BsShareFill, +} from "react-icons/bs"; import {LevelScore} from "@/constants/ielts"; import {getLevelScore} from "@/utils/score"; import {capitalize} from "lodash"; +import Modal from "@/components/Modal"; interface Score { module: Module; @@ -25,13 +36,18 @@ interface Props { user: User; modules: Module[]; scores: Score[]; + information: { + timeSpent?: number; + inactivity?: number; + }; isLoading: boolean; onViewResults: (moduleIndex?: number) => void; } -export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) { +export default function Finish({user, scores, modules, information, isLoading, onViewResults}: Props) { const [selectedModule, setSelectedModule] = useState(modules[0]); const [selectedScore, setSelectedScore] = useState(scores.find((x) => x.module === modules[0])!); + const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false); const exams = useExamStore((state) => state.exams); @@ -86,6 +102,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults} return ( <> + setIsExtraInformationOpen(false)}> +
+ {!!information.timeSpent && ( + + Time Spent: {Math.floor(information.timeSpent / 60)} minute(s) + + )} + {!!information.inactivity && ( + + Inactivity: {Math.floor(information.inactivity / 60)} minute(s) + + )} +
+
+
Review {capitalize(selectedModule)}
+ {(!!information.inactivity || !!information.timeSpent) && ( +
+ + Extra Information +
+ )} diff --git a/src/exams/Reading.tsx b/src/exams/Reading.tsx index 64ee49fd..7059d8ee 100644 --- a/src/exams/Reading.tsx +++ b/src/exams/Reading.tsx @@ -89,7 +89,7 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
{part.text.content .split(/\n|(\\n)/g) - .filter((x) => x && x.length > 0) + .filter((x) => x && x.length > 0 && x !== "\\n") .map((line, index) => ( {exerciseType === "matchSentences" && ( diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 8e74cf92..85b5d9fa 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -1,177 +1,152 @@ -import { Module } from "."; -import { InstructorGender } from "./exam"; +import {Module} from "."; +import {InstructorGender} from "./exam"; -export type User = - | StudentUser - | TeacherUser - | CorporateUser - | AgentUser - | AdminUser - | DeveloperUser; +export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser; export type UserStatus = "active" | "disabled" | "paymentDue"; export interface BasicUser { - email: string; - name: string; - profilePicture: string; - id: string; - isFirstLogin: boolean; - focus: "academic" | "general"; - levels: { [key in Module]: number }; - desiredLevels: { [key in Module]: number }; - type: Type; - bio: string; - isVerified: boolean; - subscriptionExpirationDate?: null | Date; - registrationDate?: Date; - status: UserStatus; + email: string; + name: string; + profilePicture: string; + id: string; + isFirstLogin: boolean; + focus: "academic" | "general"; + levels: {[key in Module]: number}; + desiredLevels: {[key in Module]: number}; + type: Type; + bio: string; + isVerified: boolean; + subscriptionExpirationDate?: null | Date; + registrationDate?: Date; + status: UserStatus; } export interface StudentUser extends BasicUser { - type: "student"; - preferredGender?: InstructorGender; - demographicInformation?: DemographicInformation; - preferredTopics?: string[]; + type: "student"; + preferredGender?: InstructorGender; + demographicInformation?: DemographicInformation; + preferredTopics?: string[]; } export interface TeacherUser extends BasicUser { - type: "teacher"; - demographicInformation?: DemographicInformation; + type: "teacher"; + demographicInformation?: DemographicInformation; } export interface CorporateUser extends BasicUser { - type: "corporate"; - corporateInformation: CorporateInformation; - demographicInformation?: DemographicCorporateInformation; + type: "corporate"; + corporateInformation: CorporateInformation; + demographicInformation?: DemographicCorporateInformation; } export interface AgentUser extends BasicUser { - type: "agent"; - agentInformation: AgentInformation; - demographicInformation?: DemographicInformation; + type: "agent"; + agentInformation: AgentInformation; + demographicInformation?: DemographicInformation; } export interface AdminUser extends BasicUser { - type: "admin"; - demographicInformation?: DemographicInformation; + type: "admin"; + demographicInformation?: DemographicInformation; } export interface DeveloperUser extends BasicUser { - type: "developer"; - preferredGender?: InstructorGender; - demographicInformation?: DemographicInformation; - preferredTopics?: string[]; + type: "developer"; + preferredGender?: InstructorGender; + demographicInformation?: DemographicInformation; + preferredTopics?: string[]; } export interface CorporateInformation { - companyInformation: CompanyInformation; - monthlyDuration: number; - payment?: { - value: number; - currency: string; - commission: number; - }; - referralAgent?: string; + companyInformation: CompanyInformation; + monthlyDuration: number; + payment?: { + value: number; + currency: string; + commission: number; + }; + referralAgent?: string; } export interface AgentInformation { - companyName: string; - commercialRegistration: string; - companyArabName?: string; + companyName: string; + commercialRegistration: string; + companyArabName?: string; } export interface CompanyInformation { - name: string; - userAmount: number; + name: string; + userAmount: number; } export interface DemographicInformation { - country: string; - phone: string; - gender: Gender; - employment: EmploymentStatus; - passport_id?: string; - timezone?: string; + country: string; + phone: string; + gender: Gender; + employment: EmploymentStatus; + passport_id?: string; + timezone?: string; } export interface DemographicCorporateInformation { - country: string; - phone: string; - gender: Gender; - position: string; - timezone?: string; + country: string; + phone: string; + gender: Gender; + position: string; + timezone?: string; } export type Gender = "male" | "female" | "other"; -export type EmploymentStatus = - | "employed" - | "student" - | "self-employed" - | "unemployed" - | "retired" - | "other"; -export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] = - [ - { status: "student", label: "Student" }, - { status: "employed", label: "Employed" }, - { status: "unemployed", label: "Unemployed" }, - { status: "self-employed", label: "Self-employed" }, - { status: "retired", label: "Retired" }, - { status: "other", label: "Other" }, - ]; +export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other"; +export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [ + {status: "student", label: "Student"}, + {status: "employed", label: "Employed"}, + {status: "unemployed", label: "Unemployed"}, + {status: "self-employed", label: "Self-employed"}, + {status: "retired", label: "Retired"}, + {status: "other", label: "Other"}, +]; export interface Stat { - id: string; - user: string; - exam: string; - exercise: string; - session: string; - date: number; - module: Module; - solutions: any[]; - type: string; - timeSpent?: number; - assignment?: string; - score: { - correct: number; - total: number; - missing: number; - }; - isDisabled?: boolean; + id: string; + user: string; + exam: string; + exercise: string; + session: string; + date: number; + module: Module; + solutions: any[]; + type: string; + timeSpent?: number; + inactivity?: number; + assignment?: string; + score: { + correct: number; + total: number; + missing: number; + }; + isDisabled?: boolean; } export interface Group { - admin: string; - name: string; - participants: string[]; - id: string; - disableEditing?: boolean; + admin: string; + name: string; + participants: string[]; + id: string; + disableEditing?: boolean; } export interface Code { - code: string; - creator: string; - expiryDate: Date; - type: Type; - creationDate?: string; - userId?: string; - email?: string; - name?: string; - passport_id?: string; + code: string; + creator: string; + expiryDate: Date; + type: Type; + creationDate?: string; + userId?: string; + email?: string; + name?: string; + passport_id?: string; } -export type Type = - | "student" - | "teacher" - | "corporate" - | "admin" - | "developer" - | "agent"; -export const userTypes: Type[] = [ - "student", - "teacher", - "corporate", - "admin", - "developer", - "agent", -]; +export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent"; +export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"]; diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index d583284b..f210a194 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -35,14 +35,14 @@ export default function ExamPage({page}: Props) { const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [isEvaluationLoading, setIsEvaluationLoading] = useState(false); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState([]); + const [inactivityTimer, setInactivityTimer] = useState(0); + const [totalInactivity, setTotalInactivity] = useState(0); const [timeSpent, setTimeSpent] = useState(0); const resetStore = useExamStore((state) => state.reset); const assignment = useExamStore((state) => state.assignment); const initialTimeSpent = useExamStore((state) => state.timeSpent); - const examStore = useExamStore; - const {exam, setExam} = useExamStore((state) => state); const {exams, setExams} = useExamStore((state) => state); const {sessionId, setSessionId} = useExamStore((state) => state); @@ -53,10 +53,20 @@ export default function ExamPage({page}: Props) { const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {showSolutions, setShowSolutions} = useExamStore((state) => state); const {selectedModules, setSelectedModules} = useExamStore((state) => state); + const {inactivity, setInactivity} = useExamStore((state) => state); const {user} = useUser({redirectTo: "/login"}); const router = useRouter(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const resetInactivityTimer = () => { + setInactivityTimer((prev) => { + if (moduleIndex >= selectedModules.length || moduleIndex === -1) return 0; + if (prev >= 120) setTotalInactivity((totalPrev) => totalPrev + prev); + return 0; + }); + }; + const reset = () => { resetStore(); setVariant("full"); @@ -66,8 +76,21 @@ export default function ExamPage({page}: Props) { setIsEvaluationLoading(false); setStatsAwaitingEvaluation([]); setTimeSpent(0); + setInactivity(0); + + document.removeEventListener("keydown", resetInactivityTimer); + document.removeEventListener("mousemove", resetInactivityTimer); + document.removeEventListener("mousedown", resetInactivityTimer); }; + useEffect(() => { + if (moduleIndex >= selectedModules.length || moduleIndex === -1 || showSolutions) { + document.removeEventListener("keydown", resetInactivityTimer); + document.removeEventListener("mousemove", resetInactivityTimer); + document.removeEventListener("mousedown", resetInactivityTimer); + } + }, [moduleIndex, resetInactivityTimer, selectedModules.length, showSolutions]); + // eslint-disable-next-line react-hooks/exhaustive-deps const saveSession = async () => { console.log("Saving your session..."); @@ -81,6 +104,7 @@ export default function ExamPage({page}: Props) { selectedModules, assignment, timeSpent, + inactivity: totalInactivity, exams, exam, partIndex, @@ -90,7 +114,8 @@ export default function ExamPage({page}: Props) { }); }; - useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]); + useEffect(() => setTimeSpent(initialTimeSpent), [initialTimeSpent]); + useEffect(() => setTotalInactivity(inactivity), [inactivity]); useEffect(() => { if (userSolutions.length === 0 && exams.length > 0) { @@ -144,7 +169,34 @@ export default function ExamPage({page}: Props) { }, [selectedModules.length]); useEffect(() => { - if (showSolutions) setModuleIndex(-1); + if (selectedModules.length > 0 && !showSolutions && inactivityTimer === 0) { + const inactivityInterval = setInterval(() => { + setInactivityTimer((prev) => prev + 1); + }, 1000); + + return () => { + clearInterval(inactivityInterval); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedModules.length]); + + useEffect(() => { + document.addEventListener("keydown", resetInactivityTimer); + document.addEventListener("mousemove", resetInactivityTimer); + document.addEventListener("mousedown", resetInactivityTimer); + + return () => { + document.removeEventListener("keydown", resetInactivityTimer); + document.removeEventListener("mousemove", resetInactivityTimer); + document.removeEventListener("mousedown", resetInactivityTimer); + }; + }); + + useEffect(() => { + if (showSolutions) { + setModuleIndex(-1); + } }, [setModuleIndex, showSolutions]); useEffect(() => { @@ -190,6 +242,7 @@ export default function ExamPage({page}: Props) { ...solution, id: solution.id || uuidv4(), timeSpent, + inactivity: totalInactivity, session: sessionId, exam: solution.exam!, module: solution.module!, @@ -392,6 +445,10 @@ export default function ExamPage({page}: Props) { isLoading={isEvaluationLoading} user={user!} modules={selectedModules} + information={{ + timeSpent, + inactivity: totalInactivity, + }} onViewResults={(index?: number) => { setShowSolutions(true); setModuleIndex(index || 0); diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 4bfef294..df14aa3d 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -18,7 +18,7 @@ import {sortByModule} from "@/utils/moduleUtils"; import Layout from "@/components/High/Layout"; import clsx from "clsx"; import {calculateBandScore} from "@/utils/score"; -import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import {BsBook, BsClipboard, BsClock, BsHeadphones, BsMegaphone, BsPen, BsPersonDash, BsPersonFillX, BsXCircle} from "react-icons/bs"; import Select from "@/components/Low/Select"; import useGroups from "@/hooks/useGroups"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; @@ -66,6 +66,8 @@ export default function History({user}: {user: User}) { const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setSelectedModules = useExamStore((state) => state.setSelectedModules); + const setInactivity = useExamStore((state) => state.setInactivity); + const setTimeSpent = useExamStore((state) => state.setTimeSpent); const router = useRouter(); const renderPdfIcon = usePDFDownload("stats"); @@ -184,7 +186,7 @@ export default function History({user}: {user: User}) { level: calculateBandScore(x.correct, x.total, x.module, user.focus), })); - const {timeSpent, session} = dateStats[0]; + const {timeSpent, inactivity, session} = dateStats[0]; const selectExam = () => { const examPromises = uniqBy(dateStats, "exam").map((stat) => { @@ -194,6 +196,9 @@ export default function History({user}: {user: User}) { Promise.all(examPromises).then((exams) => { if (exams.every((x) => !!x)) { + if (!!timeSpent) setTimeSpent(timeSpent); + if (!!inactivity) setInactivity(inactivity); + setUserSolutions(convertToUserSolutions(dateStats)); setShowSolutions(true); setExams(exams.map((x) => x!).sort(sortByModule)); @@ -217,14 +222,20 @@ export default function History({user}: {user: User}) { const content = ( <>
-
+
{formatTimestamp(timestamp)} - {timeSpent && ( - <> - - {Math.floor(timeSpent / 60)} minutes - - )} +
+ {!!timeSpent && ( + + {Math.floor(timeSpent / 60)} minutes + + )} + {!!inactivity && ( + + {Math.floor(inactivity / 60)} minutes + + )} +
@@ -272,7 +283,7 @@ export default function History({user}: {user: User}) {
= 0.7 && "hover:border-mti-purple", correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", diff --git a/src/stores/examStore.ts b/src/stores/examStore.ts index 65fe94e7..4c0184e5 100644 --- a/src/stores/examStore.ts +++ b/src/stores/examStore.ts @@ -17,6 +17,7 @@ export interface ExamState { partIndex: number; exerciseIndex: number; questionIndex: number; + inactivity: number; } export interface ExamFunctions { @@ -33,6 +34,7 @@ export interface ExamFunctions { setPartIndex: (partIndex: number) => void; setExerciseIndex: (exerciseIndex: number) => void; setQuestionIndex: (questionIndex: number) => void; + setInactivity: (inactivity: number) => void; reset: () => void; } @@ -50,6 +52,7 @@ export const initialState: ExamState = { partIndex: -1, exerciseIndex: -1, questionIndex: 0, + inactivity: 0, }; const useExamStore = create((set) => ({ @@ -68,6 +71,7 @@ const useExamStore = create((set) => ({ setPartIndex: (partIndex: number) => set(() => ({partIndex})), setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})), setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), + setInactivity: (inactivity: number) => set(() => ({inactivity})), reset: () => set(() => initialState), }));