Added a new feature to check for and register inactivity during an exam

This commit is contained in:
Tiago Ribeiro
2024-06-04 22:18:45 +01:00
parent 975f4c8285
commit 8ea97ee944
6 changed files with 232 additions and 144 deletions

View File

@@ -9,10 +9,21 @@ import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react"; 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 {LevelScore} from "@/constants/ielts";
import {getLevelScore} from "@/utils/score"; import {getLevelScore} from "@/utils/score";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import Modal from "@/components/Modal";
interface Score { interface Score {
module: Module; module: Module;
@@ -25,13 +36,18 @@ interface Props {
user: User; user: User;
modules: Module[]; modules: Module[];
scores: Score[]; scores: Score[];
information: {
timeSpent?: number;
inactivity?: number;
};
isLoading: boolean; isLoading: boolean;
onViewResults: (moduleIndex?: number) => void; 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 [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!); const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
const exams = useExamStore((state) => state.exams); const exams = useExamStore((state) => state.exams);
@@ -86,6 +102,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
return ( return (
<> <>
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
<div className="flex flex-col gap-2 mt-4">
{!!information.timeSpent && (
<span>
<b>Time Spent:</b> {Math.floor(information.timeSpent / 60)} minute(s)
</span>
)}
{!!information.inactivity && (
<span>
<b>Inactivity:</b> {Math.floor(information.inactivity / 60)} minute(s)
</span>
)}
</div>
</Modal>
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8"> <div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
<ModuleTitle <ModuleTitle
module={selectedModule} module={selectedModule}
@@ -247,6 +278,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</button> </button>
<span>Review {capitalize(selectedModule)}</span> <span>Review {capitalize(selectedModule)}</span>
</div> </div>
{(!!information.inactivity || !!information.timeSpent) && (
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => setIsExtraInformationOpen(true)}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsClipboardFill className="h-7 w-7 text-white" />
</button>
<span>Extra Information</span>
</div>
)}
</div> </div>
<Link href="/" className="w-full max-w-[200px] self-end"> <Link href="/" className="w-full max-w-[200px] self-end">

View File

@@ -89,7 +89,7 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{part.text.content {part.text.content
.split(/\n|(\\n)/g) .split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0) .filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => ( .map((line, index) => (
<Fragment key={index}> <Fragment key={index}>
{exerciseType === "matchSentences" && ( {exerciseType === "matchSentences" && (

View File

@@ -1,177 +1,152 @@
import { Module } from "."; import {Module} from ".";
import { InstructorGender } from "./exam"; import {InstructorGender} from "./exam";
export type User = export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
email: string; email: string;
name: string; name: string;
profilePicture: string; profilePicture: string;
id: string; id: string;
isFirstLogin: boolean; isFirstLogin: boolean;
focus: "academic" | "general"; focus: "academic" | "general";
levels: { [key in Module]: number }; levels: {[key in Module]: number};
desiredLevels: { [key in Module]: number }; desiredLevels: {[key in Module]: number};
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean; isVerified: boolean;
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: UserStatus; status: UserStatus;
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
type: "teacher"; type: "teacher";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface CorporateUser extends BasicUser { export interface CorporateUser extends BasicUser {
type: "corporate"; type: "corporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface AdminUser extends BasicUser { export interface AdminUser extends BasicUser {
type: "admin"; type: "admin";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {
companyInformation: CompanyInformation; companyInformation: CompanyInformation;
monthlyDuration: number; monthlyDuration: number;
payment?: { payment?: {
value: number; value: number;
currency: string; currency: string;
commission: number; commission: number;
}; };
referralAgent?: string; referralAgent?: string;
} }
export interface AgentInformation { export interface AgentInformation {
companyName: string; companyName: string;
commercialRegistration: string; commercialRegistration: string;
companyArabName?: string; companyArabName?: string;
} }
export interface CompanyInformation { export interface CompanyInformation {
name: string; name: string;
userAmount: number; userAmount: number;
} }
export interface DemographicInformation { export interface DemographicInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
employment: EmploymentStatus; employment: EmploymentStatus;
passport_id?: string; passport_id?: string;
timezone?: string; timezone?: string;
} }
export interface DemographicCorporateInformation { export interface DemographicCorporateInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
position: string; position: string;
timezone?: string; timezone?: string;
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
| "employed" export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
| "student" {status: "student", label: "Student"},
| "self-employed" {status: "employed", label: "Employed"},
| "unemployed" {status: "unemployed", label: "Unemployed"},
| "retired" {status: "self-employed", label: "Self-employed"},
| "other"; {status: "retired", label: "Retired"},
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] = {status: "other", label: "Other"},
[ ];
{ 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 { export interface Stat {
id: string; id: string;
user: string; user: string;
exam: string; exam: string;
exercise: string; exercise: string;
session: string; session: string;
date: number; date: number;
module: Module; module: Module;
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
assignment?: string; inactivity?: number;
score: { assignment?: string;
correct: number; score: {
total: number; correct: number;
missing: number; total: number;
}; missing: number;
isDisabled?: boolean; };
isDisabled?: boolean;
} }
export interface Group { export interface Group {
admin: string; admin: string;
name: string; name: string;
participants: string[]; participants: string[];
id: string; id: string;
disableEditing?: boolean; disableEditing?: boolean;
} }
export interface Code { export interface Code {
code: string; code: string;
creator: string; creator: string;
expiryDate: Date; expiryDate: Date;
type: Type; type: Type;
creationDate?: string; creationDate?: string;
userId?: string; userId?: string;
email?: string; email?: string;
name?: string; name?: string;
passport_id?: string; passport_id?: string;
} }
export type Type = export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
| "student" export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
];

View File

@@ -35,14 +35,14 @@ export default function ExamPage({page}: Props) {
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false); const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]); const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
const [inactivityTimer, setInactivityTimer] = useState(0);
const [totalInactivity, setTotalInactivity] = useState(0);
const [timeSpent, setTimeSpent] = useState(0); const [timeSpent, setTimeSpent] = useState(0);
const resetStore = useExamStore((state) => state.reset); const resetStore = useExamStore((state) => state.reset);
const assignment = useExamStore((state) => state.assignment); const assignment = useExamStore((state) => state.assignment);
const initialTimeSpent = useExamStore((state) => state.timeSpent); const initialTimeSpent = useExamStore((state) => state.timeSpent);
const examStore = useExamStore;
const {exam, setExam} = useExamStore((state) => state); const {exam, setExam} = useExamStore((state) => state);
const {exams, setExams} = useExamStore((state) => state); const {exams, setExams} = useExamStore((state) => state);
const {sessionId, setSessionId} = 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 {userSolutions, setUserSolutions} = useExamStore((state) => state);
const {showSolutions, setShowSolutions} = useExamStore((state) => state); const {showSolutions, setShowSolutions} = useExamStore((state) => state);
const {selectedModules, setSelectedModules} = useExamStore((state) => state); const {selectedModules, setSelectedModules} = useExamStore((state) => state);
const {inactivity, setInactivity} = useExamStore((state) => state);
const {user} = useUser({redirectTo: "/login"}); const {user} = useUser({redirectTo: "/login"});
const router = useRouter(); 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 = () => { const reset = () => {
resetStore(); resetStore();
setVariant("full"); setVariant("full");
@@ -66,8 +76,21 @@ export default function ExamPage({page}: Props) {
setIsEvaluationLoading(false); setIsEvaluationLoading(false);
setStatsAwaitingEvaluation([]); setStatsAwaitingEvaluation([]);
setTimeSpent(0); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
const saveSession = async () => { const saveSession = async () => {
console.log("Saving your session..."); console.log("Saving your session...");
@@ -81,6 +104,7 @@ export default function ExamPage({page}: Props) {
selectedModules, selectedModules,
assignment, assignment,
timeSpent, timeSpent,
inactivity: totalInactivity,
exams, exams,
exam, exam,
partIndex, 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(() => { useEffect(() => {
if (userSolutions.length === 0 && exams.length > 0) { if (userSolutions.length === 0 && exams.length > 0) {
@@ -144,7 +169,34 @@ export default function ExamPage({page}: Props) {
}, [selectedModules.length]); }, [selectedModules.length]);
useEffect(() => { 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]); }, [setModuleIndex, showSolutions]);
useEffect(() => { useEffect(() => {
@@ -190,6 +242,7 @@ export default function ExamPage({page}: Props) {
...solution, ...solution,
id: solution.id || uuidv4(), id: solution.id || uuidv4(),
timeSpent, timeSpent,
inactivity: totalInactivity,
session: sessionId, session: sessionId,
exam: solution.exam!, exam: solution.exam!,
module: solution.module!, module: solution.module!,
@@ -392,6 +445,10 @@ export default function ExamPage({page}: Props) {
isLoading={isEvaluationLoading} isLoading={isEvaluationLoading}
user={user!} user={user!}
modules={selectedModules} modules={selectedModules}
information={{
timeSpent,
inactivity: totalInactivity,
}}
onViewResults={(index?: number) => { onViewResults={(index?: number) => {
setShowSolutions(true); setShowSolutions(true);
setModuleIndex(index || 0); setModuleIndex(index || 0);

View File

@@ -18,7 +18,7 @@ import {sortByModule} from "@/utils/moduleUtils";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
import {calculateBandScore} from "@/utils/score"; 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 Select from "@/components/Low/Select";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
@@ -66,6 +66,8 @@ export default function History({user}: {user: User}) {
const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const router = useRouter(); const router = useRouter();
const renderPdfIcon = usePDFDownload("stats"); 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), level: calculateBandScore(x.correct, x.total, x.module, user.focus),
})); }));
const {timeSpent, session} = dateStats[0]; const {timeSpent, inactivity, session} = dateStats[0];
const selectExam = () => { const selectExam = () => {
const examPromises = uniqBy(dateStats, "exam").map((stat) => { const examPromises = uniqBy(dateStats, "exam").map((stat) => {
@@ -194,6 +196,9 @@ export default function History({user}: {user: User}) {
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(dateStats)); setUserSolutions(convertToUserSolutions(dateStats));
setShowSolutions(true); setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule)); setExams(exams.map((x) => x!).sort(sortByModule));
@@ -217,14 +222,20 @@ export default function History({user}: {user: User}) {
const content = ( const content = (
<> <>
<div className="w-full flex justify-between -md:items-center 2xl:items-center"> <div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 2xl:items-center"> <div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span> <span className="font-medium">{formatTimestamp(timestamp)}</span>
{timeSpent && ( <div className="flex items-center gap-2">
<> {!!timeSpent && (
<span className="md:hidden 2xl:flex"> </span> <span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span> <BsClock /> {Math.floor(timeSpent / 60)} minutes
</> </span>
)} )}
{!!inactivity && (
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
</span>
)}
</div>
</div> </div>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<span className={textColor}> <span className={textColor}>
@@ -272,7 +283,7 @@ export default function History({user}: {user: User}) {
<div <div
key={uuidv4()} key={uuidv4()}
className={clsx( className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden", "flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
isDisabled && "grayscale tooltip", isDisabled && "grayscale tooltip",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",

View File

@@ -17,6 +17,7 @@ export interface ExamState {
partIndex: number; partIndex: number;
exerciseIndex: number; exerciseIndex: number;
questionIndex: number; questionIndex: number;
inactivity: number;
} }
export interface ExamFunctions { export interface ExamFunctions {
@@ -33,6 +34,7 @@ export interface ExamFunctions {
setPartIndex: (partIndex: number) => void; setPartIndex: (partIndex: number) => void;
setExerciseIndex: (exerciseIndex: number) => void; setExerciseIndex: (exerciseIndex: number) => void;
setQuestionIndex: (questionIndex: number) => void; setQuestionIndex: (questionIndex: number) => void;
setInactivity: (inactivity: number) => void;
reset: () => void; reset: () => void;
} }
@@ -50,6 +52,7 @@ export const initialState: ExamState = {
partIndex: -1, partIndex: -1,
exerciseIndex: -1, exerciseIndex: -1,
questionIndex: 0, questionIndex: 0,
inactivity: 0,
}; };
const useExamStore = create<ExamState & ExamFunctions>((set) => ({ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
@@ -68,6 +71,7 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
setPartIndex: (partIndex: number) => set(() => ({partIndex})), setPartIndex: (partIndex: number) => set(() => ({partIndex})),
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})), setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
setInactivity: (inactivity: number) => set(() => ({inactivity})),
reset: () => set(() => initialState), reset: () => set(() => initialState),
})); }));