Files
encoach_frontend/src/pages/stats.tsx
2024-10-10 19:13:18 +01:00

643 lines
24 KiB
TypeScript

/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {BsArrowClockwise, BsChevronLeft, BsChevronRight, BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {LinearScale, Chart as ChartJS, CategoryScale, PointElement, LineElement, Legend, Tooltip, LineController} from "chart.js";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {useEffect, useState} from "react";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import {averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import {ToastContainer} from "react-toastify";
import {capitalize, Dictionary} from "lodash";
import {Module} from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
import {Chart} from "react-chartjs-2";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import DatePicker from "react-datepicker";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ProfileSummary from "@/components/ProfileSummary";
import moment from "moment";
import {Group, Stat, User} from "@/interfaces/user";
import {Divider} from "primereact/divider";
import Badge from "@/components/Low/Badge";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import Select from "@/components/Low/Select";
import { requestUser } from "@/utils/api";
ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip);
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
return {
props: serialize({user, entities, users, groups}),
};
}, sessionOptions);
interface Props {
user: User
users: User[]
entities: EntityWithRoles[]
groups: Group[]
}
export default function Stats({ user, entities, users, groups }: Props) {
const [statsUserId, setStatsUserId] = useState<string>(user.id);
const [startDate, setStartDate] = useState<Date | null>(moment(new Date()).subtract(1, "weeks").toDate());
const [endDate, setEndDate] = useState<Date | null>(new Date());
const [initialStatDate, setInitialStatDate] = useState<Date>();
const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState<Date | null>(new Date());
const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState<Date | null>(new Date());
const [dailyScoreDate, setDailyScoreDate] = useState<Date | null>(new Date());
const [intervalDates, setIntervalDates] = useState<Date[]>([]);
const {data: stats} = useFilterRecordsByUser<Stat[]>(statsUserId, !statsUserId);
useEffect(() => {
setInitialStatDate(
stats
.filter((s) => s.date)
.sort((a, b) => timestampToMoment(a).diff(timestampToMoment(b)))
.map(timestampToMoment)
.shift()
?.toDate(),
);
}, [stats]);
const calculateModuleScore = (stats: Stat[]) => {
const moduleStats = groupByModule(stats);
return Object.keys(moduleStats).map((y) => {
const correct = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = moduleStats[y].reduce((accumulator, current) => accumulator + current.score.total, 0);
return {
module: y as Module,
score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"),
};
});
};
const calculateModularScorePerSession = (stats: Stat[], module: Module) => {
const groupedBySession = groupBySession(stats);
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
const moduleStats = groupByModule(session);
if (!Object.keys(moduleStats).includes(module)) return null;
const correct = moduleStats[module].reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats[module].reduce((acc, curr) => acc + curr.score.total, 0);
return calculateBandScore(correct, total, module, user?.focus || "academic");
});
return sessionAverage;
};
const getListOfDateInInterval = (start: Date, end: Date) => {
let currentDate = moment(start);
const dates = [currentDate.toDate()];
while (moment(end).diff(currentDate, "days") > 0) {
currentDate = currentDate.add(1, "days");
dates.push(currentDate.toDate());
}
return dates;
};
useEffect(() => {
if (startDate && endDate) {
setIntervalDates(getListOfDateInInterval(startDate, endDate));
}
}, [startDate, endDate]);
const calculateTotalScore = (stats: Stat[], divisionFactor: number) => {
const moduleScores = calculateModuleScore(stats);
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / divisionFactor;
};
const calculateScorePerModule = (stats: Stat[], module: Module) => {
const moduleScores = calculateModuleScore(stats);
return moduleScores.find((x) => x.module === module)?.score || -1;
};
return (
<>
<Head>
<title>Stats | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user} className="gap-8">
<ProfileSummary
user={users.find((x) => x.id === statsUserId) || user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: countFullExams(stats),
label: "Exams",
tooltip: "Number of all conducted completed exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: countExamModules(stats),
label: "Modules",
tooltip: "Number of all exam modules performed including Level Test",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
tooltip: "Average success rate for questions responded",
},
]}
/>
<section className="flex flex-col gap-3">
<div className="w-full flex justify-between gap-4 items-center">
<>
{(user.type === "developer" || user.type === "admin") && (
<Select
className="w-full"
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value || user.id)}
/>
)}
{["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (
<Select
className="w-full"
options={users
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value || user.id)}
/>
)}
</>
</div>
{stats.length > 0 && (
<>
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Overall Level per Month */}
<div className="flex flex-col items-center gap-4 border w-full h-[420px] overflow-y-scroll scrollbar-hide md:max-w-sm border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Overall Level per Month</span>
<div className="flex gap-2 items-center">
{monthlyOverallScoreDate && (
<button
onClick={() =>
setMonthlyOverallScoreDate((prev) => moment(prev).subtract(1, "months").toDate())
}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={monthlyOverallScoreDate}
showMonthYearPicker
onChange={setMonthlyOverallScoreDate}
/>
{monthlyOverallScoreDate && (
<button
disabled={moment(monthlyOverallScoreDate).add(1, "months").isAfter(moment())}
onClick={() => setMonthlyOverallScoreDate((prev) => moment(prev).add(1, "months").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyOverallScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<div className="w-full grid grid-cols-3 gap-4 items-center">
{[...Array(31).keys()].map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
);
return date.isValid() && date.isSameOrBefore(moment()) ? (
<div
key={day}
className="flex flex-col gap-1 items-start border border-mti-gray-smoke rounded-lg overflow-hidden">
<span className="bg-mti-purple-ultralight w-full px-2 py-1 font-semibold">
Day {(day + 1).toString().padStart(2, "0")}
</span>
<span className="px-2">
Level{" "}
{calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date)),
5,
).toFixed(1)}
</span>
</div>
) : null;
})}
</div>
</div>
{/* Overall Level per Month Graph */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-[420px]">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Overall Level per Month</span>
<div className="flex gap-2 items-center">
{monthlyOverallScoreDate && (
<button
onClick={() =>
setMonthlyOverallScoreDate((prev) => moment(prev).subtract(1, "months").toDate())
}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={monthlyOverallScoreDate}
showMonthYearPicker
onChange={setMonthlyOverallScoreDate}
/>
{monthlyOverallScoreDate && (
<button
disabled={moment(monthlyOverallScoreDate).add(1, "months").isAfter(moment())}
onClick={() => setMonthlyOverallScoreDate((prev) => moment(prev).add(1, "months").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyOverallScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<Chart
type="line"
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
data={{
labels: [...Array(31).keys()]
.map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
);
return date.isValid() ? (day + 1).toString().padStart(2, "0") : undefined;
})
.filter((x) => !!x),
datasets: [
{
type: "line",
label: "Total",
fill: false,
borderColor: "#6A5FB1",
backgroundColor: "#7872BF",
borderWidth: 2,
spanGaps: true,
data: [...Array(31).keys()]
.map((day) => {
const date = moment(
`${(day + 1).toString().padStart(2, "0")}/${
moment(monthlyOverallScoreDate).get("month") + 1
}/${moment(monthlyOverallScoreDate).get("year")}`,
"DD/MM/yyyy",
);
return date.isValid()
? calculateTotalScore(
stats.filter((s) => timestampToMoment(s).isBefore(date)),
5,
).toFixed(1)
: undefined;
})
.filter((x) => !!x),
},
],
}}
/>
</div>
{/* Module Level per Day */}
<div className="flex flex-col gap-8 border w-full h-fit md:h-[420px] md:max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Module Level per Day</span>
<div className="flex gap-2 items-center">
{monthlyModuleScoreDate && (
<button
onClick={() =>
setMonthlyModuleScoreDate((prev) => moment(prev).subtract(1, "days").toDate())
}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="dd MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={monthlyModuleScoreDate}
onChange={setMonthlyModuleScoreDate}
/>
{monthlyModuleScoreDate && (
<button
disabled={moment(monthlyModuleScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setMonthlyModuleScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setMonthlyModuleScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<div className="flex flex-col gap-4">
{calculateModuleScore(stats.filter((s) => timestampToMoment(s).isBefore(moment(monthlyModuleScoreDate))))
.sort(sortByModule)
.map(({module, score}) => (
<div className="flex flex-col gap-2" key={module}>
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{score}</span> of <span className="font-medium">9</span>
</span>
<span className="text-xs">{capitalize(module)}</span>
</div>
<ProgressBar
color={module as Module}
percentage={(score * 100) / 9}
label=""
className="h-3"
/>
</div>
))}
</div>
</div>
</div>
<Divider />
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Module Level per Exam */}
<div className="flex flex-col items-center gap-4 border w-full h-[420px] overflow-y-scroll scrollbar-hide md:max-w-sm border-mti-gray-platinum p-4 pb-12 rounded-xl">
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-bold">Module Level per Exam</span>
<div className="flex gap-2 items-center">
{dailyScoreDate && (
<button onClick={() => setDailyScoreDate((prev) => moment(prev).subtract(1, "days").toDate())}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="dd MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={dailyScoreDate}
onChange={setDailyScoreDate}
/>
{dailyScoreDate && (
<button
disabled={moment(dailyScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setDailyScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setDailyScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<div className="w-full grid grid-cols-1 gap-6 items-center">
{Object.keys(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
).length === 0 && <span className="font-semibold ml-1">No exams performed this day...</span>}
{Object.keys(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
).map((session, index) => (
<div key={index} className="flex flex-col gap-2 items-start rounded-lg overflow-hidden">
<span className="bg-mti-purple-ultralight w-full px-2 py-1 font-semibold">
Exam {(index + 1).toString().padStart(2, "0")}
</span>
<div className="flex justify-between w-full">
{MODULE_ARRAY.map((module) => {
const score = calculateScorePerModule(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0,
),
)[session],
module,
);
return score === -1 ? null : <Badge module={module}>{score.toFixed(1)}</Badge>;
}).filter((m) => !!m)}
</div>
</div>
))}
</div>
</div>
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-[420px]">
<div className="flex flex-col gap-2 w-full mb-2">
<span className="text-sm font-bold">Module Level per Exam</span>
<div className="flex gap-2 items-center">
{dailyScoreDate && (
<button onClick={() => setDailyScoreDate((prev) => moment(prev).subtract(1, "days").toDate())}>
<BsChevronLeft />
</button>
)}
<DatePicker
dateFormat="dd MMMM yyyy"
className="border border-mti-gray-dim/40 px-2 py-1.5 rounded-lg text-center w-[200px]"
minDate={initialStatDate}
maxDate={new Date()}
selected={dailyScoreDate}
onChange={setDailyScoreDate}
/>
{dailyScoreDate && (
<button
disabled={moment(dailyScoreDate).add(1, "days").isAfter(moment())}
onClick={() => setDailyScoreDate((prev) => moment(prev).add(1, "days").toDate())}
className="disabled:text-neutral-200">
<BsChevronRight />
</button>
)}
<button onClick={() => setDailyScoreDate(new Date())}>
<BsArrowClockwise />
</button>
</div>
</div>
<Chart
type="line"
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
data={{
labels: Object.keys(
groupBySession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
),
).map((_, index) => `Exam ${(index + 1).toString().padStart(2, "0")}`),
datasets: [
...MODULE_ARRAY.map((module, index) => ({
type: "line" as const,
label: capitalize(module),
borderColor: COLORS[index],
backgroundColor: COLORS[index],
borderWidth: 2,
data: calculateModularScorePerSession(
stats.filter(
(s) =>
Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 &&
timestampToMoment(s).day() === moment(dailyScoreDate).day(),
),
module,
),
})),
],
}}
/>
</div>
</div>
<Divider />
<div className="w-full flex flex-col gap-4">
<DatePicker
dateFormat="dd/MM/yyyy"
className="border border-mti-gray-dim/40 px-4 py-2 rounded-lg text-center w-80"
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
filterDate={(date) => moment(date).isSameOrBefore(moment(new Date()))}
onChange={([initialDate, finalDate]) => {
setStartDate(initialDate);
setEndDate(finalDate);
}}
/>
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Module Score Band in Interval */}
{MODULE_ARRAY.map((module, index) => (
<div
className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96"
key={module}>
<span className="text-sm font-bold">{capitalize(module)} Score Band in Interval</span>
<Chart
options={{
scales: {
y: {
min: 0,
max: 9,
},
},
}}
type="line"
data={{
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
datasets: [
{
type: "line",
label: capitalize(module),
fill: false,
borderColor: COLORS[index],
backgroundColor: COLORS[index],
borderWidth: 2,
spanGaps: true,
data: intervalDates.map((date) => {
return calculateTotalScore(
stats.filter(
(s) => timestampToMoment(s).isBefore(date) && s.module === module,
),
1,
).toFixed(1);
}),
},
],
}}
/>
</div>
))}
</div>
</div>
</>
)}
</section>
{stats.length === 0 && (
<section className="flex flex-col gap-3">
<span className="font-semibold ml-1">No stats to display...</span>
</section>
)}
</Layout>
)}
</>
);
}