Added more control over the stats appearing in the stats page

This commit is contained in:
Tiago Ribeiro
2023-12-18 22:42:14 +00:00
parent c37bb2691b
commit 438778a03c
3 changed files with 225 additions and 128 deletions

View File

@@ -6,15 +6,15 @@ import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, totalExamsByModule, groupBySession, groupByModule} from "@/utils/stats";
import {averageScore, totalExamsByModule, groupBySession, groupByModule, timestampToMoment, groupByDate} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import {ToastContainer} from "react-toastify";
import {capitalize} from "lodash";
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 {MODULE_ARRAY} from "@/utils/moduleUtils";
import {MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
import {Chart} from "react-chartjs-2";
import useUsers from "@/hooks/useUsers";
import Select from "react-select";
@@ -24,6 +24,7 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ProfileSummary from "@/components/ProfileSummary";
import moment from "moment";
import {Stat} from "@/interfaces/user";
import {Divider} from "primereact/divider";
ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip);
@@ -64,6 +65,11 @@ export default function Stats() {
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = useState<Date | null>(new Date());
const [displayStats, setDisplayStats] = useState<Stat[]>([]);
const [initialStatDate, setInitialStatDate] = useState<Date>();
const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState<Date | null>(new Date());
const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState<Date | null>(new Date());
const [monthlyOverallGraphScoreDate, setMonthlyOverallGraphScoreDate] = useState<Date | null>(new Date());
const {user} = useUser({redirectTo: "/login"});
const {users} = useUsers();
@@ -76,11 +82,8 @@ export default function Stats() {
}, [user]);
useEffect(() => {
const startDateFilter = (s: Stat) => moment.unix(s.date / 1000).isAfter(moment(startDate));
const endDateFilter = (s: Stat) => {
console.log(moment.unix(s.date / 1000), moment(endDate).isAfter(moment.unix(s.date)));
return moment(endDate).isAfter(moment.unix(s.date / 1000));
};
const startDateFilter = (s: Stat) => timestampToMoment(s).isAfter(moment(startDate));
const endDateFilter = (s: Stat) => moment(endDate).isAfter(timestampToMoment(s));
const filters = [];
if (startDate) filters.push(startDateFilter);
@@ -89,20 +92,39 @@ export default function Stats() {
setDisplayStats(filters.reduce((d, f) => d.filter(f), stats));
}, [endDate, startDate, stats]);
const calculateTotalScorePerSession = () => {
const groupedBySession = groupBySession(stats);
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
const moduleStats = groupByModule(session);
const moduleScores = Object.keys(moduleStats).map((y) => {
useEffect(() => {
setInitialStatDate(
stats
.filter((s) => s.date)
.sort((a, b) => timestampToMoment(a).diff(timestampToMoment(b)))
.map(timestampToMoment)
.shift()
?.toDate(),
);
}, [stats]);
useEffect(() => {
setStartDate(initialStatDate || moment("01/01/2023").toDate());
}, [initialStatDate]);
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,
module: y as Module,
score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"),
};
});
};
const calculateTotalScorePerKey = (stats: Stat[], keyFunction: (stats: Stat[]) => Dictionary<Stat[]>) => {
const groupedBySession = keyFunction(stats);
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
const moduleScores = calculateModuleScore(session);
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4;
});
@@ -110,7 +132,12 @@ export default function Stats() {
return sessionAverage;
};
const calculateAverageTimePerModule = () => {
const calculateTotalScore = (stats: Stat[]) => {
const moduleScores = calculateModuleScore(stats);
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4;
};
const calculateAverageTimePerModule = (stats: Stat[]) => {
const groupedBySession = groupBySession(stats.filter((x) => !!x.timeSpent));
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
@@ -122,7 +149,7 @@ export default function Stats() {
return sessionAverage;
};
const calculateModularScorePerSession = (module: Module) => {
const calculateModularScorePerSession = (stats: Stat[], module: Module) => {
const groupedBySession = groupBySession(stats);
const sessionAverage = Object.keys(groupedBySession).map((x: string) => {
const session = groupedBySession[x];
@@ -208,9 +235,148 @@ export default function Stats() {
/>
)}
</>
</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>
<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}
/>
</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() ? (
<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))).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>
<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}
/>
</div>
<Chart
type="line"
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)),
).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>
<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}
/>
</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 />
{displayStats.length > 0 && (
<div className="w-full flex flex-col gap-4">
<DatePicker
dateFormat="dd/MM/yyyy"
className="border border-mti-gray-dim/40 px-4 py-1.5 rounded-lg text-center w-[256px]"
className="border border-mti-gray-dim/40 px-4 py-2 rounded-lg text-center w-80"
startDate={startDate}
endDate={endDate}
selectsRange
@@ -221,84 +387,7 @@ export default function Stats() {
setEndDate(finalDate);
}}
/>
</div>
{displayStats.length > 0 && (
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
{/* Exams per module */}
<div className="flex flex-col gap-10 border w-full h-fit md:h-96 md:max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<span className="text-sm font-bold">Exams per Module</span>
<div className="flex flex-col gap-4">
{MODULE_ARRAY.map((module) => (
<div className="flex flex-col gap-2" key={module}>
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{totalExamsByModule(displayStats, module)}</span> of{" "}
<span className="font-medium">{Object.keys(groupBySession(displayStats)).length}</span>
</span>
<span className="text-xs">{capitalize(module)}</span>
</div>
<ProgressBar
color={module}
percentage={
(totalExamsByModule(displayStats, module) * 100) /
Object.keys(groupBySession(displayStats)).length
}
label=""
className="h-3"
/>
</div>
))}
</div>
</div>
{/* Module Score */}
<div className="flex flex-col gap-10 border w-full h-fit md:h-96 md:max-w-xs border-mti-gray-platinum p-4 pb-12 rounded-xl">
<span className="text-sm font-bold">Module Score Bands</span>
<div className="flex flex-col gap-4">
{MODULE_ARRAY.map((module) => (
<div className="flex flex-col gap-2" key={module}>
<div className="flex justify-between items-end">
<span className="text-xs">
<span className="font-medium">{user.levels[module]}</span> of{" "}
<span className="font-medium">{user.desiredLevels[module]}</span>
</span>
<span className="text-xs">{capitalize(module)}</span>
</div>
<ProgressBar
color={module}
percentage={(user.levels[module] * 100) / user.desiredLevels[module]}
label=""
className="h-3"
/>
</div>
))}
</div>
</div>
{/* Total Score Band per Session */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Total Score Band per Session</span>
<Chart
type="line"
data={{
labels: Object.keys(groupBySession(displayStats)).map((_, index) => index),
datasets: [
{
type: "line",
label: "Total",
fill: false,
borderColor: "#6A5FB1",
backgroundColor: "#7872BF",
borderWidth: 2,
spanGaps: true,
data: calculateTotalScorePerSession(),
},
],
}}
/>
</div>
{/* Module Score Band per Session */}
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
<span className="text-sm font-bold">Module Score Band per Session</span>
@@ -313,7 +402,7 @@ export default function Stats() {
borderColor: COLORS[index],
backgroundColor: COLORS[index],
borderWidth: 2,
data: calculateModularScorePerSession(module),
data: calculateModularScorePerSession(displayStats, module),
})),
],
}}
@@ -326,7 +415,9 @@ export default function Stats() {
<Chart
type="line"
data={{
labels: Object.keys(groupBySession(displayStats.filter((s) => !!s.timeSpent))).map((_, index) => index),
labels: Object.keys(groupBySession(displayStats.filter((s) => !!s.timeSpent))).map(
(_, index) => index,
),
datasets: [
{
type: "line",
@@ -336,13 +427,14 @@ export default function Stats() {
backgroundColor: "#7872BF",
borderWidth: 2,
spanGaps: true,
data: calculateAverageTimePerModule(),
data: calculateAverageTimePerModule(displayStats),
},
],
}}
/>
</div>
</div>
</div>
)}
</section>
{displayStats.length === 0 && (

View File

@@ -1,7 +1,7 @@
import {Module} from "@/interfaces";
import {Exercise} from "@/interfaces/exam";
export const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking"];
export const MODULE_ARRAY: Module[] = ["reading", "listening", "writing", "speaking", "level"];
export const moduleLabels: {[key in Module]: string} = {
listening: "Listening",
@@ -11,7 +11,7 @@ export const moduleLabels: {[key in Module]: string} = {
level: "Level",
};
export const sortByModule = (a: {module: Module}, b: {module: Module}) => {
export const sortByModule = (a: {module: Module; [key: string]: any}, b: {module: Module; [key: string]: any}) => {
return MODULE_ARRAY.findIndex((x) => a.module === x) - MODULE_ARRAY.findIndex((x) => b.module === x);
};

View File

@@ -4,6 +4,11 @@ import {convertCamelCaseToReadable} from "@/utils/string";
import {UserSolution} from "@/interfaces/exam";
import {Module} from "@/interfaces";
import {MODULES} from "@/constants/ielts";
import moment from "moment";
export const timestampToMoment = (stat: Stat): moment.Moment => {
return moment.unix(stat.date > Math.pow(10, 11) ? stat.date / 1000 : stat.date);
};
export const totalExams = (stats: Stat[]): number => {
const moduleStats = formatModuleTotalStats(stats);