Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
00556ac
build analytics page outline
n1sh1thaS May 12, 2025
20a1c20
create table design
n1sh1thaS May 12, 2025
ac21bbe
build attendance pie chart
n1sh1thaS May 12, 2025
3cd7ac5
fetch past question data
n1sh1thaS May 19, 2025
eec995b
fetch class average data
n1sh1thaS May 19, 2025
30383d8
fix lint errors and past question card issue
n1sh1thaS May 20, 2025
1356154
fetch student poll and attendance scores
n1sh1thaS May 20, 2025
c8d3a91
build analytics page buttons
n1sh1thaS May 20, 2025
d4c53ef
created base UI for past questions analytics
nicholas-ngyn May 21, 2025
a8d10bf
create attendance rate chart
n1sh1thaS May 27, 2025
f9b2b9c
edit student table, add search functionality
n1sh1thaS May 27, 2025
6619710
added date column & added T/F to prisma schema
nicholas-ngyn May 27, 2025
8779425
change to protected route
n1sh1thaS May 28, 2025
df024a9
Merge branch 'main' of https://github.com/CSES-Dev/webclicker-plusplu…
n1sh1thaS May 28, 2025
a39abb1
create page header
n1sh1thaS May 28, 2025
eadaa3c
removed T/F logic, matched UI with figma, integrated backend logic
nicholas-ngyn May 28, 2025
77e1e5e
create layout for questionnaire and analytics
n1sh1thaS May 28, 2025
3a1b246
ui improvements with charts, table, and header
n1sh1thaS May 28, 2025
e636f8a
use debounce for attendance chart
n1sh1thaS May 28, 2025
745574b
fix lint errors
n1sh1thaS May 28, 2025
8766ffd
extract calculations as utils
n1sh1thaS May 29, 2025
84f5ab4
fix table height
n1sh1thaS May 29, 2025
3ff7f29
fix lint errors
n1sh1thaS May 29, 2025
6844ce0
fix student data error
n1sh1thaS May 29, 2025
db79296
create separate components, increase debounce timer
n1sh1thaS Jun 2, 2025
4880d9b
fixed design
nicholas-ngyn Jun 2, 2025
3523ab4
fix loading issue
n1sh1thaS Jun 2, 2025
25e907d
fixed calculating average logic, added map to track latest student re…
nicholas-ngyn Jun 2, 2025
4c4bae4
made circular progress bigger, added responsiveness to past questions
nicholas-ngyn Jun 2, 2025
1e6fb2f
fixed minor design difference
nicholas-ngyn Jun 2, 2025
a1fdd91
Merge branch 'feature/analytics' into feature/past-questions-analytics
nicholas-ngyn Jun 2, 2025
27b3d07
moved path for responses page
nicholas-ngyn Jun 4, 2025
8a4e57d
minor fixes
j3rrythomas Jun 20, 2025
b320f45
Merge branch 'main' of github.com:CSES-UCSD/weblicker-plusplus into f…
j3rrythomas Jun 20, 2025
a5154b0
lint fix
j3rrythomas Jun 20, 2025
a13b94b
build errors fix
j3rrythomas Jun 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions app/api/courses/[courseId]/past-questions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Role } from "@prisma/client";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { validateUser } from "@/services/userCourse";

export async function GET(request: NextRequest, context: { params: Promise<{ courseId: string }> }) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { courseId: courseIdStr } = await context.params;

const courseId = parseInt(courseIdStr);

if (!courseId || isNaN(Number(courseId))) {
return NextResponse.json(
{ error: "Invalid or missing sessionId parameter" },
{ status: 400 },
);
}

if (!(await validateUser(session.user.id, courseId, Role.LECTURER))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const pastQuestions = await prisma.question.findMany({
where: {
session: {
courseId,
endTime: { not: null },
},
},
include: {
session: { select: { startTime: true } },
options: true,
responses: {
include: {
option: true,
user: { select: { firstName: true, lastName: true } },
},
},
},
orderBy: { session: { startTime: "desc" } },
});

return NextResponse.json(pastQuestions);
} catch (error) {
console.error("Failed to fetch past questions", error);
return NextResponse.json({ error: "Failed to fetch past questions" }, { status: 500 });
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
"use client";

import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { AddEditQuestionForm } from "@/components/AddEditQuestionForm";
import { AddInstructorForm } from "@/components/AddInstuctorForm";
import BeginPollDialog from "@/components/BeginPollDialog";
import PastQuestions from "@/components/ui/PastQuestions";
import SlidingCalendar from "@/components/ui/SlidingCalendar";
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 { getCourseSessionByDate } from "@/services/session";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";

export default function Page() {
const params = useParams();
Expand All @@ -20,6 +21,7 @@ export default function Page() {
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const { toast } = useToast();

const [selectedDate, setSelectedDate] = useState(new Date());
const [refreshCalendar, setRefreshCalendar] = useState(false);
const handleQuestionUpdate = () => {
Expand Down Expand Up @@ -84,6 +86,7 @@ export default function Page() {
onDateChange={setSelectedDate}
refreshTrigger={refreshCalendar}
/>
<PastQuestions courseId={courseId} />
<AddInstructorForm />
</div>
);
Expand Down
4 changes: 3 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export default function Home() {
asChild
className="w-40 bg-secondary text-primary hover:bg-secondary hover:text-primary"
>
<Link href="/dashboard" className="!h-12">Join a Class</Link>
<Link href="/dashboard" className="!h-12">
Join a Class
</Link>
</Button>
</Button>
</main>
Expand Down
203 changes: 203 additions & 0 deletions components/ui/PastQuestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import React, { useEffect, useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Question, QuestionType } from "@prisma/client";
import Link from "next/link";

const questionTypeStyles = {
[QuestionType.MCQ]: {
bgColor: "#FFFED3",
textColor: "#58560B",
borderColor: "#58570B",
label: "Multiple Choice",
},
[QuestionType.MSQ]: {
bgColor: "#EBCFFF",
textColor: "#602E84",
borderColor: "#602E84",
label: "Select-All",
},
};

interface PastQuestion extends Question {
session: { startTime: Date };
options: { id: number; text: string; isCorrect: boolean }[];
responses: {
optionId: number;
user: { firstName: string; lastName?: string };
answeredAt: string;
}[];
}

interface Props {
courseId: number;
}

function PastQuestions({ courseId }: Props) {
const [questions, setQuestions] = useState<PastQuestion[]>([]);
const [filterType, setFilterType] = useState<QuestionType | "ALL">("ALL");

useEffect(() => {
const fetchPastQuestions = async () => {
try {
const response = await fetch(`/api/courses/${courseId}/past-questions`);
if (!response.ok) throw new Error("Failed to fetch questions");
const data = await response.json();
setQuestions(data);
} catch (error) {
console.error("Error fetching past questions:", error);
}
};

fetchPastQuestions();
}, [courseId]);

const filteredQuestions = questions.filter(
(question) => filterType === "ALL" || question.type === filterType,
);

const calculateScore = (question: PastQuestion) => {
if (question.responses.length === 0) return 0;

const correctOptionIds = question.options
.filter((option) => option.isCorrect)
.map((option) => option.id);

const userResponses = new Map<string, number[]>();
const userResponseTimes = new Map<string, Date>();

question.responses.forEach((response) => {
const userId = response.user.firstName + (response.user.lastName || "");
const responseTime = response.answeredAt ? new Date(response.answeredAt) : new Date(0);

if (!userResponseTimes.has(userId)) {
userResponseTimes.set(userId, responseTime);
userResponses.set(userId, [response.optionId]);
} else if (responseTime > userResponseTimes.get(userId)!) {
userResponseTimes.set(userId, responseTime);
userResponses.set(userId, [response.optionId]);
} else if (responseTime.getTime() === userResponseTimes.get(userId)?.getTime()) {
userResponses.get(userId)?.push(response.optionId);
}
});

let correctCount = 0;
userResponses.forEach((selectedOptionIds, userId) => {
if (question.type === QuestionType.MSQ) {
const isCorrect =
selectedOptionIds.length === correctOptionIds.length &&
correctOptionIds.every((id) => selectedOptionIds.includes(id));
if (isCorrect) correctCount++;
} else {
const isCorrect = question.options.some(
(opt) => opt.id === selectedOptionIds[0] && opt.isCorrect,
);
if (isCorrect) correctCount++;
}
});

const totalUsers = userResponses.size;
return totalUsers > 0 ? Math.round((correctCount / totalUsers) * 100) : 0;
};

const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("en-US");
};

return (
<div className="flex flex-col items-center space-y-4 w-full">
<section className="w-full max-w-screen-xl flex justify-between items-center">
<h1 className="font-medium text-2xl sm:text-4xl text-black">Past Questions</h1>
</section>

<div className="w-full flex flex-col max-w-screen-xl max-h-[600px] bg-white rounded-[20px] border border-[#A5A5A5]">
{/* Filter row */}
<section className="grid grid-cols-12 items-center p-6 bg-[#F9F9F9] border-b border-[#D9D9D9] rounded-t-[inherit]">
<div className="col-span-6 flex items-center space-x-4">
<span className="text-xl font-normal text-[#414141]">Question Type:</span>
<Select
value={filterType}
onValueChange={(value) => setFilterType(value as QuestionType | "ALL")}
>
<SelectTrigger className="w-[180px] text-lg text-black font-medium">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value={QuestionType.MCQ}>Multiple Choice</SelectItem>
<SelectItem value={QuestionType.MSQ}>Select-All</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 flex justify-center">
<span className="text-xl font-medium text-[#414141]">Date</span>
</div>
<div className="col-span-2 flex justify-center">
<span className="text-xl font-medium text-[#414141]">Avg. Score</span>
</div>
<div className="col-span-2"></div>
</section>

{/* Questions table */}
<section className="overflow-y-auto divide-y divide-[#D9D9D9]">
{filteredQuestions.length === 0 ? (
<div className="p-6 text-center text-gray-500">No past questions found</div>
) : (
filteredQuestions.map((question) => (
<div
key={question.id}
className="grid grid-cols-1 lg:grid-cols-12 gap-y-4 lg:gap-y-0 items-start lg:items-center p-6"
>
{/* Question column */}
<div className="lg:col-span-6">
<span
className="text-base lg:text-xl font-normal px-2 py-1 rounded border"
style={{
backgroundColor:
questionTypeStyles[question.type].bgColor,
color: questionTypeStyles[question.type].textColor,
borderColor:
questionTypeStyles[question.type].borderColor,
}}
>
{questionTypeStyles[question.type].label}
</span>
<p className="mt-2 text-lg lg:text-2xl font-normal text-[#1F1F1F]">
{question.text}
</p>
</div>

{/* Date column */}
<div className="lg:col-span-2 flex lg:justify-center text-base lg:text-xl text-[#1F1F1F]">
{formatDate(question.session.startTime)}
</div>

{/* Score column */}
<div className="lg:col-span-2 flex lg:justify-center text-base lg:text-xl font-semibold text-[#2D9B62]">
{calculateScore(question)}%
</div>

{/* Student Answers column */}
<div className="lg:col-span-2 flex lg:justify-end">
<Link
href={`/dashboard/course/${courseId}/questionnaire/${question.id}/responses`}
className="px-4 py-2 text-black rounded-lg border border-[#A5A5A5] text-center block whitespace-nowrap"
>
Student Answers &rarr;
</Link>
</div>
</div>
))
)}
</section>
</div>
</div>
);
}

export default PastQuestions;
1 change: 1 addition & 0 deletions components/ui/StudentTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useToast } from "@/hooks/use-toast";
import { getStudents } from "@/services/userCourse";
import { getAllSessionIds } from "@/services/session";
import { getStudentsWithScores } from "@/lib/utils";
import { GlobalLoadingSpinner } from "./global-loading-spinner";
import LoaderComponent from "./loader";
import { StudentAnalyticsDrawer } from "../StudentAnalyticsDrawer";

Expand Down
57 changes: 57 additions & 0 deletions components/ui/circular-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { cn } from "@/lib/utils";
import React from "react";

export function CircularProgress({
value,
size = 160,
thickness = 12,
className,
}: {
value: number;
size?: number;
thickness?: number;
className?: string;
}) {
const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (value / 100) * circumference;

return (
<div className={cn("relative", className)} style={{ width: size, height: size }}>
<svg className="absolute" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
stroke={value > 0 ? "#E6F6EC" : "#E5E7EB"}
strokeWidth={thickness}
className="transition-colors duration-300"
/>
</svg>

<svg className="absolute" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="transparent"
stroke="#2D9B62"
strokeWidth={thickness}
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all duration-300"
/>
</svg>

<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-base font-normal text-muted-foreground">Class Average:</span>
<span className="text-3xl font-medium text-foreground">{Math.round(value)}%</span>
</div>
</div>
);
}
10 changes: 9 additions & 1 deletion components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,12 @@ const TooltipContent = React.forwardRef<
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
const StringTooltipContainer = ({ text }: { text: string }) => {
return (
<div className="bg-white rounded-lg p-4 max-h-40 max-w-40 overflow-scroll text-md">
{text}
</div>
);
};

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, StringTooltipContainer };
2 changes: 1 addition & 1 deletion services/course.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export async function addCourse(
const courseCodes = new Set(courseResponse.map((course) => course.code));
while (i < 100) {
// Limit to 100 iterations - Prevent infinite loop with unbounded search
const tempCode = String(Math.round(Math.random() * (1e6 - 1))).padStart(6, '0');
const tempCode = String(Math.round(Math.random() * (1e6 - 1))).padStart(6, "0");

if (!courseCodes.has(tempCode)) {
code = tempCode;
Expand Down