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 {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<Score>(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 (
<>
<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">
<ModuleTitle
module={selectedModule}
@@ -247,6 +278,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</button>
<span>Review {capitalize(selectedModule)}</span>
</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>
<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" />
{part.text.content
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
{exerciseType === "matchSentences" && (

View File

@@ -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"];

View File

@@ -35,14 +35,14 @@ export default function ExamPage({page}: Props) {
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
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);

View File

@@ -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 = (
<>
<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>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
</>
)}
<div className="flex items-center gap-2">
{!!timeSpent && (
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
<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 className="flex flex-row gap-2">
<span className={textColor}>
@@ -272,7 +283,7 @@ export default function History({user}: {user: User}) {
<div
key={uuidv4()}
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",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",

View File

@@ -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<ExamState & ExamFunctions>((set) => ({
@@ -68,6 +71,7 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
setPartIndex: (partIndex: number) => set(() => ({partIndex})),
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
setInactivity: (inactivity: number) => set(() => ({inactivity})),
reset: () => set(() => initialState),
}));