diff --git a/src/components/Low/Badge.tsx b/src/components/Low/Badge.tsx new file mode 100644 index 00000000..76c421af --- /dev/null +++ b/src/components/Low/Badge.tsx @@ -0,0 +1,30 @@ +import {Module} from "@/interfaces"; +import clsx from "clsx"; +import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; + +interface Props { + module: Module; + children: string; +} + +export default function Badge({module, children}: Props) { + return ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {children} +
+ ); +} diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx index 5be1f2cb..a6abe9ef 100644 --- a/src/pages/stats.tsx +++ b/src/pages/stats.tsx @@ -1,20 +1,20 @@ /* eslint-disable @next/next/no-img-element */ import Head from "next/head"; -import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs"; +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 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,10 +24,12 @@ 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"; +import Badge from "@/components/Low/Badge"; ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip); -const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8"]; +const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"]; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -61,9 +63,15 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { export default function Stats() { const [statsUserId, setStatsUserId] = useState(); - const [startDate, setStartDate] = useState(moment("01/01/2023").toDate()); + const [startDate, setStartDate] = useState(moment(new Date()).subtract(1, "weeks").toDate()); const [endDate, setEndDate] = useState(new Date()); - const [displayStats, setDisplayStats] = useState([]); + const [initialStatDate, setInitialStatDate] = useState(); + + const [monthlyOverallScoreDate, setMonthlyOverallScoreDate] = useState(new Date()); + const [monthlyModuleScoreDate, setMonthlyModuleScoreDate] = useState(new Date()); + + const [dailyScoreDate, setDailyScoreDate] = useState(new Date()); + const [intervalDates, setIntervalDates] = useState([]); const {user} = useUser({redirectTo: "/login"}); const {users} = useUsers(); @@ -76,53 +84,30 @@ 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)); - }; + setInitialStatDate( + stats + .filter((s) => s.date) + .sort((a, b) => timestampToMoment(a).diff(timestampToMoment(b))) + .map(timestampToMoment) + .shift() + ?.toDate(), + ); + }, [stats]); - const filters = []; - if (startDate) filters.push(startDateFilter); - if (endDate) filters.push(endDateFilter); + 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); - 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) => { - 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, - score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"), - }; - }); - - return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4; + return { + module: y as Module, + score: calculateBandScore(correct, total, y as Module, user?.focus || "academic"), + }; }); - - return sessionAverage; }; - const calculateAverageTimePerModule = () => { - const groupedBySession = groupBySession(stats.filter((x) => !!x.timeSpent)); - const sessionAverage = Object.keys(groupedBySession).map((x: string) => { - const session = groupedBySession[x]; - const timeSpent = session[0].timeSpent!; - - return Math.floor(timeSpent / session.length / 60); - }); - - 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]; @@ -137,6 +122,33 @@ export default function Stats() { 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[]) => { + const moduleScores = calculateModuleScore(stats); + return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4; + }; + + const calculateScorePerModule = (stats: Stat[], module: Module) => { + const moduleScores = calculateModuleScore(stats); + return moduleScores.find((x) => x.module === module)?.score || -1; + }; + return ( <> @@ -208,144 +220,573 @@ export default function Stats() { /> )} - moment(date).isSameOrBefore(moment(new Date()))} - onChange={([initialDate, finalDate]) => { - setStartDate(initialDate ?? moment("01/01/2023").toDate()); - setEndDate(finalDate); - }} - /> - {displayStats.length > 0 && ( -
- {/* Exams per module */} -
- Exams per Module -
- {MODULE_ARRAY.map((module) => ( -
-
- - {totalExamsByModule(displayStats, module)} of{" "} - {Object.keys(groupBySession(displayStats)).length} - - {capitalize(module)} -
- 0 && ( + <> +
+ {/* Overall Level per Month */} +
+
+ Overall Level per Month +
+ {monthlyOverallScoreDate && ( + + )} + + {monthlyOverallScoreDate && ( + + )} +
- ))} +
+
+ {[...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()) ? ( +
+ + Day {(day + 1).toString().padStart(2, "0")} + + + Level{" "} + {calculateTotalScore(stats.filter((s) => timestampToMoment(s).isBefore(date))).toFixed(1)} + +
+ ) : null; + })} +
+
+ + {/* Overall Level per Month Graph */} +
+
+ Overall Level per Month +
+ {monthlyOverallScoreDate && ( + + )} + + {monthlyOverallScoreDate && ( + + )} + +
+
+ { + 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), + }, + ], + }} + /> +
+ + {/* Module Level per Day */} +
+
+ Module Level per Day +
+ {monthlyModuleScoreDate && ( + + )} + + {monthlyModuleScoreDate && ( + + )} + +
+
+
+ {calculateModuleScore(stats.filter((s) => timestampToMoment(s).isBefore(moment(monthlyModuleScoreDate)))) + .sort(sortByModule) + .map(({module, score}) => ( +
+
+ + {score} of 9 + + {capitalize(module)} +
+ +
+ ))} +
- {/* Module Score */} -
- Module Score Bands -
- {MODULE_ARRAY.map((module) => ( -
-
- - {user.levels[module]} of{" "} - {user.desiredLevels[module]} - - {capitalize(module)} -
- + +
+ {/* Module Level per Exam */} +
+
+ Module Level per Exam +
+ {dailyScoreDate && ( + + )} + + {dailyScoreDate && ( + + )} +
- ))} +
+ +
+ {Object.keys( + groupBySession( + stats.filter( + (s) => + Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 && + timestampToMoment(s).day() === moment(dailyScoreDate).day(), + ), + ), + ).length === 0 && No exams performed this day...} + + {Object.keys( + groupBySession( + stats.filter( + (s) => + Math.abs(timestampToMoment(s).diff(moment(dailyScoreDate), "days")) === 0 && + timestampToMoment(s).day() === moment(dailyScoreDate).day(), + ), + ), + ).map((session, index) => ( +
+ + Exam {(index + 1).toString().padStart(2, "0")} + +
+ {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 : {score.toFixed(1)}; + }).filter((m) => !!m)} +
+
+ ))} +
+
+ +
+
+ Module Level per Exam +
+ {dailyScoreDate && ( + + )} + + {dailyScoreDate && ( + + )} + +
+
+ + 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, + ), + })), + ], + }} + />
- {/* Total Score Band per Session */} -
- Total Score Band per Session - index), - datasets: [ - { - type: "line", - label: "Total", - fill: false, - borderColor: "#6A5FB1", - backgroundColor: "#7872BF", - borderWidth: 2, - spanGaps: true, - data: calculateTotalScorePerSession(), - }, - ], - }} - /> -
+ - {/* Module Score Band per Session */} -
- Module Score Band per Session - index), - datasets: [ - ...MODULE_ARRAY.map((module, index) => ({ - type: "line" as const, - label: capitalize(module), - borderColor: COLORS[index], - backgroundColor: COLORS[index], - borderWidth: 2, - data: calculateModularScorePerSession(module), - })), - ], +
+ moment(date).isSameOrBefore(moment(new Date()))} + onChange={([initialDate, finalDate]) => { + setStartDate(initialDate); + setEndDate(finalDate); }} /> -
+
+ {/* Reading Score Band in Interval */} +
+ Reading Score Band in Interval + moment(date).format("DD/MM/YYYY")), + datasets: [ + { + type: "line", + label: "Reading", + fill: false, + borderColor: COLORS[0], + backgroundColor: COLORS[0], + borderWidth: 2, + spanGaps: true, + data: intervalDates.map((date) => { + return calculateTotalScore( + stats.filter( + (s) => timestampToMoment(s).isBefore(date) && s.module === "reading", + ), + ).toFixed(1); + }), + }, + ], + }} + /> +
- {/* Average Time per Module */} -
- Average Time per Module (in Minutes) - !!s.timeSpent))).map((_, index) => index), - datasets: [ - { - type: "line", - label: "Average (in minutes)", - fill: false, - borderColor: "#6A5FB1", - backgroundColor: "#7872BF", - borderWidth: 2, - spanGaps: true, - data: calculateAverageTimePerModule(), - }, - ], - }} - /> + {/* Listening Score Band in Interval */} +
+ Listening Score Band in Interval + moment(date).format("DD/MM/YYYY")), + datasets: [ + { + type: "line", + label: "Listening", + fill: false, + borderColor: COLORS[1], + backgroundColor: COLORS[1], + borderWidth: 2, + spanGaps: true, + data: intervalDates.map((date) => { + return calculateTotalScore( + stats.filter( + (s) => timestampToMoment(s).isBefore(date) && s.module === "listening", + ), + ).toFixed(1); + }), + }, + ], + }} + /> +
+ + {/* Writing Score Band in Interval */} +
+ Writing Score Band in Interval + moment(date).format("DD/MM/YYYY")), + datasets: [ + { + type: "line", + label: "Writing", + fill: false, + borderColor: COLORS[2], + backgroundColor: COLORS[2], + borderWidth: 2, + spanGaps: true, + data: intervalDates.map((date) => { + return calculateTotalScore( + stats.filter( + (s) => timestampToMoment(s).isBefore(date) && s.module === "writing", + ), + ).toFixed(1); + }), + }, + ], + }} + /> +
+ + {/* Speaking Score Band in Interval */} +
+ Speaking Score Band in Interval + moment(date).format("DD/MM/YYYY")), + datasets: [ + { + type: "line", + label: "Speaking", + fill: false, + borderColor: COLORS[3], + backgroundColor: COLORS[3], + borderWidth: 2, + spanGaps: true, + data: intervalDates.map((date) => { + return calculateTotalScore( + stats.filter( + (s) => timestampToMoment(s).isBefore(date) && s.module === "speaking", + ), + ).toFixed(1); + }), + }, + ], + }} + /> +
+ + {/* Level Score Band in Interval */} +
+ Level Score Band in Interval + moment(date).format("DD/MM/YYYY")), + datasets: [ + { + type: "line", + label: "Level", + fill: false, + borderColor: COLORS[4], + backgroundColor: COLORS[4], + borderWidth: 2, + spanGaps: true, + data: intervalDates.map((date) => { + return calculateTotalScore( + stats.filter((s) => timestampToMoment(s).isBefore(date) && s.module === "level"), + ).toFixed(1); + }), + }, + ], + }} + /> +
+
-
+ )} - {displayStats.length === 0 && ( + {stats.length === 0 && (
No stats to display...
diff --git a/src/utils/moduleUtils.ts b/src/utils/moduleUtils.ts index 37783463..8abafbd8 100644 --- a/src/utils/moduleUtils.ts +++ b/src/utils/moduleUtils.ts @@ -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); }; diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 8f8e7e97..ebd2f074 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -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);