From 00556ac4cf6051fa32e753673accf3cc54103532 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 12 May 2025 15:14:13 -0700 Subject: [PATCH 01/23] build analytics page outline --- .../course/[courseId]/analytics/page.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/dashboard/course/[courseId]/analytics/page.tsx diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx new file mode 100644 index 0000000..0b8e813 --- /dev/null +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useParams } from "next/navigation"; +import React, { useState } from "react"; + +export default function Page() { + const params = useParams(); + const courseId = parseInt((params.courseId as string) ?? "0"); + const [query, setQuery] = useState(); + + return ( +
+

Overall Performance

+
+
+

Student Data

+ setQuery(e.target.value)} + className="h-10 w-[14vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" + /> +
+
+
+ ); +} From 20a1c20b45e089f0c80440cde513f717386e681b Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 12 May 2025 15:29:19 -0700 Subject: [PATCH 02/23] create table design --- .../course/[courseId]/analytics/page.tsx | 40 +++++- components/ui/table.tsx | 120 ++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 components/ui/table.tsx diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 0b8e813..9cfe6cb 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -1,5 +1,14 @@ "use client"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { useParams } from "next/navigation"; import React, { useState } from "react"; @@ -16,12 +25,39 @@ export default function Page() {

Student Data

setQuery(e.target.value)} className="h-10 w-[14vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" /> -
+
+ + + + Name + Identification + Attendance + Poll Score + Activity + + + + + + Student name here + + A123456789 + 85% + 85% + + + + + +
+
); } diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 0000000..c0df655 --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} From ac21bbeb291c87be2a725d65b46ec912dd233983 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 12 May 2025 16:19:13 -0700 Subject: [PATCH 03/23] build attendance pie chart --- .../course/[courseId]/analytics/page.tsx | 97 ++++++++++++++----- components/ui/DonutChart.tsx | 71 ++++++++++++++ 2 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 components/ui/DonutChart.tsx diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 9cfe6cb..0a5892e 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -1,16 +1,40 @@ "use client"; +import React, { useState } from "react"; import { Table, TableBody, - TableCaption, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { useParams } from "next/navigation"; -import React, { useState } from "react"; +import { ChartConfig } from "@/components/ui/chart"; +import DonutChart from "@/components/ui/DonutChart"; + +const chartData = [ + { idea: "participated", count: 200, fill: "#CCCCCC" }, + { idea: "notParticipated", count: 300, fill: "#BAFF7E" }, +]; +const chartConfig = { + count: { + label: "Count", + }, + participated: { + label: "Participated", + color: "grey", + }, + notParticipated: { + label: "Not Participated", + color: "black", + }, +} satisfies ChartConfig; + +const dataKey = "count"; +const nameKey = "idea"; +const description = "Class Average"; +const descriptionStatistic = 75; export default function Page() { const params = useParams(); @@ -20,7 +44,20 @@ export default function Page() { return (

Overall Performance

-
+
+
+
+ +
+
+

Student Data

-
- +
+
- Name - Identification - Attendance - Poll Score - Activity + + Name + + + Identification + + + Attendance + + + Poll Score + + + Activity + - - - Student name here - - A123456789 - 85% - 85% - - - - + {Array.from({ length: 10 }) + .keys() + .map((i) => ( + + + Student name here + + A123456789 + 85% + 85% + + + + + ))}
diff --git a/components/ui/DonutChart.tsx b/components/ui/DonutChart.tsx new file mode 100644 index 0000000..e980a43 --- /dev/null +++ b/components/ui/DonutChart.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Label, Pie, PieChart } from "recharts"; + +interface Props { + chartData: {}[]; + chartConfig: ChartConfig; + dataKey: string; + nameKey: string; + description: string; + descriptionStatistic: number; +} + +export default function DonutChart({ + chartData, + chartConfig, + dataKey, + nameKey, + description, + descriptionStatistic, +}: Props) { + return ( + + + } /> + + + + + ); +} From 3cd7ac5e84b079f2757c48b0df1816eec8ec37c7 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 19 May 2025 15:25:36 -0700 Subject: [PATCH 04/23] fetch past question data --- .../course/[courseId]/analytics/page.tsx | 101 +++++++++++++----- components/ui/DonutChart.tsx | 6 +- services/question.ts | 49 +++++++++ 3 files changed, 128 insertions(+), 28 deletions(-) diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 0a5892e..f007590 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Table, TableBody, @@ -12,6 +12,8 @@ import { import { useParams } from "next/navigation"; import { ChartConfig } from "@/components/ui/chart"; import DonutChart from "@/components/ui/DonutChart"; +import { getPastQuestionsWithScore } from "@/services/question"; +import { useToast } from "@/hooks/use-toast"; const chartData = [ { idea: "participated", count: 200, fill: "#CCCCCC" }, @@ -39,40 +41,84 @@ const descriptionStatistic = 75; export default function Page() { const params = useParams(); const courseId = parseInt((params.courseId as string) ?? "0"); - const [query, setQuery] = useState(); + const [pastQuestions, setPastQuestions] = useState< + { type: string; title: string; average: number }[] + >([]); + const [students, setStudents] = useState(); + const { toast } = useToast(); + + useEffect(() => { + const fetchCourseStatistics = async () => { + await getPastQuestionsWithScore(courseId) + .then((res) => { + if ("error" in res) + return toast({ + variant: "destructive", + description: res?.error ?? "Unknown error occurred.", + }); + else { + setPastQuestions(res); + } + }) + .catch((err: unknown) => { + console.error(err); + return toast({ + variant: "destructive", + description: "Unknown error occurred", + }); + }); + }; + const fetchStudentData = async () => {}; + fetchCourseStatistics(); + fetchStudentData(); + }, []); return (

Overall Performance

-
-
-
- -
+
+ {/* Donut Chart */} +
+ +
+ {/* Past Questions */} +
+ {pastQuestions.map((question, idx) => ( +
+
+

+ {question.type} +

+

{question.average}

+
+

{question.title}

+
+ ))}

Student Data

- setQuery(e.target.value)} - className="h-10 w-[14vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" - /> +
+ {/* Student Data Table */}
- + - Name + Student Identification @@ -84,7 +130,11 @@ export default function Page() { Poll Score - Activity + @@ -93,8 +143,9 @@ export default function Page() { .keys() .map((i) => ( - - Student name here + +

Student name here

+

student@ucsd.edu

A123456789 85% diff --git a/components/ui/DonutChart.tsx b/components/ui/DonutChart.tsx index e980a43..629efc2 100644 --- a/components/ui/DonutChart.tsx +++ b/components/ui/DonutChart.tsx @@ -25,15 +25,15 @@ export default function DonutChart({ descriptionStatistic, }: Props) { return ( - + } />
- {Array.from({ length: 10 }) - .keys() - .map((i) => ( - - -

Student name here

-

student@ucsd.edu

-
- A123456789 - 85% - 85% - - - -
- ))} + {students.map((student, idx) => ( + + +

{student.name}

+

+ {student.email ?? "No email provided"} +

+
+ A123456789 + +

50 && + student.attendance <= 75 + ? "bg-[#F8ECA1]" + : "bg-[#BFF2A6]" + }`} + > + {student.attendance}% +

+
+ +

50 && student.pollScore <= 75 + ? "bg-[#F8ECA1]" + : "bg-[#BFF2A6]" + }`} + > + {student.pollScore}% +

+
+ + + +
+ ))}
diff --git a/services/userCourse.ts b/services/userCourse.ts index deb9dc5..9eb15e7 100644 --- a/services/userCourse.ts +++ b/services/userCourse.ts @@ -96,3 +96,75 @@ export async function addUserToCourseByEmail( return { error: "User is already enrolled in this course" }; } } + +export async function getStudents(courseId: number) { + try{ + const studentsData = await prisma.user.findMany({ + where: { + courses: { + some: { + courseId, + role: "STUDENT", + }, + }, + }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + responses: + { + select: { + question: { + select: { + sessionId: true, + options: { + select: { + isCorrect: true, + }, + }, + }, + }, + }, + }, + }, + }); + const sessions = await prisma.courseSession.findMany({ + where: { + courseId + }, + select: { + id: true, + }, + }).then((res) => res.map((session) => session.id)); + + const result = studentsData.map((student) => { + const totalSessions = sessions.length; + const studentResponses = student.responses.filter((response) => sessions.includes(response.question.sessionId)); + const attendedSessions = new Set( + studentResponses.map((response) => response.question.sessionId) + ).size; + + const correctResponses = studentResponses.filter( + (response) => response.question.options.some((option) => option.isCorrect) + ).length; + + const attendance = totalSessions > 0 ? Math.trunc((attendedSessions / totalSessions) * 100) : 0; + const pollScore = studentResponses.length > 0 ? Math.trunc((correctResponses / studentResponses.length) * 100) : 0; + + return { + name: String(student.firstName) + ' ' + String(student.lastName), + email: student.email, + attendance, + pollScore + }; + }); + return result; + + + } catch (err){ + console.error(err); + return { error: "Error fetching students." }; + } +} From c8d3a913233a4faea3a2e91fdb336cc77a7cefda Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 19 May 2025 20:57:09 -0700 Subject: [PATCH 08/23] build analytics page buttons --- .../course/[courseId]/analytics/page.tsx | 107 +++++++++++------- lib/constants.ts | 2 + 2 files changed, 71 insertions(+), 38 deletions(-) diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 6be880d..4cedf2c 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -12,7 +12,14 @@ import { TableRow, } from "@/components/ui/table"; import { useToast } from "@/hooks/use-toast"; -import { chartConfig, dataKey, description, nameKey, questionTypeMap } from "@/lib/constants"; +import { + chartConfig, + dataKey, + description, + nameKey, + questionTypeMap, + analyticsPages, +} from "@/lib/constants"; import { getPastQuestionsWithScore, getResponseStatistics } from "@/services/question"; import { getStudents } from "@/services/userCourse"; @@ -37,6 +44,7 @@ export default function Page() { pollScore: number; }[] >([]); + const [page, setPage] = useState("Performance"); const { toast } = useToast(); const chartData = [ @@ -109,45 +117,68 @@ export default function Page() { return (
-

Overall Performance

+
+ {analyticsPages.map((pageTitle: string) => ( + + ))} + {/* + */} +
- {/* Donut Chart */} -
- -
- {/* Past Questions */} -
- {pastQuestions.map((question, idx) => ( -
-
-

- {questionTypeMap[question.type]} -

-

{question.average}

-
-

{question.title}

+ {/* Performance page */} + {page === "Performance" ? ( + <> +
+ {/* Donut Chart */} + +
+ {/* Past Questions */} +
+ {pastQuestions.map((question, idx) => ( +
+
+

+ {questionTypeMap[question.type]} +

+

{question.average}

+
+

{question.title}

+
+ ))}
- ))} -
+ + ) : ( +
+ )}

Student Data

diff --git a/lib/constants.ts b/lib/constants.ts index 5478730..b84754c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -36,5 +36,7 @@ export const chartConfig = { }, } satisfies ChartConfig; +export const analyticsPages = ['Performance', 'Attendance'] + export const DEFAULT_SHOW_RESULTS = false; \ No newline at end of file From a8d10bf534125a71288a00652e3df16b08aa753f Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Tue, 27 May 2025 11:57:04 -0700 Subject: [PATCH 09/23] create attendance rate chart --- .../course/[courseId]/analytics/page.tsx | 19 ++- components/ui/AttendanceLineChart.tsx | 109 ++++++++++++++++++ lib/constants.ts | 11 +- services/userCourse.ts | 68 +++++++++++ 4 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 components/ui/AttendanceLineChart.tsx diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 4cedf2c..174d0ea 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -13,7 +13,7 @@ import { } from "@/components/ui/table"; import { useToast } from "@/hooks/use-toast"; import { - chartConfig, + performanceChartConfig, dataKey, description, nameKey, @@ -22,6 +22,7 @@ import { } from "@/lib/constants"; import { getPastQuestionsWithScore, getResponseStatistics } from "@/services/question"; import { getStudents } from "@/services/userCourse"; +import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; export default function Page() { const params = useParams(); @@ -47,7 +48,7 @@ export default function Page() { const [page, setPage] = useState("Performance"); const { toast } = useToast(); - const chartData = [ + const performanceChartData = [ { result: "correct", count: responseStatistics.correct, fill: "#BAFF7E" }, { result: "incorrect", count: responseStatistics.incorrect, fill: "#CCCCCC" }, ]; @@ -131,18 +132,16 @@ export default function Page() { {pageTitle} ))} - {/* - */}
-
+
{/* Performance page */} {page === "Performance" ? ( <> -
+
{/* Donut Chart */}
{/* Past Questions */} -
+
{pastQuestions.map((question, idx) => (
) : ( -
+ )}
diff --git a/components/ui/AttendanceLineChart.tsx b/components/ui/AttendanceLineChart.tsx new file mode 100644 index 0000000..dd1e3a1 --- /dev/null +++ b/components/ui/AttendanceLineChart.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from "react"; +import { ChartContainer } from "@/components/ui/chart"; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; +import dayjs from "dayjs"; +import { attendanceChartConfig } from "@/lib/constants"; +import { getAttendanceByDay } from "@/services/userCourse"; +import { useToast } from "@/hooks/use-toast"; + +interface Props { + courseId: number; +} + +export default function AttendanceLineChart({ courseId }: Props) { + const [weekStart, setWeekStart] = useState(dayjs().startOf("week").toDate()); + const [chartData, setChartData] = useState<{ date: string; attendance: number }[]>(); + const { toast } = useToast(); + + useEffect(() => { + const fetchWeekData = async (start: Date) => { + const week: { date: string; attendance: number }[] = []; + for (let i = 0; i < 7; i++) { + const day = dayjs(start).add(i, "day"); + let attendance = 0; + await getAttendanceByDay(courseId, day.toDate()) + .then((res) => { + if (typeof res !== "number" && "error" in res) { + return toast({ + variant: "destructive", + description: res?.error ?? "Unknown error occurred.", + }); + } else { + attendance = Number(res); + } + }) + .catch((err: unknown) => { + console.error(err); + return toast({ + variant: "destructive", + description: "Unknown error occurred.", + }); + }); + week.push({ + date: day.format("M/D"), + attendance, + }); + } + setChartData(week); + }; + fetchWeekData(weekStart); + }, [weekStart]); + + const handleNextWeek = () => { + setWeekStart((prev) => dayjs(prev).add(7, "day").toDate()); + }; + const handlePrevWeek = () => { + setWeekStart((prev) => dayjs(prev).subtract(7, "day").toDate()); + }; + return ( +
+ + + + + + `${value}`} + /> + `${value}%`} + /> + + + + + +
+ ); +} diff --git a/lib/constants.ts b/lib/constants.ts index b84754c..733e2eb 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -22,7 +22,7 @@ export const questionTypeMap = { export const dataKey = "count"; export const nameKey = "result"; export const description = "Class Average"; -export const chartConfig = { +export const performanceChartConfig = { count: { label: "Count", }, @@ -36,7 +36,14 @@ export const chartConfig = { }, } satisfies ChartConfig; -export const analyticsPages = ['Performance', 'Attendance'] +export const attendanceChartConfig = { + attendance: { + label: "Attendance", + color: "black", + }, +} satisfies ChartConfig; + +export const analyticsPages = ['Performance', 'Attendance Rate'] export const DEFAULT_SHOW_RESULTS = false; \ No newline at end of file diff --git a/services/userCourse.ts b/services/userCourse.ts index 9eb15e7..6bf1248 100644 --- a/services/userCourse.ts +++ b/services/userCourse.ts @@ -168,3 +168,71 @@ export async function getStudents(courseId: number) { return { error: "Error fetching students." }; } } + +export async function getAttendanceByDay(courseId: number, date: Date) { + try{ + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const sessions = await prisma.courseSession.findMany({ + where: { + courseId, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + }, + select: { + id: true, + }, + }).then((res) => res.map((session) => session.id)); + + const totalStudents = await prisma.user.count( + { + where: { + courses: { + some: { + courseId, + role: "STUDENT", + }, + }, + }, + } + ) + + if (sessions.length === 0 || totalStudents === 0) { + return 0; + } + + const attendedStudents = await prisma.response.findMany({ + where: { + question: { + sessionId: { + in: sessions, + }, + }, + user: { + courses: { + some: { + courseId, + role: 'STUDENT', + }, + }, + }, + }, + distinct: ['userId'], + select: { + userId: true, + }, + }); + + return Math.trunc((attendedStudents.length / totalStudents) * 100); + } catch (err){ + console.error(err); + return { error: "Error calculating attendance rate." }; + } +} + From f9b2b9c52628c16241aad201ad7dc62527976ce8 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Tue, 27 May 2025 12:14:49 -0700 Subject: [PATCH 10/23] edit student table, add search functionality --- .../course/[courseId]/analytics/page.tsx | 18 ++++++------- services/userCourse.ts | 26 ++++++++++++++++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 174d0ea..4f241a1 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useParams } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import DonutChart from "@/components/ui/DonutChart"; import { Table, @@ -46,6 +46,7 @@ export default function Page() { }[] >([]); const [page, setPage] = useState("Performance"); + const [studentQuery, setStudentQuery] = useState(undefined); const { toast } = useToast(); const performanceChartData = [ @@ -92,8 +93,12 @@ export default function Page() { }); }); }; + void fetchCourseStatistics(); + }, []); + + useEffect(() => { const fetchStudentData = async () => { - await getStudents(courseId) + await getStudents(courseId, studentQuery) .then((res) => { if ("error" in res) return toast({ @@ -112,10 +117,8 @@ export default function Page() { }); }); }; - void fetchCourseStatistics(); void fetchStudentData(); - }, []); - + }, [studentQuery]); return (
@@ -193,9 +196,6 @@ export default function Page() { Student - - Identification - Attendance @@ -204,6 +204,7 @@ export default function Page() { setStudentQuery(e.target.value)} type="text" placeholder="Search student..." className="h-8 w-[12vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" @@ -220,7 +221,6 @@ export default function Page() { {student.email ?? "No email provided"}

- A123456789

Date: Tue, 27 May 2025 17:37:09 -0700 Subject: [PATCH 11/23] change to protected route --- .../course/[courseId]/analytics/page.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 4f241a1..0a2ab7e 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import React, { useEffect, useRef, useState } from "react"; import DonutChart from "@/components/ui/DonutChart"; import { @@ -23,10 +23,13 @@ import { import { getPastQuestionsWithScore, getResponseStatistics } from "@/services/question"; import { getStudents } from "@/services/userCourse"; import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; +import useAccess from "@/hooks/use-access"; +import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; export default function Page() { const params = useParams(); const courseId = parseInt((params.courseId as string) ?? "0"); + const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const [pastQuestions, setPastQuestions] = useState< { type: keyof typeof questionTypeMap; title: string; average: number }[] >([]); @@ -48,12 +51,24 @@ export default function Page() { const [page, setPage] = useState("Performance"); const [studentQuery, setStudentQuery] = useState(undefined); const { toast } = useToast(); + const router = useRouter(); const performanceChartData = [ { result: "correct", count: responseStatistics.correct, fill: "#BAFF7E" }, { result: "incorrect", count: responseStatistics.incorrect, fill: "#CCCCCC" }, ]; + useEffect(() => { + if (isAccessLoading) { + return; + } + if (!isAccessLoading && !hasAccess) { + toast({ variant: "destructive", description: "Access denied!" }); + router.push("/dashboard"); + return; + } + }, [isAccessLoading, hasAccess]); + useEffect(() => { const fetchCourseStatistics = async () => { await getPastQuestionsWithScore(courseId) @@ -119,6 +134,11 @@ export default function Page() { }; void fetchStudentData(); }, [studentQuery]); + + if (isAccessLoading || !hasAccess) { + return ; + } + return (

From a39abb1fa42c60063d49c73f3d0b9c1d2b7b7068 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Tue, 27 May 2025 19:21:31 -0700 Subject: [PATCH 12/23] create page header --- .../course/[courseId]/analytics/page.tsx | 92 +++++++++++++------ components/ui/AttendanceLineChart.tsx | 2 +- lib/constants.ts | 1 + 3 files changed, 65 insertions(+), 30 deletions(-) diff --git a/app/dashboard/course/[courseId]/analytics/page.tsx b/app/dashboard/course/[courseId]/analytics/page.tsx index 0a2ab7e..1ff1ef6 100644 --- a/app/dashboard/course/[courseId]/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/analytics/page.tsx @@ -19,16 +19,19 @@ import { nameKey, questionTypeMap, analyticsPages, + coursePages, } from "@/lib/constants"; import { getPastQuestionsWithScore, getResponseStatistics } from "@/services/question"; import { getStudents } from "@/services/userCourse"; import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; import useAccess from "@/hooks/use-access"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; +import { getCourseWithId } from "@/services/course"; export default function Page() { const params = useParams(); const courseId = parseInt((params.courseId as string) ?? "0"); + const [courseName, setCourseName] = useState(""); const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const [pastQuestions, setPastQuestions] = useState< { type: keyof typeof questionTypeMap; title: string; average: number }[] @@ -70,44 +73,57 @@ export default function Page() { }, [isAccessLoading, hasAccess]); useEffect(() => { - const fetchCourseStatistics = async () => { - await getPastQuestionsWithScore(courseId) - .then((res) => { - if ("error" in res) - return toast({ - variant: "destructive", - description: res?.error ?? "Unknown error occurred.", - }); - else { - setPastQuestions(res); - } - }) - .catch((err: unknown) => { - console.error(err); + const fetchCourseName = async () => { + try { + const res = await getCourseWithId(courseId); + if ("error" in res) { return toast({ variant: "destructive", - description: "Unknown error occurred.", + description: "Unable to fetch course information", }); + } else { + setCourseName(res.title); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + description: "Unknown error occurred.", }); - await getResponseStatistics(courseId) - .then((res) => { - if (typeof res !== "number" && "error" in res) - return toast({ - variant: "destructive", - description: res?.error ?? "Unknown error occurred.", - }); - else { - setResponseStatistics(res); - } - }) - .catch((err: unknown) => { - console.error(err); + } + }; + + const fetchCourseStatistics = async () => { + try { + const pastQuestionsRes = await getPastQuestionsWithScore(courseId); + if ("error" in pastQuestionsRes) { return toast({ variant: "destructive", - description: "Unknown error occurred.", + description: pastQuestionsRes?.error ?? "Unknown error occurred.", + }); + } else { + setPastQuestions(pastQuestionsRes); + } + + const statsRes = await getResponseStatistics(courseId); + if (typeof statsRes !== "number" && "error" in statsRes) { + return toast({ + variant: "destructive", + description: statsRes?.error ?? "Unknown error occurred.", }); + } else { + setResponseStatistics(statsRes); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + description: "Unknown error occurred.", }); + } }; + + void fetchCourseName(); void fetchCourseStatistics(); }, []); @@ -141,6 +157,24 @@ export default function Page() { return (
+

{courseName}

+
+ {coursePages.map((tab) => ( + + ))} +
{analyticsPages.map((pageTitle: string) => ( - ))} -
{analyticsPages.map((pageTitle: string) => ( + ))} +
+
{children}
+ + ); +} diff --git a/app/dashboard/course/[courseId]/questionnaire/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx similarity index 87% rename from app/dashboard/course/[courseId]/questionnaire/page.tsx rename to app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx index 7b56b38..3c0f7b2 100644 --- a/app/dashboard/course/[courseId]/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx @@ -9,7 +9,6 @@ import BeginPollDialog from "@/components/BeginPollDialog"; import SlidingCalendar from "@/components/ui/SlidingCalendar"; import { Button } from "@/components/ui/button"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; -import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; import { formatDateToISO } from "@/lib/utils"; import { getCourseWithId } from "@/services/course"; @@ -23,21 +22,12 @@ export default function Page() { const [isLoading, setIsLoading] = useState(true); const router = useRouter(); const { toast } = useToast(); - const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const [refreshCalendar, setRefreshCalendar] = useState(false); const handleQuestionUpdate = () => { - setRefreshCalendar(prev => !prev); + setRefreshCalendar((prev) => !prev); }; useEffect(() => { - if (isAccessLoading) { - return; - } - if (!isAccessLoading && !hasAccess) { - toast({ variant: "destructive", description: "Access denied!" }); - router.push("/dashboard"); - return; - } const getCourseName = async () => { setHasActiveSession(false); setIsLoading(true); @@ -60,9 +50,9 @@ export default function Page() { } }; void getCourseName(); - }, [courseId, hasAccess, isAccessLoading]); + }, [courseId]); - if (isAccessLoading || !hasAccess || isLoading) { + if (isLoading) { return ; } From 3a1b246babe19fc17fa9dd066785d38d1414fca5 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Wed, 28 May 2025 13:54:02 -0700 Subject: [PATCH 14/23] ui improvements with charts, table, and header --- .../(courseInfo)/analytics/page.tsx | 35 ++++++++++--------- .../course/[courseId]/(courseInfo)/layout.tsx | 20 +++++++---- .../(courseInfo)/questionnaire/page.tsx | 3 -- components/ui/AttendanceLineChart.tsx | 9 +++-- 4 files changed, 38 insertions(+), 29 deletions(-) diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index 65f83bd..a468605 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -6,6 +6,7 @@ import DonutChart from "@/components/ui/DonutChart"; import { Table, TableBody, + TableCaption, TableCell, TableHead, TableHeader, @@ -24,7 +25,6 @@ import { getPastQuestionsWithScore, getResponseStatistics } from "@/services/que import { getStudents } from "@/services/userCourse"; import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; -import { set } from "date-fns"; export default function Page() { const params = useParams(); @@ -134,7 +134,7 @@ export default function Page() { key={pageTitle} className={`p-2 h-fit px-4 rounded-md transition-colors duration-300 ease-in-out ${ page === pageTitle - ? "bg-white text-[hsl(var(--primary))]" + ? "bg-white text-[#1441DB]" : "bg-slate-200 text-slate-500" }`} onClick={() => setPage(pageTitle)} @@ -143,11 +143,11 @@ export default function Page() { ))}
-
+
{/* Performance page */} {page === "Performance" ? ( <> -
+
{/* Donut Chart */}
{/* Student Data Table */} -
+
+ {students?.length == 0 && No students enrolled} - + Student - + Attendance - + Poll Score - + setStudentQuery(e.target.value)} type="text" placeholder="Search student..." - className="h-8 w-[12vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" + className="h-8 w-full md:max-w-[12vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" /> @@ -222,13 +223,13 @@ export default function Page() { {students.map((student, idx) => ( - +

{student.name}

{student.email ?? "No email provided"}

- +

- +

- +

+ +
))} diff --git a/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx b/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx index effecb2..a7f3989 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx @@ -5,7 +5,7 @@ import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; import { coursePages } from "@/lib/constants"; import { getCourseWithId } from "@/services/course"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, usePathname, useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; export default function CourseInfoLayout({ @@ -15,10 +15,14 @@ export default function CourseInfoLayout({ }>) { const router = useRouter(); const params = useParams(); + const path = usePathname(); const courseId = parseInt(params.courseId as string); const { hasAccess, isLoading: isAccessLoading } = useAccess({ courseId, role: "LECTURER" }); const { toast } = useToast(); - const [courseName, setCourseName] = useState(""); + const [courseInfo, setCourseInfo] = useState<{ name: string; code: string }>({ + name: "", + code: "", + }); useEffect(() => { if (isAccessLoading) { @@ -38,7 +42,7 @@ export default function CourseInfoLayout({ description: "Unable to fetch course information", }); } else { - setCourseName(res.title); + setCourseInfo({ name: res.title, code: res.code }); } } catch (err) { console.error(err); @@ -57,8 +61,8 @@ export default function CourseInfoLayout({ return ( <> -

{courseName}

-
+

{`${courseInfo?.name} (${courseInfo?.code})`}

+
{coursePages.map((tab) => ( ))}
-
{children}
+
{children}
); } diff --git a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx index 3c0f7b2..4e73e1f 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx @@ -59,9 +59,6 @@ export default function Page() { return (
-

- {`${courseInfo?.name} (${courseInfo?.code})`}{" "} -

Previous - + `${value}`} /> From e636f8a10d48afdf3e1ef0b02b852dbb3a56556c Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Wed, 28 May 2025 14:34:32 -0700 Subject: [PATCH 15/23] use debounce for attendance chart --- .../(courseInfo)/analytics/page.tsx | 4 ---- components/ui/AttendanceLineChart.tsx | 12 +++++++----- hooks/use-debounce.tsx | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 hooks/use-debounce.tsx diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index a468605..06c6cc9 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -96,7 +96,6 @@ export default function Page() { useEffect(() => { const fetchStudentData = async () => { - setIsLoading(true); await getStudents(courseId, studentQuery) .then((res) => { if ("error" in res) @@ -114,9 +113,6 @@ export default function Page() { variant: "destructive", description: "Unknown error occurred.", }); - }) - .finally(() => { - setIsLoading(false); }); }; void fetchStudentData(); diff --git a/components/ui/AttendanceLineChart.tsx b/components/ui/AttendanceLineChart.tsx index fe9357e..c291a66 100644 --- a/components/ui/AttendanceLineChart.tsx +++ b/components/ui/AttendanceLineChart.tsx @@ -5,13 +5,15 @@ import dayjs from "dayjs"; import { attendanceChartConfig } from "@/lib/constants"; import { getAttendanceByDay } from "@/services/userCourse"; import { useToast } from "@/hooks/use-toast"; +import useDebounce from "@/hooks/use-debounce"; interface Props { courseId: number; } export default function AttendanceLineChart({ courseId }: Props) { - const [weekStart, setWeekStart] = useState(dayjs().startOf("week").toDate()); + const [weekStart, setWeekStart] = useState(dayjs().startOf("week").toDate()); + let debouncedWeekStart = useDebounce(weekStart, 200); const [chartData, setChartData] = useState<{ date: string; attendance: number }[]>(); const { toast } = useToast(); @@ -46,14 +48,14 @@ export default function AttendanceLineChart({ courseId }: Props) { } setChartData(week); }; - fetchWeekData(weekStart); - }, [weekStart]); + fetchWeekData(debouncedWeekStart); + }, [debouncedWeekStart]); const handleNextWeek = () => { - setWeekStart((prev) => dayjs(prev).add(7, "day").toDate()); + setWeekStart(dayjs(weekStart).add(7, "day").toDate()); }; const handlePrevWeek = () => { - setWeekStart((prev) => dayjs(prev).subtract(7, "day").toDate()); + setWeekStart(dayjs(weekStart).subtract(7, "day").toDate()); }; return (
diff --git a/hooks/use-debounce.tsx b/hooks/use-debounce.tsx new file mode 100644 index 0000000..96061aa --- /dev/null +++ b/hooks/use-debounce.tsx @@ -0,0 +1,19 @@ +import { useState, useEffect } from "react"; + +const useDebounce = (value: any, delay: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}; + +export default useDebounce; From 745574be20805f795db1185a409c540b7fdd6095 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Wed, 28 May 2025 14:52:58 -0700 Subject: [PATCH 16/23] fix lint errors --- app/api/updateCourse/[id]/route.ts | 296 ++++++++-------- .../(courseInfo)/analytics/page.tsx | 18 +- .../course/[courseId]/(courseInfo)/layout.tsx | 6 +- .../(courseInfo)/questionnaire/page.tsx | 8 +- .../course/[courseId]/start-session/page.tsx | 2 - components/AddEditCourseForm.tsx | 1 - components/AddEditQuestionForm.tsx | 1 - components/ui/AttendanceLineChart.tsx | 2 +- components/ui/CourseCard.tsx | 327 +++++++++--------- components/ui/SlidingCalendar.tsx | 6 +- components/ui/answerOptions.tsx | 1 - components/ui/table.tsx | 174 ++++------ hooks/use-debounce.tsx | 8 +- lib/constants.ts | 5 +- lib/utils.ts | 1 - services/course.ts | 10 +- services/question.ts | 92 ++--- services/userCourse.ts | 106 +++--- 18 files changed, 519 insertions(+), 545 deletions(-) diff --git a/app/api/updateCourse/[id]/route.ts b/app/api/updateCourse/[id]/route.ts index 33c230a..36cecc4 100644 --- a/app/api/updateCourse/[id]/route.ts +++ b/app/api/updateCourse/[id]/route.ts @@ -5,173 +5,167 @@ import { authOptions } from "@/lib/auth"; import prisma from "@/lib/prisma"; const updateSchema = z.object({ - title: z.string().min(2), - color: z.string().length(7), - days: z.array(z.string()).min(1), - startTime: z.string(), - endTime: z.string(), + title: z.string().min(2), + color: z.string().length(7), + days: z.array(z.string()).min(1), + startTime: z.string(), + endTime: z.string(), }); function getCourseId(request: Request): number { - const url = new URL(request.url); - const id = url.pathname.split('/').pop(); - const courseId = parseInt(id ?? ''); - - if (isNaN(courseId)) { - throw new Error('Invalid course ID'); - } - return courseId; + const url = new URL(request.url); + const id = url.pathname.split("/").pop(); + const courseId = parseInt(id ?? ""); + + if (isNaN(courseId)) { + throw new Error("Invalid course ID"); + } + return courseId; } // PUT - Update Course export async function PUT(request: Request) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const courseId = getCourseId(request); - - // Verify user has permission - const userCourse = await prisma.userCourse.findUnique({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - - if (!userCourse || userCourse.role !== "LECTURER") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Validate request body - const body: unknown = await request.json(); - const parsed = updateSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Invalid input", details: parsed.error.flatten() }, - { status: 400 } - ); - } + try { + const courseId = getCourseId(request); - const { title, color, days, startTime, endTime } = parsed.data; - - // Update course and schedule in transaction - const [updatedCourse] = await prisma.$transaction([ - prisma.course.update({ - where: { id: courseId }, - data: { title, color }, - }), - prisma.schedule.updateMany({ - where: { courseId }, - data: { dayOfWeek: days, startTime, endTime }, - }), - ]); - - return NextResponse.json(updatedCourse); - } catch (error) { - if (error instanceof Error && error.message === 'Invalid course ID') { - return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + // Verify user has permission + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + + if (!userCourse || userCourse.role !== "LECTURER") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Validate request body + const body: unknown = await request.json(); + const parsed = updateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { title, color, days, startTime, endTime } = parsed.data; + + // Update course and schedule in transaction + const [updatedCourse] = await prisma.$transaction([ + prisma.course.update({ + where: { id: courseId }, + data: { title, color }, + }), + prisma.schedule.updateMany({ + where: { courseId }, + data: { dayOfWeek: days, startTime, endTime }, + }), + ]); + + return NextResponse.json(updatedCourse); + } catch (error) { + if (error instanceof Error && error.message === "Invalid course ID") { + return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + } + + console.error("Error updating course:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } - - console.error("Error updating course:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 } - ); - } } // DELETE - Delete Course export async function DELETE(request: Request) { - const session = await getServerSession(authOptions); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const courseId = getCourseId(request); - - // Verify user has permission - const userCourse = await prisma.userCourse.findUnique({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - - if (!userCourse) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (userCourse.role === "LECTURER") { - // Lecturer - delete all related records in proper order - await prisma.$transaction([ - prisma.response.deleteMany({ - where: { - question: { - session: { - courseId, - }, - }, - }, - }), - prisma.option.deleteMany({ - where: { - question: { - session: { - courseId, - }, - }, - }, - }), - prisma.question.deleteMany({ - where: { - session: { - courseId, - }, - }, - }), - prisma.courseSession.deleteMany({ - where: { courseId }, - }), - prisma.schedule.deleteMany({ - where: { courseId }, - }), - prisma.userCourse.deleteMany({ - where: { courseId }, - }), - prisma.course.delete({ - where: { id: courseId }, - }), - ]); - } else { - // Student - only remove their association - await prisma.userCourse.delete({ - where: { - userId_courseId: { - userId: session.user.id, - courseId, - }, - }, - }); - } + try { + const courseId = getCourseId(request); - return NextResponse.json({ success: true }); - } catch (error) { - if (error instanceof Error && error.message === 'Invalid course ID') { - return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + // Verify user has permission + const userCourse = await prisma.userCourse.findUnique({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + + if (!userCourse) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (userCourse.role === "LECTURER") { + // Lecturer - delete all related records in proper order + await prisma.$transaction([ + prisma.response.deleteMany({ + where: { + question: { + session: { + courseId, + }, + }, + }, + }), + prisma.option.deleteMany({ + where: { + question: { + session: { + courseId, + }, + }, + }, + }), + prisma.question.deleteMany({ + where: { + session: { + courseId, + }, + }, + }), + prisma.courseSession.deleteMany({ + where: { courseId }, + }), + prisma.schedule.deleteMany({ + where: { courseId }, + }), + prisma.userCourse.deleteMany({ + where: { courseId }, + }), + prisma.course.delete({ + where: { id: courseId }, + }), + ]); + } else { + // Student - only remove their association + await prisma.userCourse.delete({ + where: { + userId_courseId: { + userId: session.user.id, + courseId, + }, + }, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.message === "Invalid course ID") { + return NextResponse.json({ error: "Invalid course ID" }, { status: 400 }); + } + + console.error("Error deleting course:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } - - console.error("Error deleting course:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 } - ); - } -} \ No newline at end of file +} diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index 06c6cc9..eb6c574 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -2,7 +2,9 @@ import { useParams } from "next/navigation"; import React, { useEffect, useState } from "react"; +import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; import DonutChart from "@/components/ui/DonutChart"; +import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; import { Table, TableBody, @@ -14,17 +16,15 @@ import { } from "@/components/ui/table"; import { useToast } from "@/hooks/use-toast"; import { - performanceChartConfig, + analyticsPages, dataKey, description, nameKey, + performanceChartConfig, questionTypeMap, - analyticsPages, } from "@/lib/constants"; import { getPastQuestionsWithScore, getResponseStatistics } from "@/services/question"; import { getStudents } from "@/services/userCourse"; -import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; -import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; export default function Page() { const params = useParams(); @@ -133,7 +133,9 @@ export default function Page() { ? "bg-white text-[#1441DB]" : "bg-slate-200 text-slate-500" }`} - onClick={() => setPage(pageTitle)} + onClick={() => { + setPage(pageTitle); + }} > {pageTitle} @@ -194,7 +196,7 @@ export default function Page() { {/* Student Data Table */}
- {students?.length == 0 && No students enrolled} + {students?.length === 0 && No students enrolled} @@ -208,7 +210,9 @@ export default function Page() { setStudentQuery(e.target.value)} + onChange={(e) => { + setStudentQuery(e.target.value); + }} type="text" placeholder="Search student..." className="h-8 w-full md:max-w-[12vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" diff --git a/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx b/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx index a7f3989..8ffdf91 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/layout.tsx @@ -1,12 +1,12 @@ "use client"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import React, { useEffect, useState } from "react"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; import useAccess from "@/hooks/use-access"; import { useToast } from "@/hooks/use-toast"; import { coursePages } from "@/lib/constants"; import { getCourseWithId } from "@/services/course"; -import { useParams, usePathname, useRouter } from "next/navigation"; -import React, { useEffect, useState } from "react"; export default function CourseInfoLayout({ children, @@ -52,7 +52,7 @@ export default function CourseInfoLayout({ }); } }; - fetchCourseName(); + void fetchCourseName(); }, [isAccessLoading, hasAccess, courseId]); if (isAccessLoading || !hasAccess) { diff --git a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx index 4e73e1f..6881adf 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/questionnaire/page.tsx @@ -11,13 +11,11 @@ import { Button } from "@/components/ui/button"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; import { useToast } from "@/hooks/use-toast"; import { formatDateToISO } from "@/lib/utils"; -import { getCourseWithId } from "@/services/course"; import { getCourseSessionByDate } from "@/services/session"; export default function Page() { const params = useParams(); const courseId = parseInt((params.courseId as string) ?? "0"); - const [courseInfo, setCourseInfo] = useState<{ name: string; code: string }>(); const [hasActiveSession, setHasActiveSession] = useState(false); const [isLoading, setIsLoading] = useState(true); const router = useRouter(); @@ -28,11 +26,10 @@ export default function Page() { }; useEffect(() => { - const getCourseName = async () => { + const getCourseInfo = async () => { setHasActiveSession(false); setIsLoading(true); try { - const course = await getCourseWithId(courseId); const courseSession = await getCourseSessionByDate( courseId, formatDateToISO(new Date()), @@ -40,7 +37,6 @@ export default function Page() { if (courseSession?.activeQuestionId) { setHasActiveSession(true); } - setCourseInfo({ name: course.title, code: course.code }); } catch (error) { toast({ variant: "destructive", description: "Could not get course information." }); console.error("Failed to fetch course:", error); @@ -49,7 +45,7 @@ export default function Page() { setIsLoading(false); } }; - void getCourseName(); + void getCourseInfo(); }, [courseId]); if (isLoading) { diff --git a/app/dashboard/course/[courseId]/start-session/page.tsx b/app/dashboard/course/[courseId]/start-session/page.tsx index 11784e3..4700d7f 100644 --- a/app/dashboard/course/[courseId]/start-session/page.tsx +++ b/app/dashboard/course/[courseId]/start-session/page.tsx @@ -53,8 +53,6 @@ export default function StartSession() { const [showResults, setShowResults] = useState(DEFAULT_SHOW_RESULTS); const [isChangingQuestion, setIsChangingQuestion] = useState(false); // New state for question navigation - - useEffect(() => { async function fetchSessionData() { const session = await getCourseSessionByDate(courseId, utcDate); diff --git a/components/AddEditCourseForm.tsx b/components/AddEditCourseForm.tsx index 0745b16..5b88ef5 100644 --- a/components/AddEditCourseForm.tsx +++ b/components/AddEditCourseForm.tsx @@ -62,7 +62,6 @@ export const AddEditCourseForm = ({ }, }); - const handleSubmit = async (values: z.infer) => { const { title, color, days, endTime, startTime } = values; setLoading(true); diff --git a/components/AddEditQuestionForm.tsx b/components/AddEditQuestionForm.tsx index d6d7d1d..250a96d 100644 --- a/components/AddEditQuestionForm.tsx +++ b/components/AddEditQuestionForm.tsx @@ -130,7 +130,6 @@ export const AddEditQuestionForm: React.FC = ({ }); } }, [prevData]); - useEffect(() => { if (!defaultDate) return; diff --git a/components/ui/AttendanceLineChart.tsx b/components/ui/AttendanceLineChart.tsx index c291a66..8762fc4 100644 --- a/components/ui/AttendanceLineChart.tsx +++ b/components/ui/AttendanceLineChart.tsx @@ -13,7 +13,7 @@ interface Props { export default function AttendanceLineChart({ courseId }: Props) { const [weekStart, setWeekStart] = useState(dayjs().startOf("week").toDate()); - let debouncedWeekStart = useDebounce(weekStart, 200); + let debouncedWeekStart = useDebounce(weekStart, 200); const [chartData, setChartData] = useState<{ date: string; attendance: number }[]>(); const { toast } = useToast(); diff --git a/components/ui/CourseCard.tsx b/components/ui/CourseCard.tsx index a3ab2d5..0ff6e98 100644 --- a/components/ui/CourseCard.tsx +++ b/components/ui/CourseCard.tsx @@ -5,187 +5,186 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { MoreVertical, Edit, Trash2 } from "lucide-react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AddEditCourseForm } from "@/components/AddEditCourseForm"; import { dayLabels } from "@/lib/constants"; export type CourseCardProps = { - color: string; - days: string[]; - title: string; - timeStart: string; - timeEnd: string; - code: string; - role: Role; - id: number; - onEdit?: () => void; - onDelete?: () => void; + color: string; + days: string[]; + title: string; + timeStart: string; + timeEnd: string; + code: string; + role: Role; + id: number; + onEdit?: () => void; + onDelete?: () => void; }; export default function CourseCard({ - color, - days, - title, - timeStart, - timeEnd, - code, - role, - id, - onEdit, - onDelete, + color, + days, + title, + timeStart, + timeEnd, + code, + role, + id, + onEdit, + onDelete, }: CourseCardProps) { - const router = useRouter(); - const [isEditOpen, setIsEditOpen] = useState(false); + const router = useRouter(); + const [isEditOpen, setIsEditOpen] = useState(false); - const shortDays = days - .map((fullDay) => { - const entry = Object.entries(dayLabels).find(([, label]) => label === fullDay); - return entry ? entry[0] : undefined; - }) - .filter(Boolean) as ("M" | "T" | "W" | "Th" | "F")[]; + const shortDays = days + .map((fullDay) => { + const entry = Object.entries(dayLabels).find(([, label]) => label === fullDay); + return entry ? entry[0] : undefined; + }) + .filter(Boolean) as ("M" | "T" | "W" | "Th" | "F")[]; - const handleCardClick = () => { - if (!isEditOpen) { - router.push( - role === "LECTURER" - ? `/dashboard/course/${id}/questionnaire` - : `/dashboard/course/${id}/live-poll` - ); - } - }; + const handleCardClick = () => { + if (!isEditOpen) { + router.push( + role === "LECTURER" + ? `/dashboard/course/${id}/questionnaire` + : `/dashboard/course/${id}/live-poll`, + ); + } + }; - const handleEdit = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsEditOpen(true); - }; + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsEditOpen(true); + }; - const handleDelete = (e: React.MouseEvent) => { - e.stopPropagation(); - onDelete?.(); - }; + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete?.(); + }; - const handleEditClose = () => { - setIsEditOpen(false); - onEdit?.(); - }; + const handleEditClose = () => { + setIsEditOpen(false); + onEdit?.(); + }; - const CardContent = () => ( - <> -
-
-

- Time: {timeStart} - {timeEnd} -

-

{title}

- -

- Code:{" "} - - {(+code).toLocaleString("en-US", { - minimumIntegerDigits: 6, - useGrouping: false, - })} - -

-

- Role: {role.toLocaleLowerCase()} -

-
-

{days.join(", ")}

-
- - ); + const CardContent = () => ( + <> +
+
+

+ Time: {timeStart} - {timeEnd} +

+

{title}

+ +

+ Code:{" "} + + {(+code).toLocaleString("en-US", { + minimumIntegerDigits: 6, + useGrouping: false, + })} + +

+

+ Role: {role.toLocaleLowerCase()} +

+
+

{days.join(", ")}

+
+ + ); - const MobileCardContent = () => ( - <> -
-
-

- Time: {timeStart} - {timeEnd} -

-

{title}

-
-

{days.join(", ")}

- - ); + const MobileCardContent = () => ( + <> +
+
+

+ Time: {timeStart} - {timeEnd} +

+

{title}

+
+

{days.join(", ")}

+ + ); - const MenuDropdown = () => ( - - - - - - {role === "LECTURER" && ( - { - event.preventDefault(); - handleEdit(event as unknown as React.MouseEvent); - }} - > - - Edit - - )} - handleDelete(event as unknown as React.MouseEvent)}> - - {role === "LECTURER" ? "Delete" : "Leave"} - - - - ); + const MenuDropdown = () => ( + + + + + + {role === "LECTURER" && ( + { + event.preventDefault(); + handleEdit(event as unknown as React.MouseEvent); + }} + > + + Edit + + )} + handleDelete(event as unknown as React.MouseEvent)} + > + + {role === "LECTURER" ? "Delete" : "Leave"} + + + + ); - return ( - <> - {/* Mobile View */} -
- - -
+ return ( + <> + {/* Mobile View */} +
+ + +
- {/* Desktop View */} -
- - -
+ {/* Desktop View */} +
+ + +
- !open && handleEditClose()} - defaultValues={{ - title, - color, - days: shortDays, - startTime: timeStart, - endTime: timeEnd, - }} - onSuccess={handleEditClose} - /> - - ); -} \ No newline at end of file + !open && handleEditClose()} + defaultValues={{ + title, + color, + days: shortDays, + startTime: timeStart, + endTime: timeEnd, + }} + onSuccess={handleEditClose} + /> + + ); +} diff --git a/components/ui/SlidingCalendar.tsx b/components/ui/SlidingCalendar.tsx index f5252ef..82aa56c 100644 --- a/components/ui/SlidingCalendar.tsx +++ b/components/ui/SlidingCalendar.tsx @@ -253,7 +253,11 @@ function SlidingCalendar({ courseId, refreshTrigger }: Props) { courseId={courseId} location="page" questionId={question.id} - onUpdate={() => fetchQuestions(selectedDate.toDate())} + onUpdate={() => + fetchQuestions( + selectedDate.toDate(), + ) + } prevData={{ question: question.text, selectedQuestionType: diff --git a/components/ui/answerOptions.tsx b/components/ui/answerOptions.tsx index ecd56f8..9467497 100644 --- a/components/ui/answerOptions.tsx +++ b/components/ui/answerOptions.tsx @@ -16,7 +16,6 @@ interface AnswerOptionsProps { onSelectionChange: (value: number | number[]) => void; } - const AnswerOptions: React.FC = ({ options, questionType, diff --git a/components/ui/table.tsx b/components/ui/table.tsx index c0df655..f42ae52 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -1,120 +1,98 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Table = React.forwardRef< - HTMLTableElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-
- -)) -Table.displayName = "Table" +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+
+ + ), +); +Table.displayName = "Table"; const TableHeader = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableHeader.displayName = "TableHeader" + +)); +TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - -)) -TableBody.displayName = "TableBody" + +)); +TableBody.displayName = "TableBody"; const TableFooter = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes + HTMLTableSectionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - tr]:last:border-b-0", - className - )} - {...props} - /> -)) -TableFooter.displayName = "TableFooter" + tr]:last:border-b-0", className)} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; -const TableRow = React.forwardRef< - HTMLTableRowElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( - -)) -TableRow.displayName = "TableRow" +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +); +TableRow.displayName = "TableRow"; const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes + HTMLTableCellElement, + React.ThHTMLAttributes >(({ className, ...props }, ref) => ( -
[role=checkbox]]:translate-y-[2px]", - className - )} - {...props} - /> -)) -TableHead.displayName = "TableHead" + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< - HTMLTableCellElement, - React.TdHTMLAttributes + HTMLTableCellElement, + React.TdHTMLAttributes >(({ className, ...props }, ref) => ( - [role=checkbox]]:translate-y-[2px]", - className - )} - {...props} - /> -)) -TableCell.displayName = "TableCell" + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableCell.displayName = "TableCell"; const TableCaption = React.forwardRef< - HTMLTableCaptionElement, - React.HTMLAttributes + HTMLTableCaptionElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( -
-)) -TableCaption.displayName = "TableCaption" + +)); +TableCaption.displayName = "TableCaption"; -export { - Table, - TableHeader, - TableBody, - TableFooter, - TableHead, - TableRow, - TableCell, - TableCaption, -} +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/hooks/use-debounce.tsx b/hooks/use-debounce.tsx index 96061aa..534768f 100644 --- a/hooks/use-debounce.tsx +++ b/hooks/use-debounce.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; -const useDebounce = (value: any, delay: number) => { - const [debouncedValue, setDebouncedValue] = useState(value); +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { @@ -14,6 +14,6 @@ const useDebounce = (value: any, delay: number) => { }, [value, delay]); return debouncedValue; -}; +} export default useDebounce; diff --git a/lib/constants.ts b/lib/constants.ts index 03a2389..2fe9a0a 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -43,8 +43,7 @@ export const attendanceChartConfig = { }, } satisfies ChartConfig; -export const analyticsPages = ['Performance', 'Attendance Rate'] -export const coursePages = ['Questionnaire', 'Analytics'] - +export const analyticsPages = ["Performance", "Attendance Rate"]; +export const coursePages = ["Questionnaire", "Analytics"]; export const DEFAULT_SHOW_RESULTS = false; diff --git a/lib/utils.ts b/lib/utils.ts index 012c43a..14677e9 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -32,7 +32,6 @@ export function formatDateToISO(date: Date) { return new Date(date.setHours(0, 0, 0, 0)).toISOString(); } - export function shuffleArray(array: T[]): T[] { const copy = [...array]; for (let i = copy.length - 1; i > 0; i--) { diff --git a/services/course.ts b/services/course.ts index 544a326..9ef88b4 100644 --- a/services/course.ts +++ b/services/course.ts @@ -115,7 +115,7 @@ type UpdateCourseParams = { export async function updateCourse( courseId: number, - data: UpdateCourseParams + data: UpdateCourseParams, ): Promise { try { // First update the course details @@ -148,10 +148,8 @@ export async function updateCourse( return updatedCourse; } catch (error) { console.error("Error updating course:", error); - return { - error: error instanceof Error - ? error.message - : "Failed to update course" + return { + error: error instanceof Error ? error.message : "Failed to update course", }; } -} \ No newline at end of file +} diff --git a/services/question.ts b/services/question.ts index 51f4f6a..8fc7d76 100644 --- a/services/question.ts +++ b/services/question.ts @@ -120,88 +120,92 @@ export async function deleteQuestion(questionId: number) { } } -export async function getPastQuestionsWithScore(courseId: number){ - try{ +export async function getPastQuestionsWithScore(courseId: number) { + try { const data = await prisma.question.findMany({ where: { - session: { - courseId, - }, + session: { + courseId, + }, }, orderBy: [ - {session: { - startTime: 'desc', - }}, - {position: 'desc',} + { + session: { + startTime: "desc", + }, + }, + { position: "desc" }, ], take: 2, include: { responses: true, - options: true - } + options: true, + }, }); - const pastQuestions = [] - - for (const question of data){ - const correctOptionIds = question.options.filter((option) => option.isCorrect).map((option) => option.id);; - + const pastQuestions = []; + + for (const question of data) { + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + let correctCount = 0; question.responses.forEach((response) => { - if (correctOptionIds.includes(response.optionId)){ + if (correctOptionIds.includes(response.optionId)) { correctCount++; } - }) + }); pastQuestions.push({ type: question.type, title: question.text, - average: question.responses.length === 0 ? 0 : Math.trunc((correctCount / question.responses.length) * 100) - }) - + average: + question.responses.length === 0 + ? 0 + : Math.trunc((correctCount / question.responses.length) * 100), + }); } - - return pastQuestions; - }catch (err){ + return pastQuestions; + } catch (err) { console.error(err); return { error: "Error fetching past questions." }; } } -export async function getResponseStatistics(courseId: number){ - try{ +export async function getResponseStatistics(courseId: number) { + try { const data = await prisma.question.findMany({ where: { - session: { - courseId, - }, + session: { + courseId, + }, }, include: { responses: true, - options: true - } + options: true, + }, }); - let correctResponses = 0 - let incorrectReponses = 0 - for (const question of data){ - const correctOptionIds = question.options.filter((option) => option.isCorrect).map((option) => option.id);; - + let correctResponses = 0; + let incorrectReponses = 0; + for (const question of data) { + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + question.responses.forEach((response) => { - if (correctOptionIds.includes(response.optionId)){ + if (correctOptionIds.includes(response.optionId)) { correctResponses++; - } - else{ + } else { incorrectReponses++; } - }) - + }); } - return {incorrect: incorrectReponses, correct: correctResponses}; - } - catch (err){ + return { incorrect: incorrectReponses, correct: correctResponses }; + } catch (err) { console.error(err); return { error: "Error calculating class average." }; } diff --git a/services/userCourse.ts b/services/userCourse.ts index b0bfbbc..d8d846f 100644 --- a/services/userCourse.ts +++ b/services/userCourse.ts @@ -98,7 +98,7 @@ export async function addUserToCourseByEmail( } export async function getStudents(courseId: number, query: string | undefined) { - try{ + try { const studentsData = await prisma.user.findMany({ where: { courses: { @@ -137,8 +137,7 @@ export async function getStudents(courseId: number, query: string | undefined) { firstName: true, lastName: true, email: true, - responses: - { + responses: { select: { question: { select: { @@ -151,81 +150,87 @@ export async function getStudents(courseId: number, query: string | undefined) { }, }, }, + }, }, - }, }); - const sessions = await prisma.courseSession.findMany({ - where: { - courseId - }, - select: { - id: true, - }, - }).then((res) => res.map((session) => session.id)); + const sessions = await prisma.courseSession + .findMany({ + where: { + courseId, + }, + select: { + id: true, + }, + }) + .then((res) => res.map((session) => session.id)); const result = studentsData.map((student) => { const totalSessions = sessions.length; - const studentResponses = student.responses.filter((response) => sessions.includes(response.question.sessionId)); + const studentResponses = student.responses.filter((response) => + sessions.includes(response.question.sessionId), + ); const attendedSessions = new Set( - studentResponses.map((response) => response.question.sessionId) + studentResponses.map((response) => response.question.sessionId), ).size; - const correctResponses = studentResponses.filter( - (response) => response.question.options.some((option) => option.isCorrect) + const correctResponses = studentResponses.filter((response) => + response.question.options.some((option) => option.isCorrect), ).length; - const attendance = totalSessions > 0 ? Math.trunc((attendedSessions / totalSessions) * 100) : 0; - const pollScore = studentResponses.length > 0 ? Math.trunc((correctResponses / studentResponses.length) * 100) : 0; + const attendance = + totalSessions > 0 ? Math.trunc((attendedSessions / totalSessions) * 100) : 0; + const pollScore = + studentResponses.length > 0 + ? Math.trunc((correctResponses / studentResponses.length) * 100) + : 0; return { - name: String(student.firstName) + ' ' + String(student.lastName), + name: String(student.firstName) + " " + String(student.lastName), email: student.email, attendance, - pollScore + pollScore, }; }); - return result; - - - } catch (err){ + return result; + } catch (err) { console.error(err); return { error: "Error fetching students." }; } } export async function getAttendanceByDay(courseId: number, date: Date) { - try{ + try { const startOfDay = new Date(date); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(date); endOfDay.setHours(23, 59, 59, 999); - const sessions = await prisma.courseSession.findMany({ - where: { - courseId, - startTime: { - gte: startOfDay, - lte: endOfDay, + const sessions = await prisma.courseSession + .findMany({ + where: { + courseId, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, }, - }, - select: { - id: true, - }, - }).then((res) => res.map((session) => session.id)); + select: { + id: true, + }, + }) + .then((res) => res.map((session) => session.id)); - const totalStudents = await prisma.user.count( - { - where: { - courses: { - some: { - courseId, - role: "STUDENT", - }, + const totalStudents = await prisma.user.count({ + where: { + courses: { + some: { + courseId, + role: "STUDENT", }, }, - } - ) + }, + }); if (sessions.length === 0 || totalStudents === 0) { return 0; @@ -242,21 +247,20 @@ export async function getAttendanceByDay(courseId: number, date: Date) { courses: { some: { courseId, - role: 'STUDENT', + role: "STUDENT", }, }, }, }, - distinct: ['userId'], + distinct: ["userId"], select: { userId: true, }, }); - + return Math.trunc((attendedStudents.length / totalStudents) * 100); - } catch (err){ + } catch (err) { console.error(err); return { error: "Error calculating attendance rate." }; } } - From 8766ffd6db86ffef6b0441a7c5fa41dec231938f Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Thu, 29 May 2025 08:10:45 -0700 Subject: [PATCH 17/23] extract calculations as utils --- .../(courseInfo)/analytics/page.tsx | 47 ++++-- components/ui/AttendanceLineChart.tsx | 39 ++--- lib/constants.ts | 52 ++++++ lib/utils.ts | 149 +++++++++++++++++- services/question.ts | 54 +------ services/session.ts | 47 ++++++ services/userCourse.ts | 95 ++++------- 7 files changed, 325 insertions(+), 158 deletions(-) diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index eb6c574..c5c4c19 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -23,8 +23,14 @@ import { performanceChartConfig, questionTypeMap, } from "@/lib/constants"; -import { getPastQuestionsWithScore, getResponseStatistics } from "@/services/question"; +import { getLimitedPastQuestions, getResponses } from "@/services/question"; import { getStudents } from "@/services/userCourse"; +import { + getIncorrectAndCorrectResponseCounts, + getQuestionsWithAverageScore, + getStudentsWithScores, +} from "@/lib/utils"; +import { getAllSessionIds } from "@/services/session"; export default function Page() { const params = useParams(); @@ -61,24 +67,24 @@ export default function Page() { const fetchCourseStatistics = async () => { try { setIsLoading(true); - const pastQuestionsRes = await getPastQuestionsWithScore(courseId); + const pastQuestionsRes = await getLimitedPastQuestions(courseId, 2); if ("error" in pastQuestionsRes) { return toast({ variant: "destructive", description: pastQuestionsRes?.error ?? "Unknown error occurred.", }); } else { - setPastQuestions(pastQuestionsRes); + setPastQuestions(getQuestionsWithAverageScore(pastQuestionsRes)); } - const statsRes = await getResponseStatistics(courseId); - if (typeof statsRes !== "number" && "error" in statsRes) { + const responses = await getResponses(courseId); + if (!responses || (typeof responses !== "number" && "error" in responses)) { return toast({ variant: "destructive", - description: statsRes?.error ?? "Unknown error occurred.", + description: responses?.error ?? "Unknown error occurred.", }); - } else { - setResponseStatistics(statsRes); + } else if (responses) { + setResponseStatistics(getIncorrectAndCorrectResponseCounts(responses)); } } catch (err) { console.error(err); @@ -96,15 +102,24 @@ export default function Page() { useEffect(() => { const fetchStudentData = async () => { - await getStudents(courseId, studentQuery) - .then((res) => { - if ("error" in res) + const students = await getStudents(courseId, studentQuery) + .then(async (students) => { + if ("error" in students) return toast({ variant: "destructive", - description: res?.error ?? "Unknown error occurred.", + description: students?.error ?? "Unknown error occurred.", }); else { - setStudents(res); + await getAllSessionIds(courseId).then((sessions) => { + if ("error" in sessions) { + return toast({ + variant: "destructive", + description: sessions?.error ?? "Unknown error occurred.", + }); + } else { + setStudents(getStudentsWithScores(students, sessions)); + } + }); } }) .catch((err: unknown) => { @@ -145,7 +160,7 @@ export default function Page() { {/* Performance page */} {page === "Performance" ? ( <> -
+
{/* Donut Chart */}
{/* Past Questions */} -
+
{pastQuestions.map((question, idx) => (

diff --git a/components/ui/AttendanceLineChart.tsx b/components/ui/AttendanceLineChart.tsx index 8762fc4..d373c2c 100644 --- a/components/ui/AttendanceLineChart.tsx +++ b/components/ui/AttendanceLineChart.tsx @@ -3,7 +3,7 @@ import { ChartContainer } from "@/components/ui/chart"; import { CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; import dayjs from "dayjs"; import { attendanceChartConfig } from "@/lib/constants"; -import { getAttendanceByDay } from "@/services/userCourse"; +import { calculateWeekAttendance } from "@/lib/utils"; import { useToast } from "@/hooks/use-toast"; import useDebounce from "@/hooks/use-debounce"; @@ -19,34 +19,21 @@ export default function AttendanceLineChart({ courseId }: Props) { useEffect(() => { const fetchWeekData = async (start: Date) => { - const week: { date: string; attendance: number }[] = []; - for (let i = 0; i < 7; i++) { - const day = dayjs(start).add(i, "day"); - let attendance = 0; - await getAttendanceByDay(courseId, day.toDate()) - .then((res) => { - if (typeof res !== "number" && "error" in res) { - return toast({ - variant: "destructive", - description: res?.error ?? "Unknown error occurred.", - }); - } else { - attendance = Number(res); - } - }) - .catch((err: unknown) => { - console.error(err); - return toast({ + await calculateWeekAttendance(start, courseId) + .then((res) => { + if (res && "error" in res) { + toast({ variant: "destructive", - description: "Unknown error occurred.", + description: res.error ?? "Unknown error occurred", }); - }); - week.push({ - date: day.format("M/D"), - attendance, + } else { + setChartData(res); + } + }) + .catch((err: unknown) => { + console.error(err); + toast({ variant: "destructive", description: "Unknown error occurred" }); }); - } - setChartData(week); }; fetchWeekData(debouncedWeekStart); }, [debouncedWeekStart]); diff --git a/lib/constants.ts b/lib/constants.ts index 2fe9a0a..3050d66 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,4 +1,5 @@ import { ChartConfig } from "@/components/ui/chart"; +import { $Enums } from "@prisma/client"; export const questionTypes = ["Multiple Choice", "Select All"] as const; export const colorOptions = ["#ED9D9D", "#F3AB7E", "#EEF583", "#94ED79", "#8E87F2"]; @@ -47,3 +48,54 @@ export const analyticsPages = ["Performance", "Attendance Rate"]; export const coursePages = ["Questionnaire", "Analytics"]; export const DEFAULT_SHOW_RESULTS = false; + +export type QuestionWithResponesAndOptions = { + options: { + isCorrect: boolean; + text: string; + id: number; + questionId: number; + }[]; + responses: { + userId: string; + questionId: number; + optionId: number; + answeredAt: Date; + }[]; +} & { + text: string; + id: number; + type: $Enums.QuestionType; + sessionId: number; + position: number; +} + +export type Response = { + options: { + isCorrect: boolean; + id: number; + text: string; + questionId: number; + }[]; + responses: { + optionId: number; + questionId: number; + userId: string; + answeredAt: Date; + }[] +} + +export type Student = { + id: string; + responses: { + question: { + sessionId: number; + options: { + isCorrect: boolean; + }[]; + }; + }[]; + email: string | null; + firstName: string; + lastName: string | null; +} \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index 14677e9..34aad30 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,7 +1,9 @@ import { clsx } from "clsx"; import type { ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; - +import { QuestionWithResponesAndOptions, Response, Student } from "./constants"; +import dayjs from "dayjs"; +import { getAttendanceCount, getStudentCount } from "@/services/userCourse"; /** * A utility function that merges tailwind classes with conditional classes combining functionalities of twMerge and clsx. * @@ -40,3 +42,148 @@ export function shuffleArray(array: T[]): T[] { } return copy; } + +export function getQuestionsWithAverageScore(questions: QuestionWithResponesAndOptions[]) { + const questionsWithScores = []; + + for (const question of questions) { + // get the IDs of the correct options + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + // check if at least one option is correct + let correctCount = 0; + question.responses.forEach((response) => { + if (correctOptionIds.includes(response.optionId)) { + correctCount++; + } + }); + + // add question and score information to result + questionsWithScores.push({ + type: question.type, + title: question.text, + average: + question.responses.length === 0 + ? 0 + : Math.trunc((correctCount / question.responses.length) * 100), + }); + } + + return questionsWithScores; +} + +export function getIncorrectAndCorrectResponseCounts(responses: Response[]) { + let correctResponses = 0; + let incorrectReponses = 0; + + for (const question of responses) { + // get the IDs of the correct options + const correctOptionIds = question.options + .filter((option) => option.isCorrect) + .map((option) => option.id); + + // increment correctResponses if the option is correct, else increment incorrect responses + question.responses.forEach((response) => { + if (correctOptionIds.includes(response.optionId)) { + correctResponses++; + } else { + incorrectReponses++; + } + }); + } + + return { incorrect: incorrectReponses, correct: correctResponses }; +} + +export function getStudentsWithScores(students: Student[], sessionIds: number[]) { + const studentData = students.map((student) => { + // get total number of sessions + const totalSessions = sessionIds.length; + + // get all student responses for this course + const studentResponses = student.responses.filter((response) => + sessionIds.includes(response.question.sessionId), + ); + + // get number of sessions attended + const attendedSessions = new Set( + studentResponses.map((response) => response.question.sessionId), + ).size; + + // get number of correct responses + const correctResponses = studentResponses.filter((response) => + response.question.options.some((option) => option.isCorrect), + ).length; + + // calculate attendance and poll score + const attendance = + totalSessions > 0 ? Math.trunc((attendedSessions / totalSessions) * 100) : 0; + + const pollScore = + studentResponses.length > 0 + ? Math.trunc((correctResponses / studentResponses.length) * 100) + : 0; + + return { + name: String(student.firstName) + " " + String(student.lastName), + email: student.email, + attendance, + pollScore, + }; + }); + + return studentData; +} + +export function calculateDayAttendance(attendanceCount: number, totalStudentsCount: number) { + if (totalStudentsCount === 0) { + return 0; + } + return Math.trunc((attendanceCount / totalStudentsCount) * 100); +} + +export async function calculateWeekAttendance(start: Date, courseId: number) { + try { + const week: { date: string; attendance: number }[] = []; + + for (let i = 0; i < 7; i++) { + // calculate day + const day = dayjs(start).add(i, "day"); + + // calculate attendance as number of students attended / total number of students + await getStudentCount(courseId) + .then((studentCount) => { + if (typeof studentCount !== "number" && "error" in studentCount) { + return { error: studentCount?.error ?? "Unknown error occurred." }; + } else { + getAttendanceCount(courseId, day.toDate()).then((attendanceCount) => { + if (typeof attendanceCount !== "number" && "error" in attendanceCount) { + return { + error: attendanceCount?.error ?? "Unknown error occurred.", + }; + } else { + let attendance = calculateDayAttendance( + attendanceCount, + studentCount, + ); + week.push({ + date: day.format("M/D"), + attendance, + }); + } + }); + } + }) + .catch((err: unknown) => { + console.error(err); + return { error: "Unknown error occurred." }; + }); + } + + return week; + } catch (err) { + return { error: "Error calculating attendance" }; + } +} diff --git a/services/question.ts b/services/question.ts index 8fc7d76..2e1165d 100644 --- a/services/question.ts +++ b/services/question.ts @@ -120,9 +120,9 @@ export async function deleteQuestion(questionId: number) { } } -export async function getPastQuestionsWithScore(courseId: number) { +export async function getLimitedPastQuestions(courseId: number, limit: number) { try { - const data = await prisma.question.findMany({ + const questions = await prisma.question.findMany({ where: { session: { courseId, @@ -136,47 +136,23 @@ export async function getPastQuestionsWithScore(courseId: number) { }, { position: "desc" }, ], - take: 2, + take: limit, include: { responses: true, options: true, }, }); - const pastQuestions = []; - - for (const question of data) { - const correctOptionIds = question.options - .filter((option) => option.isCorrect) - .map((option) => option.id); - - let correctCount = 0; - question.responses.forEach((response) => { - if (correctOptionIds.includes(response.optionId)) { - correctCount++; - } - }); - - pastQuestions.push({ - type: question.type, - title: question.text, - average: - question.responses.length === 0 - ? 0 - : Math.trunc((correctCount / question.responses.length) * 100), - }); - } - - return pastQuestions; + return questions; } catch (err) { console.error(err); return { error: "Error fetching past questions." }; } } -export async function getResponseStatistics(courseId: number) { +export async function getResponses(courseId: number) { try { - const data = await prisma.question.findMany({ + const responses = await prisma.question.findMany({ where: { session: { courseId, @@ -188,23 +164,7 @@ export async function getResponseStatistics(courseId: number) { }, }); - let correctResponses = 0; - let incorrectReponses = 0; - for (const question of data) { - const correctOptionIds = question.options - .filter((option) => option.isCorrect) - .map((option) => option.id); - - question.responses.forEach((response) => { - if (correctOptionIds.includes(response.optionId)) { - correctResponses++; - } else { - incorrectReponses++; - } - }); - } - - return { incorrect: incorrectReponses, correct: correctResponses }; + return responses; } catch (err) { console.error(err); return { error: "Error calculating class average." }; diff --git a/services/session.ts b/services/session.ts index 57b9768..7c923de 100644 --- a/services/session.ts +++ b/services/session.ts @@ -94,3 +94,50 @@ export async function getQuestionById(questionId: number) { throw error; } } + +export async function getAllSessionIds(courseId: number){ + try { + const sessions = await prisma.courseSession + .findMany({ + where: { + courseId, + }, + select: { + id: true, + }, + }) + + return sessions.map((session) => session.id); + } catch (error) { + return {error: "Error fetching sessions"} + } +} + +export async function getSessionIdsByDate(courseId: number, date: Date){ + try{ + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const sessions = await prisma.courseSession + .findMany({ + where: { + courseId, + startTime: { + gte: startOfDay, + lte: endOfDay, + }, + }, + select: { + id: true, + }, + }) + .then((res) => res.map((session) => session.id)); + + return sessions; + } catch (error) { + return {error: "Error fetching session information"} + } +} diff --git a/services/userCourse.ts b/services/userCourse.ts index d8d846f..f8298bd 100644 --- a/services/userCourse.ts +++ b/services/userCourse.ts @@ -1,6 +1,7 @@ "use server"; import { Role } from "@prisma/client"; import prisma from "@/lib/prisma"; +import { getSessionIdsByDate } from "./session"; export async function addUserToCourse(courseId: number, userId: string, role: Role = "STUDENT") { const existingUser = await prisma.userCourse.findFirst({ @@ -99,7 +100,7 @@ export async function addUserToCourseByEmail( export async function getStudents(courseId: number, query: string | undefined) { try { - const studentsData = await prisma.user.findMany({ + const students = await prisma.user.findMany({ where: { courses: { some: { @@ -153,74 +154,17 @@ export async function getStudents(courseId: number, query: string | undefined) { }, }, }); - const sessions = await prisma.courseSession - .findMany({ - where: { - courseId, - }, - select: { - id: true, - }, - }) - .then((res) => res.map((session) => session.id)); - - const result = studentsData.map((student) => { - const totalSessions = sessions.length; - const studentResponses = student.responses.filter((response) => - sessions.includes(response.question.sessionId), - ); - const attendedSessions = new Set( - studentResponses.map((response) => response.question.sessionId), - ).size; - - const correctResponses = studentResponses.filter((response) => - response.question.options.some((option) => option.isCorrect), - ).length; + + return students; - const attendance = - totalSessions > 0 ? Math.trunc((attendedSessions / totalSessions) * 100) : 0; - const pollScore = - studentResponses.length > 0 - ? Math.trunc((correctResponses / studentResponses.length) * 100) - : 0; - - return { - name: String(student.firstName) + " " + String(student.lastName), - email: student.email, - attendance, - pollScore, - }; - }); - return result; } catch (err) { console.error(err); return { error: "Error fetching students." }; } } -export async function getAttendanceByDay(courseId: number, date: Date) { - try { - const startOfDay = new Date(date); - startOfDay.setHours(0, 0, 0, 0); - - const endOfDay = new Date(date); - endOfDay.setHours(23, 59, 59, 999); - - const sessions = await prisma.courseSession - .findMany({ - where: { - courseId, - startTime: { - gte: startOfDay, - lte: endOfDay, - }, - }, - select: { - id: true, - }, - }) - .then((res) => res.map((session) => session.id)); - +export async function getStudentCount(courseId: number){ + try{ const totalStudents = await prisma.user.count({ where: { courses: { @@ -232,7 +176,22 @@ export async function getAttendanceByDay(courseId: number, date: Date) { }, }); - if (sessions.length === 0 || totalStudents === 0) { + return totalStudents; + + } catch (err) { + return { error: "Error fetching student information" }; + } +} + +export async function getAttendanceCount(courseId: number, date: Date){ + try { + const sessionIds = await getSessionIdsByDate(courseId, date); + + if ('error' in sessionIds){ + throw Error; + } + + if (sessionIds.length === 0){ return 0; } @@ -240,7 +199,7 @@ export async function getAttendanceByDay(courseId: number, date: Date) { where: { question: { sessionId: { - in: sessions, + in: sessionIds, }, }, user: { @@ -258,9 +217,9 @@ export async function getAttendanceByDay(courseId: number, date: Date) { }, }); - return Math.trunc((attendedStudents.length / totalStudents) * 100); + return attendedStudents.length; + } catch (err) { - console.error(err); - return { error: "Error calculating attendance rate." }; + return { error: "Error fetching student information" }; } -} +} \ No newline at end of file From 84f5ab4d41eb336320400302bff6006e63a56f92 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Thu, 29 May 2025 08:19:45 -0700 Subject: [PATCH 18/23] fix table height --- .../course/[courseId]/(courseInfo)/analytics/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index c5c4c19..e124345 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -118,6 +118,7 @@ export default function Page() { }); } else { setStudents(getStudentsWithScores(students, sessions)); + setStudents([]); } }); } @@ -209,8 +210,8 @@ export default function Page() {

{/* Student Data Table */} -
- +
+
{students?.length === 0 && No students enrolled} From 3ff7f293d8ac60409213a14e98c964be16967a15 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Thu, 29 May 2025 08:35:59 -0700 Subject: [PATCH 19/23] fix lint errors --- .../(courseInfo)/analytics/page.tsx | 14 ++--- lib/constants.ts | 10 ++-- lib/utils.ts | 59 +++++++------------ services/session.ts | 45 +++++++------- services/userCourse.ts | 23 ++++---- 5 files changed, 68 insertions(+), 83 deletions(-) diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index e124345..37be184 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -23,14 +23,14 @@ import { performanceChartConfig, questionTypeMap, } from "@/lib/constants"; -import { getLimitedPastQuestions, getResponses } from "@/services/question"; -import { getStudents } from "@/services/userCourse"; import { getIncorrectAndCorrectResponseCounts, getQuestionsWithAverageScore, getStudentsWithScores, } from "@/lib/utils"; +import { getLimitedPastQuestions, getResponses } from "@/services/question"; import { getAllSessionIds } from "@/services/session"; +import { getStudents } from "@/services/userCourse"; export default function Page() { const params = useParams(); @@ -102,12 +102,12 @@ export default function Page() { useEffect(() => { const fetchStudentData = async () => { - const students = await getStudents(courseId, studentQuery) - .then(async (students) => { - if ("error" in students) + await getStudents(courseId, studentQuery) + .then(async (studentData) => { + if ("error" in studentData) return toast({ variant: "destructive", - description: students?.error ?? "Unknown error occurred.", + description: studentData?.error ?? "Unknown error occurred.", }); else { await getAllSessionIds(courseId).then((sessions) => { @@ -117,7 +117,7 @@ export default function Page() { description: sessions?.error ?? "Unknown error occurred.", }); } else { - setStudents(getStudentsWithScores(students, sessions)); + setStudents(getStudentsWithScores(studentData, sessions)); setStudents([]); } }); diff --git a/lib/constants.ts b/lib/constants.ts index 3050d66..3348480 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,5 +1,5 @@ -import { ChartConfig } from "@/components/ui/chart"; import { $Enums } from "@prisma/client"; +import { ChartConfig } from "@/components/ui/chart"; export const questionTypes = ["Multiple Choice", "Select All"] as const; export const colorOptions = ["#ED9D9D", "#F3AB7E", "#EEF583", "#94ED79", "#8E87F2"]; @@ -68,7 +68,7 @@ export type QuestionWithResponesAndOptions = { type: $Enums.QuestionType; sessionId: number; position: number; -} +}; export type Response = { options: { @@ -82,8 +82,8 @@ export type Response = { questionId: number; userId: string; answeredAt: Date; - }[] -} + }[]; +}; export type Student = { id: string; @@ -98,4 +98,4 @@ export type Student = { email: string | null; firstName: string; lastName: string | null; -} \ No newline at end of file +}; diff --git a/lib/utils.ts b/lib/utils.ts index 34aad30..5d3281b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,8 +1,8 @@ import { clsx } from "clsx"; import type { ClassValue } from "clsx"; +import dayjs from "dayjs"; import { twMerge } from "tailwind-merge"; import { QuestionWithResponesAndOptions, Response, Student } from "./constants"; -import dayjs from "dayjs"; import { getAttendanceCount, getStudentCount } from "@/services/userCourse"; /** * A utility function that merges tailwind classes with conditional classes combining functionalities of twMerge and clsx. @@ -146,44 +146,29 @@ export function calculateDayAttendance(attendanceCount: number, totalStudentsCou export async function calculateWeekAttendance(start: Date, courseId: number) { try { - const week: { date: string; attendance: number }[] = []; - - for (let i = 0; i < 7; i++) { - // calculate day + const promises = Array.from({ length: 7 }).map(async (_, i) => { const day = dayjs(start).add(i, "day"); + try { + const studentCount = await getStudentCount(courseId); + if (typeof studentCount !== "number") { + throw Error(); + } + const attendanceCount = await getAttendanceCount(courseId, day.toDate()); + if (typeof attendanceCount !== "number") { + throw Error(); + } + const attendance = calculateDayAttendance(attendanceCount, studentCount); + return { date: day.format("M/D"), attendance }; + } catch (err) { + console.error(err); + throw Error(); + } + }); - // calculate attendance as number of students attended / total number of students - await getStudentCount(courseId) - .then((studentCount) => { - if (typeof studentCount !== "number" && "error" in studentCount) { - return { error: studentCount?.error ?? "Unknown error occurred." }; - } else { - getAttendanceCount(courseId, day.toDate()).then((attendanceCount) => { - if (typeof attendanceCount !== "number" && "error" in attendanceCount) { - return { - error: attendanceCount?.error ?? "Unknown error occurred.", - }; - } else { - let attendance = calculateDayAttendance( - attendanceCount, - studentCount, - ); - week.push({ - date: day.format("M/D"), - attendance, - }); - } - }); - } - }) - .catch((err: unknown) => { - console.error(err); - return { error: "Unknown error occurred." }; - }); - } - - return week; - } catch (err) { + const weekData = await Promise.all(promises); + return weekData; + } catch (err: unknown) { + console.error(err); return { error: "Error calculating attendance" }; } } diff --git a/services/session.ts b/services/session.ts index 7c923de..7dd598a 100644 --- a/services/session.ts +++ b/services/session.ts @@ -95,39 +95,39 @@ export async function getQuestionById(questionId: number) { } } -export async function getAllSessionIds(courseId: number){ +export async function getAllSessionIds(courseId: number) { try { - const sessions = await prisma.courseSession - .findMany({ - where: { - courseId, - }, - select: { - id: true, - }, - }) - + const sessions = await prisma.courseSession.findMany({ + where: { + courseId, + }, + select: { + id: true, + }, + }); + return sessions.map((session) => session.id); } catch (error) { - return {error: "Error fetching sessions"} - } + console.error(error); + return { error: "Error fetching sessions" }; + } } -export async function getSessionIdsByDate(courseId: number, date: Date){ - try{ - const startOfDay = new Date(date); - startOfDay.setHours(0, 0, 0, 0); +export async function getSessionIdsByDate(courseId: number, date: Date) { + try { + const dayStart = new Date(date); + dayStart.setHours(0, 0, 0, 0); - const endOfDay = new Date(date); - endOfDay.setHours(23, 59, 59, 999); + const dayEnd = new Date(date); + dayEnd.setHours(23, 59, 59, 999); const sessions = await prisma.courseSession .findMany({ where: { courseId, startTime: { - gte: startOfDay, - lte: endOfDay, + gte: dayStart, + lte: dayEnd, }, }, select: { @@ -138,6 +138,7 @@ export async function getSessionIdsByDate(courseId: number, date: Date){ return sessions; } catch (error) { - return {error: "Error fetching session information"} + console.error(error); + return { error: "Error fetching session information" }; } } diff --git a/services/userCourse.ts b/services/userCourse.ts index f8298bd..1df2936 100644 --- a/services/userCourse.ts +++ b/services/userCourse.ts @@ -1,7 +1,7 @@ "use server"; import { Role } from "@prisma/client"; -import prisma from "@/lib/prisma"; import { getSessionIdsByDate } from "./session"; +import prisma from "@/lib/prisma"; export async function addUserToCourse(courseId: number, userId: string, role: Role = "STUDENT") { const existingUser = await prisma.userCourse.findFirst({ @@ -154,17 +154,16 @@ export async function getStudents(courseId: number, query: string | undefined) { }, }, }); - - return students; + return students; } catch (err) { console.error(err); return { error: "Error fetching students." }; } } -export async function getStudentCount(courseId: number){ - try{ +export async function getStudentCount(courseId: number) { + try { const totalStudents = await prisma.user.count({ where: { courses: { @@ -177,21 +176,21 @@ export async function getStudentCount(courseId: number){ }); return totalStudents; - } catch (err) { + console.error(err); return { error: "Error fetching student information" }; } } -export async function getAttendanceCount(courseId: number, date: Date){ +export async function getAttendanceCount(courseId: number, date: Date) { try { const sessionIds = await getSessionIdsByDate(courseId, date); - if ('error' in sessionIds){ - throw Error; + if ("error" in sessionIds) { + throw Error(); } - if (sessionIds.length === 0){ + if (sessionIds.length === 0) { return 0; } @@ -218,8 +217,8 @@ export async function getAttendanceCount(courseId: number, date: Date){ }); return attendedStudents.length; - } catch (err) { + console.error(err); return { error: "Error fetching student information" }; } -} \ No newline at end of file +} From 6844ce0a58d3a960d31c89cb25fd1dafbb5e5034 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Thu, 29 May 2025 08:37:59 -0700 Subject: [PATCH 20/23] fix student data error --- app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index 37be184..eb4d7f7 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -118,7 +118,6 @@ export default function Page() { }); } else { setStudents(getStudentsWithScores(studentData, sessions)); - setStudents([]); } }); } From db792963fd49b01f95e894298a41062f311decd9 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Sun, 1 Jun 2025 18:29:30 -0700 Subject: [PATCH 21/23] create separate components, increase debounce timer --- .../(courseInfo)/analytics/page.tsx | 248 +----------------- components/ui/AttendanceLineChart.tsx | 5 +- components/ui/PerformanceData.tsx | 110 ++++++++ components/ui/StudentTable.tsx | 140 ++++++++++ 4 files changed, 262 insertions(+), 241 deletions(-) create mode 100644 components/ui/PerformanceData.tsx create mode 100644 components/ui/StudentTable.tsx diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index eb4d7f7..6f8e0b6 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -1,137 +1,18 @@ "use client"; import { useParams } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; -import DonutChart from "@/components/ui/DonutChart"; +import PerformanceData from "@/components/ui/PerformanceData"; +import StudentTable from "@/components/ui/StudentTable"; import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { useToast } from "@/hooks/use-toast"; -import { - analyticsPages, - dataKey, - description, - nameKey, - performanceChartConfig, - questionTypeMap, -} from "@/lib/constants"; -import { - getIncorrectAndCorrectResponseCounts, - getQuestionsWithAverageScore, - getStudentsWithScores, -} from "@/lib/utils"; -import { getLimitedPastQuestions, getResponses } from "@/services/question"; -import { getAllSessionIds } from "@/services/session"; -import { getStudents } from "@/services/userCourse"; +import { analyticsPages } from "@/lib/constants"; export default function Page() { const params = useParams(); const courseId = parseInt((params.courseId as string) ?? "0"); - const [isLoading, setIsLoading] = useState(true); - const [pastQuestions, setPastQuestions] = useState< - { type: keyof typeof questionTypeMap; title: string; average: number }[] - >([]); - const [responseStatistics, setResponseStatistics] = useState<{ - incorrect: number; - correct: number; - }>({ - incorrect: 0, - correct: 0, - }); - const [students, setStudents] = useState< - { - name: string; - email: string | null; - attendance: number; - pollScore: number; - }[] - >([]); + const [isLoading, setIsLoading] = useState(false); const [page, setPage] = useState("Performance"); - const [studentQuery, setStudentQuery] = useState(undefined); - const { toast } = useToast(); - - const performanceChartData = [ - { result: "correct", count: responseStatistics.correct, fill: "#BAFF7E" }, - { result: "incorrect", count: responseStatistics.incorrect, fill: "#CCCCCC" }, - ]; - - useEffect(() => { - const fetchCourseStatistics = async () => { - try { - setIsLoading(true); - const pastQuestionsRes = await getLimitedPastQuestions(courseId, 2); - if ("error" in pastQuestionsRes) { - return toast({ - variant: "destructive", - description: pastQuestionsRes?.error ?? "Unknown error occurred.", - }); - } else { - setPastQuestions(getQuestionsWithAverageScore(pastQuestionsRes)); - } - - const responses = await getResponses(courseId); - if (!responses || (typeof responses !== "number" && "error" in responses)) { - return toast({ - variant: "destructive", - description: responses?.error ?? "Unknown error occurred.", - }); - } else if (responses) { - setResponseStatistics(getIncorrectAndCorrectResponseCounts(responses)); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - description: "Unknown error occurred.", - }); - } finally { - setIsLoading(false); - } - }; - - void fetchCourseStatistics(); - }, []); - - useEffect(() => { - const fetchStudentData = async () => { - await getStudents(courseId, studentQuery) - .then(async (studentData) => { - if ("error" in studentData) - return toast({ - variant: "destructive", - description: studentData?.error ?? "Unknown error occurred.", - }); - else { - await getAllSessionIds(courseId).then((sessions) => { - if ("error" in sessions) { - return toast({ - variant: "destructive", - description: sessions?.error ?? "Unknown error occurred.", - }); - } else { - setStudents(getStudentsWithScores(studentData, sessions)); - } - }); - } - }) - .catch((err: unknown) => { - console.error(err); - return toast({ - variant: "destructive", - description: "Unknown error occurred.", - }); - }); - }; - void fetchStudentData(); - }, [studentQuery]); if (isLoading) { return ; @@ -156,50 +37,12 @@ export default function Page() { ))} + {/* Performance and Attendance Data */}
- {/* Performance page */} {page === "Performance" ? ( - <> -
- {/* Donut Chart */} - -
- {/* Past Questions */} -
- {pastQuestions.map((question, idx) => ( -
-
-

- {questionTypeMap[question.type]} -

-

{question.average}

-
-

{question.title}

-
- ))} -
- + ) : ( - + )}
@@ -209,80 +52,7 @@ export default function Page() {
{/* Student Data Table */} -
-
- {students?.length === 0 && No students enrolled} - - - - Student - - - Attendance - - - Poll Score - - - { - setStudentQuery(e.target.value); - }} - type="text" - placeholder="Search student..." - className="h-8 w-full md:max-w-[12vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" - /> - - - - - {students.map((student, idx) => ( - - -

{student.name}

-

- {student.email ?? "No email provided"} -

-
- -

50 && - student.attendance <= 75 - ? "bg-[#F8ECA1]" - : "bg-[#BFF2A6]" - }`} - > - {student.attendance}% -

-
- -

50 && student.pollScore <= 75 - ? "bg-[#F8ECA1]" - : "bg-[#BFF2A6]" - }`} - > - {student.pollScore}% -

-
- -
- -
-
-
- ))} -
-
-
+
); } diff --git a/components/ui/AttendanceLineChart.tsx b/components/ui/AttendanceLineChart.tsx index d373c2c..e50e373 100644 --- a/components/ui/AttendanceLineChart.tsx +++ b/components/ui/AttendanceLineChart.tsx @@ -9,11 +9,12 @@ import useDebounce from "@/hooks/use-debounce"; interface Props { courseId: number; + setIsLoading: (isLoading: boolean) => void; } -export default function AttendanceLineChart({ courseId }: Props) { +export default function AttendanceLineChart({ courseId, setIsLoading }: Props) { const [weekStart, setWeekStart] = useState(dayjs().startOf("week").toDate()); - let debouncedWeekStart = useDebounce(weekStart, 200); + let debouncedWeekStart = useDebounce(weekStart, 2000); const [chartData, setChartData] = useState<{ date: string; attendance: number }[]>(); const { toast } = useToast(); diff --git a/components/ui/PerformanceData.tsx b/components/ui/PerformanceData.tsx new file mode 100644 index 0000000..0005814 --- /dev/null +++ b/components/ui/PerformanceData.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from "react"; +import DonutChart from "@/components/ui/DonutChart"; +import { + dataKey, + description, + nameKey, + performanceChartConfig, + questionTypeMap, +} from "@/lib/constants"; +import { useToast } from "@/hooks/use-toast"; +import { getLimitedPastQuestions, getResponses } from "@/services/question"; +import { getIncorrectAndCorrectResponseCounts, getQuestionsWithAverageScore } from "@/lib/utils"; + +interface Props { + courseId: number; + setIsLoading: (isLoading: boolean) => void; +} +export default function PerformanceData({ courseId, setIsLoading }: Props) { + const [pastQuestions, setPastQuestions] = useState< + { type: keyof typeof questionTypeMap; title: string; average: number }[] + >([]); + const [responseStatistics, setResponseStatistics] = useState<{ + incorrect: number; + correct: number; + }>({ + incorrect: 0, + correct: 0, + }); + const { toast } = useToast(); + + const performanceChartData = [ + { result: "correct", count: responseStatistics.correct, fill: "#BAFF7E" }, + { result: "incorrect", count: responseStatistics.incorrect, fill: "#CCCCCC" }, + ]; + + useEffect(() => { + const fetchCourseStatistics = async () => { + try { + const pastQuestionsRes = await getLimitedPastQuestions(courseId, 2); + if ("error" in pastQuestionsRes) { + return toast({ + variant: "destructive", + description: pastQuestionsRes?.error ?? "Unknown error occurred.", + }); + } else { + setPastQuestions(getQuestionsWithAverageScore(pastQuestionsRes)); + } + + const responses = await getResponses(courseId); + if (!responses || (typeof responses !== "number" && "error" in responses)) { + return toast({ + variant: "destructive", + description: responses?.error ?? "Unknown error occurred.", + }); + } else if (responses) { + setResponseStatistics(getIncorrectAndCorrectResponseCounts(responses)); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + description: "Unknown error occurred.", + }); + } + }; + + void fetchCourseStatistics(); + }, []); + + return ( + <> +
+ {/* Donut Chart */} + +
+ {/* Past Questions */} +
+ {pastQuestions.map((question, idx) => ( +
+
+

+ {questionTypeMap[question.type]} +

+

{question.average}

+
+

{question.title}

+
+ ))} +
+ + ); +} diff --git a/components/ui/StudentTable.tsx b/components/ui/StudentTable.tsx new file mode 100644 index 0000000..e983725 --- /dev/null +++ b/components/ui/StudentTable.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useState } from "react"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { getStudents } from "@/services/userCourse"; +import { getAllSessionIds } from "@/services/session"; +import { getStudentsWithScores } from "@/lib/utils"; + +interface Props { + courseId: number; + setIsLoading: (isLoading: boolean) => void; +} +export default function StudentTable({ courseId, setIsLoading }: Props) { + const [students, setStudents] = useState< + { + name: string; + email: string | null; + attendance: number; + pollScore: number; + }[] + >([]); + const [studentQuery, setStudentQuery] = useState(undefined); + const { toast } = useToast(); + + useEffect(() => { + const fetchStudentData = async () => { + await getStudents(courseId, studentQuery) + .then(async (studentData) => { + if ("error" in studentData) + return toast({ + variant: "destructive", + description: studentData?.error ?? "Unknown error occurred.", + }); + else { + await getAllSessionIds(courseId).then((sessions) => { + if ("error" in sessions) { + return toast({ + variant: "destructive", + description: sessions?.error ?? "Unknown error occurred.", + }); + } else { + setStudents(getStudentsWithScores(studentData, sessions)); + } + }); + } + }) + .catch((err: unknown) => { + console.error(err); + return toast({ + variant: "destructive", + description: "Unknown error occurred.", + }); + }); + }; + void fetchStudentData(); + }, [studentQuery]); + + return ( +
+ + {students?.length === 0 && No students enrolled} + + + + Student + + + Attendance + + + Poll Score + + + { + setStudentQuery(e.target.value); + }} + type="text" + placeholder="Search student..." + className="h-8 w-full md:max-w-[12vw] px-3 bg-white text-black border border-slate-300 rounded-lg focus:outline-none" + /> + + + + + {students.map((student, idx) => ( + + +

{student.name}

+

+ {student.email ?? "No email provided"} +

+
+ +

50 && student.attendance <= 75 + ? "bg-[#F8ECA1]" + : "bg-[#BFF2A6]" + }`} + > + {student.attendance}% +

+
+ +

50 && student.pollScore <= 75 + ? "bg-[#F8ECA1]" + : "bg-[#BFF2A6]" + }`} + > + {student.pollScore}% +

+
+ +
+ +
+
+
+ ))} +
+
+
+ ); +} From 3523ab4bb955777d13f2094a284f9ef49c12d770 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Sun, 1 Jun 2025 20:13:29 -0700 Subject: [PATCH 22/23] fix loading issue --- .../[courseId]/(courseInfo)/analytics/page.tsx | 12 +++--------- components/ui/AttendanceLineChart.tsx | 4 ++-- components/ui/PerformanceData.tsx | 12 ++++++++++-- components/ui/StudentTable.tsx | 13 +++++++++++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx index 6f8e0b6..4004be7 100644 --- a/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx +++ b/app/dashboard/course/[courseId]/(courseInfo)/analytics/page.tsx @@ -5,19 +5,13 @@ import React, { useState } from "react"; import AttendanceLineChart from "@/components/ui/AttendanceLineChart"; import PerformanceData from "@/components/ui/PerformanceData"; import StudentTable from "@/components/ui/StudentTable"; -import { GlobalLoadingSpinner } from "@/components/ui/global-loading-spinner"; import { analyticsPages } from "@/lib/constants"; export default function Page() { const params = useParams(); const courseId = parseInt((params.courseId as string) ?? "0"); - const [isLoading, setIsLoading] = useState(false); const [page, setPage] = useState("Performance"); - if (isLoading) { - return ; - } - return (
@@ -40,9 +34,9 @@ export default function Page() { {/* Performance and Attendance Data */}
{page === "Performance" ? ( - + ) : ( - + )}
@@ -52,7 +46,7 @@ export default function Page() {
{/* Student Data Table */} - +
); } diff --git a/components/ui/AttendanceLineChart.tsx b/components/ui/AttendanceLineChart.tsx index e50e373..c7457b0 100644 --- a/components/ui/AttendanceLineChart.tsx +++ b/components/ui/AttendanceLineChart.tsx @@ -9,10 +9,9 @@ import useDebounce from "@/hooks/use-debounce"; interface Props { courseId: number; - setIsLoading: (isLoading: boolean) => void; } -export default function AttendanceLineChart({ courseId, setIsLoading }: Props) { +export default function AttendanceLineChart({ courseId }: Props) { const [weekStart, setWeekStart] = useState(dayjs().startOf("week").toDate()); let debouncedWeekStart = useDebounce(weekStart, 2000); const [chartData, setChartData] = useState<{ date: string; attendance: number }[]>(); @@ -45,6 +44,7 @@ export default function AttendanceLineChart({ courseId, setIsLoading }: Props) { const handlePrevWeek = () => { setWeekStart(dayjs(weekStart).subtract(7, "day").toDate()); }; + return (