314 lines
9.6 KiB
TypeScript
314 lines
9.6 KiB
TypeScript
import React from "react";
|
|
import {BsClock, BsXCircle} from "react-icons/bs";
|
|
import clsx from "clsx";
|
|
import {Stat, User} from "@/interfaces/user";
|
|
import {Module, Step} from "@/interfaces";
|
|
import ai_usage from "@/utils/ai.detection";
|
|
import {calculateBandScore} from "@/utils/score";
|
|
import moment from "moment";
|
|
import {Assignment} from "@/interfaces/results";
|
|
import {uuidv4} from "@firebase/util";
|
|
import {useRouter} from "next/router";
|
|
import {uniqBy} from "lodash";
|
|
import {sortByModule} from "@/utils/moduleUtils";
|
|
import {convertToUserSolutions} from "@/utils/stats";
|
|
import {getExamById} from "@/utils/exams";
|
|
import {Exam, UserSolution} from "@/interfaces/exam";
|
|
import ModuleBadge from "../ModuleBadge";
|
|
|
|
const formatTimestamp = (timestamp: string | number) => {
|
|
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
|
const date = moment(time);
|
|
const formatter = "YYYY/MM/DD - HH:mm";
|
|
return date.format(formatter);
|
|
};
|
|
|
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
|
const scores: {
|
|
[key in Module]: {total: number; missing: number; correct: number};
|
|
} = {
|
|
reading: {
|
|
total: 0,
|
|
correct: 0,
|
|
missing: 0,
|
|
},
|
|
listening: {
|
|
total: 0,
|
|
correct: 0,
|
|
missing: 0,
|
|
},
|
|
writing: {
|
|
total: 0,
|
|
correct: 0,
|
|
missing: 0,
|
|
},
|
|
speaking: {
|
|
total: 0,
|
|
correct: 0,
|
|
missing: 0,
|
|
},
|
|
level: {
|
|
total: 0,
|
|
correct: 0,
|
|
missing: 0,
|
|
},
|
|
};
|
|
|
|
stats.forEach((x) => {
|
|
scores[x.module!] = {
|
|
total: scores[x.module!].total + x.score.total,
|
|
correct: scores[x.module!].correct + x.score.correct,
|
|
missing: scores[x.module!].missing + x.score.missing,
|
|
};
|
|
});
|
|
|
|
return Object.keys(scores)
|
|
.filter((x) => scores[x as Module].total > 0)
|
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
|
};
|
|
|
|
interface StatsGridItemProps {
|
|
width?: string | undefined;
|
|
height?: string | undefined;
|
|
examNumber?: number | undefined;
|
|
stats: Stat[];
|
|
timestamp: string | number;
|
|
user: User;
|
|
assignments: Assignment[];
|
|
users: User[];
|
|
training?: boolean;
|
|
gradingSystem?: Step[];
|
|
selectedTrainingExams?: string[];
|
|
maxTrainingExams?: number;
|
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
|
setExams: (exams: Exam[]) => void;
|
|
setShowSolutions: (show: boolean) => void;
|
|
setUserSolutions: (solutions: UserSolution[]) => void;
|
|
setSelectedModules: (modules: Module[]) => void;
|
|
setInactivity: (inactivity: number) => void;
|
|
setTimeSpent: (time: number) => void;
|
|
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
|
}
|
|
|
|
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|
stats,
|
|
timestamp,
|
|
user,
|
|
assignments,
|
|
users,
|
|
training,
|
|
selectedTrainingExams,
|
|
gradingSystem,
|
|
setSelectedTrainingExams,
|
|
setExams,
|
|
setShowSolutions,
|
|
setUserSolutions,
|
|
setSelectedModules,
|
|
setInactivity,
|
|
setTimeSpent,
|
|
renderPdfIcon,
|
|
width = undefined,
|
|
height = undefined,
|
|
examNumber = undefined,
|
|
maxTrainingExams = undefined,
|
|
}) => {
|
|
const router = useRouter();
|
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
|
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
|
|
const assignment = assignments.find((a) => a.id === assignmentID);
|
|
const isDisabled = stats.some((x) => x.isDisabled);
|
|
|
|
const aiUsage = Math.round(ai_usage(stats) * 100);
|
|
|
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
|
module: x.module,
|
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
|
}));
|
|
|
|
const textColor = clsx(
|
|
correct / total >= 0.7 && "text-mti-purple",
|
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
|
correct / total < 0.3 && "text-mti-rose",
|
|
);
|
|
|
|
const {timeSpent, inactivity, session} = stats[0];
|
|
|
|
const selectExam = () => {
|
|
if (
|
|
training &&
|
|
!isDisabled &&
|
|
typeof maxTrainingExams !== "undefined" &&
|
|
typeof setSelectedTrainingExams !== "undefined" &&
|
|
typeof timestamp == "string"
|
|
) {
|
|
setSelectedTrainingExams((prevExams) => {
|
|
const uniqueExams = [...new Set(stats.map((stat) => `${stat.module}-${stat.date}`))];
|
|
const indexes = uniqueExams.map((exam) => prevExams.indexOf(exam)).filter((index) => index !== -1);
|
|
if (indexes.length > 0) {
|
|
const newExams = [...prevExams];
|
|
indexes
|
|
.sort((a, b) => b - a)
|
|
.forEach((index) => {
|
|
newExams.splice(index, 1);
|
|
});
|
|
return newExams;
|
|
} else {
|
|
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
|
return [...prevExams, ...uniqueExams];
|
|
} else {
|
|
return prevExams;
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
const examPromises = uniqBy(stats, "exam").map((stat) => {
|
|
return getExamById(stat.module, stat.exam);
|
|
});
|
|
|
|
if (isDisabled) return;
|
|
|
|
Promise.all(examPromises).then((exams) => {
|
|
if (exams.every((x) => !!x)) {
|
|
if (!!timeSpent) setTimeSpent(timeSpent);
|
|
if (!!inactivity) setInactivity(inactivity);
|
|
setUserSolutions(convertToUserSolutions(stats));
|
|
setShowSolutions(true);
|
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
|
setSelectedModules(
|
|
exams
|
|
.map((x) => x!)
|
|
.sort(sortByModule)
|
|
.map((x) => x!.module),
|
|
);
|
|
router.push("/exam");
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const shouldRenderPDFIcon = () => {
|
|
if (assignment) {
|
|
return assignment.released;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const content = (
|
|
<>
|
|
<div className="w-full flex justify-between -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>
|
|
<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-col gap-2">
|
|
<div className="flex flex-row gap-2">
|
|
{!!assignment && (assignment.released || assignment.released === undefined) && (
|
|
<span className={textColor}>
|
|
Level{" "}
|
|
{(
|
|
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
|
).toFixed(1)}
|
|
</span>
|
|
)}
|
|
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
|
|
</div>
|
|
{examNumber === undefined ? (
|
|
<>
|
|
{aiUsage >= 50 && user.type !== "student" && (
|
|
<div
|
|
className={clsx("ml-auto border px-1 rounded w-fit mr-1", {
|
|
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
|
})}>
|
|
<span className="text-xs">AI Usage</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex justify-end">
|
|
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full flex flex-col gap-1">
|
|
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
|
|
{!!assignment &&
|
|
(assignment.released || assignment.released === undefined) &&
|
|
aggregatedLevels.map(({module, level}) => <ModuleBadge key={module} module={module} level={level} />)}
|
|
</div>
|
|
|
|
{assignment && (
|
|
<span className="font-light text-sm">
|
|
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
key={uuidv4()}
|
|
className={clsx(
|
|
"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 || (!!assignment && !assignment.released)) && "grayscale tooltip",
|
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
|
correct / total < 0.3 && "hover:border-mti-rose",
|
|
typeof selectedTrainingExams !== "undefined" &&
|
|
typeof timestamp === "string" &&
|
|
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
|
|
"border-2 border-slate-600",
|
|
)}
|
|
onClick={() => {
|
|
if (!!assignment && !assignment.released) return;
|
|
if (examNumber === undefined) return selectExam();
|
|
return;
|
|
}}
|
|
style={{
|
|
...(width !== undefined && {width}),
|
|
...(height !== undefined && {height}),
|
|
}}
|
|
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
|
|
role="button">
|
|
{content}
|
|
</div>
|
|
<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:tooltip md:hidden",
|
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
|
correct / total < 0.3 && "hover:border-mti-rose",
|
|
)}
|
|
data-tip="Your screen size is too small to view previous exams."
|
|
style={{
|
|
...(width !== undefined && {width}),
|
|
...(height !== undefined && {height}),
|
|
}}
|
|
role="button">
|
|
{content}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default StatsGridItem;
|