329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import React from "react";
|
|
import { BsClock, BsXCircle } from "react-icons/bs";
|
|
import clsx from "clsx";
|
|
import { Stat, User } from "@/interfaces/user";
|
|
import { Grading, Module, Step } from "@/interfaces";
|
|
import ai_usage from "@/utils/ai.detection";
|
|
import { calculateBandScore, getGradingLabel } 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 { getExamById } from "@/utils/exams";
|
|
import ModuleBadge from "../ModuleBadge";
|
|
import useExamStore from "@/stores/exam";
|
|
import { findBy } from "@/utils";
|
|
|
|
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.filter(x => !x.isPractice).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;
|
|
gradingSystems: Grading[]
|
|
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[]>>;
|
|
renderPdfIcon?: (session: string, color: string, textColor: string) => React.ReactNode;
|
|
}
|
|
|
|
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|
stats,
|
|
timestamp,
|
|
user,
|
|
assignments,
|
|
gradingSystems,
|
|
users,
|
|
training,
|
|
selectedTrainingExams,
|
|
setSelectedTrainingExams,
|
|
renderPdfIcon,
|
|
width = undefined,
|
|
height = undefined,
|
|
examNumber = undefined,
|
|
maxTrainingExams = undefined,
|
|
}) => {
|
|
const router = useRouter();
|
|
const dispatch = useExamStore((s) => s.dispatch);
|
|
|
|
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)) {
|
|
dispatch({
|
|
type: 'INIT_SOLUTIONS', payload: {
|
|
exams: exams.map((x) => x!).sort(sortByModule),
|
|
modules: exams
|
|
.map((x) => x!)
|
|
.sort(sortByModule)
|
|
.map((x) => x!.module),
|
|
stats,
|
|
timeSpent,
|
|
inactivity
|
|
}
|
|
});
|
|
router.push("/exam");
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const shouldRenderPDFIcon = () => {
|
|
if (assignment) return assignment.released;
|
|
return true;
|
|
};
|
|
|
|
const levelAverage = () =>
|
|
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
|
|
|
const renderLevelScore = () => {
|
|
const defaultLevelScore = levelAverage().toFixed(1)
|
|
if (!stats.every(s => s.module === "level")) return defaultLevelScore
|
|
if (gradingSystems.length === 0) return defaultLevelScore
|
|
|
|
const score = {
|
|
correct: stats.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
total: stats.reduce((acc, curr) => acc + curr.score.total, 0)
|
|
}
|
|
|
|
const level: number = calculateBandScore(score.correct, score.total, "level", user.focus);
|
|
|
|
if (!!assignment) {
|
|
const gradingSystem = findBy(gradingSystems, 'entity', assignment.entity)
|
|
if (!gradingSystem) return defaultLevelScore
|
|
|
|
return getGradingLabel(level, gradingSystem.steps)
|
|
}
|
|
|
|
return getGradingLabel(level, gradingSystems[0].steps)
|
|
}
|
|
|
|
const content = (
|
|
<>
|
|
<div className="w-full flex justify-between">
|
|
<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)) || !assignment) && (
|
|
<span className={textColor}>
|
|
Level{' '}
|
|
{renderLevelScore()}
|
|
</span>
|
|
)}
|
|
{shouldRenderPDFIcon() && renderPdfIcon && 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={(!!assignment && (assignment.released || assignment.released === undefined)) || !assignment ? level : undefined}
|
|
/>
|
|
)}
|
|
</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;
|