298 lines
12 KiB
TypeScript
298 lines
12 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 } 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,
|
|
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,
|
|
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("/exercises");
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
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">
|
|
<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"
|
|
)}>
|
|
{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 && "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={examNumber === undefined ? selectExam : undefined}
|
|
style={{
|
|
...(width !== undefined && { width }),
|
|
...(height !== undefined && { height }),
|
|
}}
|
|
data-tip="This exam is still being evaluated..."
|
|
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; |