From 21202683e3e29cb6a527948f2b29ee924a7f007c Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 01/10] feat: add course analytics computation library --- src/lib/courseAnalytics.ts | 289 +++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 src/lib/courseAnalytics.ts diff --git a/src/lib/courseAnalytics.ts b/src/lib/courseAnalytics.ts new file mode 100644 index 0000000..2279b9a --- /dev/null +++ b/src/lib/courseAnalytics.ts @@ -0,0 +1,289 @@ +import { prisma } from "@/lib/prisma"; +import { Prisma, Role } from "../generated/prisma"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ParticipantStats { + userId: string; + name: string; + role: Role; + questionsAsked: number; + answersGiven: number; + upvotesReceived: number; +} + +export interface WeeklyEngagement { + /** 1-based week index from the anchor week. */ + week: number; + /** ISO date of that week's Monday. */ + weekStart: string; + questions: number; + answers: number; + activeUsers: number; +} + +export interface CourseAnalytics { + course: { id: string; code: string; name: string }; + summary: { + totalQuestions: number; + totalAnswers: number; + activeParticipants: number; + answeredRate: number; + }; + participants: ParticipantStats[]; + weekly: WeeklyEngagement[]; +} + +export interface AnalyticsOptions { + from?: Date; + to?: Date; + /** Anchor for week numbering; defaults to the earliest session start. */ + weekStart?: Date; +} + +// --------------------------------------------------------------------------- +// Authorization +// --------------------------------------------------------------------------- + +/** + * Only instructors (PROFESSOR or TA) enrolled in the course may view its + * analytics. This is course-scoped and independent of the site-admin + * whitelist. + */ +export async function canViewCourseAnalytics(userId: string, courseId: string): Promise { + const enrollment = await prisma.courseEnrollment.findUnique({ + where: { userId_courseId: { userId, courseId } }, + select: { role: true }, + }); + return enrollment?.role === "PROFESSOR" || enrollment?.role === "TA"; +} + +// --------------------------------------------------------------------------- +// Week bucketing helpers +// --------------------------------------------------------------------------- + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +/** Returns the UTC Monday 00:00 of the ISO week containing `date`. */ +export function startOfIsoWeek(date: Date): Date { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const daysSinceMonday = (d.getUTCDay() + 6) % 7; + d.setUTCDate(d.getUTCDate() - daysSinceMonday); + return d; +} + +interface WeeklyRow { + weekStart: Date; + questions: number; + answers: number; + activeUsers: number; +} + +/** + * Expands sparse per-week rows into a contiguous series (empty weeks are + * reported as 0, not skipped), numbered 1-based from the anchor week. + */ +export function fillWeeklyBuckets(rows: WeeklyRow[], anchor: Date): WeeklyEngagement[] { + if (rows.length === 0) return []; + + // Re-normalize row keys to UTC Monday midnight so bucket lookup can't be + // thrown off by how the driver parsed the DB's date_trunc timestamps. + const keyed = rows.map((r) => ({ ...r, key: startOfIsoWeek(r.weekStart).getTime() })); + + const anchorWeek = startOfIsoWeek(anchor); + // Never number a data week below 1 — extend the anchor back if data + // predates it. + const first = Math.min(anchorWeek.getTime(), keyed[0].key); + const last = keyed[keyed.length - 1].key; + + const byWeek = new Map(keyed.map((r) => [r.key, r])); + const weekly: WeeklyEngagement[] = []; + for (let t = first, week = 1; t <= last; t += WEEK_MS, week++) { + const row = byWeek.get(t); + weekly.push({ + week, + weekStart: new Date(t).toISOString().slice(0, 10), + questions: row?.questions ?? 0, + answers: row?.answers ?? 0, + activeUsers: row?.activeUsers ?? 0, + }); + } + return weekly; +} + +// --------------------------------------------------------------------------- +// Aggregation +// --------------------------------------------------------------------------- + +/** + * Per-participant contribution stats for a course, joined via + * Question/Answer → Session.courseId. Anonymous posts are attributed to + * their real author (aggregate counts only — no content is exposed). + */ +export async function getParticipantBreakdown( + courseId: string, + sessionIds: string[], + from?: Date, + to?: Date +): Promise { + const createdAt = from || to ? { gte: from, lte: to } : undefined; + + const [questionStats, answerStats] = await Promise.all([ + prisma.question.groupBy({ + by: ["authorId"], + where: { sessionId: { in: sessionIds }, createdAt }, + _count: { _all: true }, + _sum: { upvoteCount: true }, + }), + prisma.answer.groupBy({ + by: ["authorId"], + where: { question: { sessionId: { in: sessionIds } }, createdAt }, + _count: { _all: true }, + _sum: { upvoteCount: true }, + }), + ]); + + const byUser = new Map< + string, + { questionsAsked: number; answersGiven: number; upvotesReceived: number } + >(); + const get = (userId: string) => { + let entry = byUser.get(userId); + if (!entry) { + entry = { questionsAsked: 0, answersGiven: 0, upvotesReceived: 0 }; + byUser.set(userId, entry); + } + return entry; + }; + + for (const q of questionStats) { + if (!q.authorId) continue; // legacy rows without an author + const entry = get(q.authorId); + entry.questionsAsked = q._count._all; + entry.upvotesReceived += q._sum.upvoteCount ?? 0; + } + for (const a of answerStats) { + const entry = get(a.authorId); + entry.answersGiven = a._count._all; + entry.upvotesReceived += a._sum.upvoteCount ?? 0; + } + + const userIds = [...byUser.keys()]; + const [users, enrollments] = await Promise.all([ + prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true }, + }), + prisma.courseEnrollment.findMany({ + where: { courseId, userId: { in: userIds } }, + select: { userId: true, role: true }, + }), + ]); + const nameById = new Map(users.map((u) => [u.id, u.name])); + const roleById = new Map(enrollments.map((e) => [e.userId, e.role])); + + return [...byUser.entries()] + .map(([userId, stats]) => ({ + userId, + name: nameById.get(userId) ?? "Unknown", + // Participants no longer enrolled (e.g. dropped) default to STUDENT + role: roleById.get(userId) ?? Role.STUDENT, + ...stats, + })) + .sort((a, b) => b.questionsAsked + b.answersGiven - (a.questionsAsked + a.answersGiven)); +} + +/** + * Weekly questions/answers/active-user counts across the course, bucketed by + * ISO week in the database (no per-row loading). + */ +export async function getWeeklyEngagement( + sessionIds: string[], + anchor: Date, + from?: Date, + to?: Date +): Promise { + const rows = await prisma.$queryRaw(Prisma.sql` + SELECT date_trunc('week', x."createdAt") AS "weekStart", + COUNT(*) FILTER (WHERE x.kind = 'question')::int AS "questions", + COUNT(*) FILTER (WHERE x.kind = 'answer')::int AS "answers", + COUNT(DISTINCT x."authorId")::int AS "activeUsers" + FROM ( + SELECT q."createdAt", q."authorId", 'question' AS kind + FROM "Question" q + WHERE q."sessionId" = ANY(${sessionIds}) + UNION ALL + SELECT a."createdAt", a."authorId", 'answer' AS kind + FROM "Answer" a + JOIN "Question" q ON q."id" = a."questionId" + WHERE q."sessionId" = ANY(${sessionIds}) + ) x + WHERE (CAST(${from ?? null} AS timestamptz) IS NULL OR x."createdAt" >= ${from ?? null}) + AND (CAST(${to ?? null} AS timestamptz) IS NULL OR x."createdAt" <= ${to ?? null}) + GROUP BY 1 + ORDER BY 1 + `); + + return fillWeeklyBuckets(rows, anchor); +} + +/** + * Full analytics payload for a course. `course` is passed in (the route has + * already fetched it for the 404 check). + */ +export async function getCourseAnalytics( + course: { id: string; code: string; name: string }, + options: AnalyticsOptions = {} +): Promise { + const { from, to } = options; + + const sessions = await prisma.session.findMany({ + where: { courseId: course.id }, + select: { id: true, startTime: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }); + const sessionIds = sessions.map((s) => s.id); + + const empty: CourseAnalytics = { + course, + summary: { totalQuestions: 0, totalAnswers: 0, activeParticipants: 0, answeredRate: 0 }, + participants: [], + weekly: [], + }; + if (sessionIds.length === 0) return empty; + + // Week numbering anchor: explicit weekStart wins, then the range start (so + // a filtered view starts at week 1 instead of leading empty weeks), then + // the earliest session. + const anchor = options.weekStart ?? from ?? sessions[0].startTime ?? sessions[0].createdAt; + const createdAt = from || to ? { gte: from, lte: to } : undefined; + const questionWhere = { sessionId: { in: sessionIds }, createdAt }; + + const [participants, weekly, totalQuestions, answeredQuestions, totalAnswers] = await Promise.all( + [ + getParticipantBreakdown(course.id, sessionIds, from, to), + getWeeklyEngagement(sessionIds, anchor, from, to), + prisma.question.count({ where: questionWhere }), + prisma.question.count({ + where: { ...questionWhere, status: { in: ["ANSWERED", "RESOLVED"] } }, + }), + prisma.answer.count({ + where: { question: { sessionId: { in: sessionIds } }, createdAt }, + }), + ] + ); + + return { + course, + summary: { + totalQuestions, + totalAnswers, + activeParticipants: participants.length, + answeredRate: totalQuestions === 0 ? 0 : answeredQuestions / totalQuestions, + }, + participants, + weekly, + }; +} From ac49cc56ff6fdff87184a8dd6ce274005249c96c Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 02/10] feat: add analytics date-range validation helper --- src/lib/analyticsValidation.ts | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/lib/analyticsValidation.ts diff --git a/src/lib/analyticsValidation.ts b/src/lib/analyticsValidation.ts new file mode 100644 index 0000000..74248f8 --- /dev/null +++ b/src/lib/analyticsValidation.ts @@ -0,0 +1,59 @@ +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +export interface AnalyticsRangeResult extends ValidationResult { + /** Parsed values, present when valid. Absent params stay undefined. */ + from?: Date; + to?: Date; + weekStart?: Date; +} + +// --------------------------------------------------------------------------- +// Validation Functions +// --------------------------------------------------------------------------- + +function parseDateParam(name: string, raw: string | null): { date?: Date; error?: string } { + if (raw === null || raw.trim() === "") return {}; + const date = new Date(raw); + if (isNaN(date.getTime())) { + return { error: `${name} must be a valid ISO date.` }; + } + return { date }; +} + +/** + * Validates the optional date-range query params of the analytics endpoint. + * All params are optional; when both `from` and `to` are present, `from` + * must not be after `to`. + */ +export function validateAnalyticsRange( + from: string | null, + to: string | null, + weekStart: string | null = null +): AnalyticsRangeResult { + const fromResult = parseDateParam("from", from); + if (fromResult.error) return { valid: false, error: fromResult.error }; + + const toResult = parseDateParam("to", to); + if (toResult.error) return { valid: false, error: toResult.error }; + + const weekStartResult = parseDateParam("weekStart", weekStart); + if (weekStartResult.error) return { valid: false, error: weekStartResult.error }; + + if (fromResult.date && toResult.date && fromResult.date > toResult.date) { + return { valid: false, error: "from must be before or equal to to." }; + } + + return { + valid: true, + from: fromResult.date, + to: toResult.date, + weekStart: weekStartResult.date, + }; +} From 3d9011b29140a6a92d6b4e7f677d32f7491b9fdf Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 03/10] feat: add course analytics API route --- .../api/courses/[courseId]/analytics/route.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/app/api/courses/[courseId]/analytics/route.ts diff --git a/src/app/api/courses/[courseId]/analytics/route.ts b/src/app/api/courses/[courseId]/analytics/route.ts new file mode 100644 index 0000000..a08c4d1 --- /dev/null +++ b/src/app/api/courses/[courseId]/analytics/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { getCurrentUser } from "@/lib/auth"; +import { canViewCourseAnalytics, getCourseAnalytics } from "@/lib/courseAnalytics"; +import { validateAnalyticsRange } from "@/lib/analyticsValidation"; + +interface RouteParams { + params: Promise<{ courseId: string }>; +} + +// --------------------------------------------------------------------------- +// GET /api/courses/[courseId]/analytics +// --------------------------------------------------------------------------- + +/** + * Returns engagement analytics for a course: summary totals, per-participant + * contribution stats (role-tagged), and a weekly engagement time series. + * + * Query params (all optional, ISO dates): + * - from / to: restrict the range (from <= to) + * - weekStart: anchor for week numbering (default: earliest session start) + * + * Only instructors (PROFESSOR or TA) enrolled in the course may call this. + */ +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { courseId } = await params; + + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + const course = await prisma.course.findUnique({ + where: { id: courseId }, + select: { id: true, code: true, name: true }, + }); + if (!course) { + return NextResponse.json({ error: "Course not found." }, { status: 404 }); + } + + if (!(await canViewCourseAnalytics(user.userId, courseId))) { + return NextResponse.json( + { error: "Only instructors of this course can view analytics." }, + { status: 403 } + ); + } + + const searchParams = request.nextUrl.searchParams; + const range = validateAnalyticsRange( + searchParams.get("from"), + searchParams.get("to"), + searchParams.get("weekStart") + ); + if (!range.valid) { + return NextResponse.json({ error: range.error }, { status: 400 }); + } + + const analytics = await getCourseAnalytics(course, { + from: range.from, + to: range.to, + weekStart: range.weekStart, + }); + + return NextResponse.json(analytics); + } catch (error) { + console.error("[Courses API] Failed to compute analytics:", error); + return NextResponse.json({ error: "An error occurred." }, { status: 500 }); + } +} From c9b89acf65ff00795ca7317319594c1aa01a37ff Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 04/10] feat: add analytics range selector component --- .../analytics/components/RangeSelector.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/app/courses/[courseId]/analytics/components/RangeSelector.tsx diff --git a/src/app/courses/[courseId]/analytics/components/RangeSelector.tsx b/src/app/courses/[courseId]/analytics/components/RangeSelector.tsx new file mode 100644 index 0000000..d31a070 --- /dev/null +++ b/src/app/courses/[courseId]/analytics/components/RangeSelector.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { Calendar } from "lucide-react"; + +export interface DateRange { + from?: string; + to?: string; +} + +type PresetKey = "all" | "today" | "week" | "month" | "4w" | "custom"; + +const PRESETS: { key: PresetKey; label: string }[] = [ + { key: "all", label: "All time" }, + { key: "today", label: "Today" }, + { key: "week", label: "This week" }, + { key: "month", label: "This month" }, + { key: "4w", label: "Last 4 weeks" }, + { key: "custom", label: "Custom range" }, +]; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Start of the given local day as an ISO instant. */ +function startOfLocalDay(d: Date): string { + const start = new Date(d); + start.setHours(0, 0, 0, 0); + return start.toISOString(); +} + +/** End of the given local day as an ISO instant. */ +function endOfLocalDay(d: Date): string { + const end = new Date(d); + end.setHours(23, 59, 59, 999); + return end.toISOString(); +} + +// Presets use the instructor's local wall clock ("Today" means their today); +// the backend filters on the resulting instants. +function presetRange(key: PresetKey): DateRange { + const now = new Date(); + switch (key) { + case "today": + return { from: startOfLocalDay(now), to: endOfLocalDay(now) }; + case "week": { + const monday = new Date(now.getTime() - ((now.getDay() + 6) % 7) * DAY_MS); + return { from: startOfLocalDay(monday), to: endOfLocalDay(now) }; + } + case "month": { + const first = new Date(now.getFullYear(), now.getMonth(), 1); + return { from: startOfLocalDay(first), to: endOfLocalDay(now) }; + } + case "4w": + return { + from: startOfLocalDay(new Date(now.getTime() - 28 * DAY_MS)), + to: endOfLocalDay(now), + }; + default: + return {}; + } +} + +/** + * Preset/custom time-range picker. Emits {from, to} ISO strings (or {} for + * all time) — the page refetches the analytics endpoint with them. + */ +export default function RangeSelector({ onChange }: { onChange: (range: DateRange) => void }) { + const [preset, setPreset] = useState("all"); + const [customFrom, setCustomFrom] = useState(""); + const [customTo, setCustomTo] = useState(""); + + const handlePreset = (key: PresetKey) => { + setPreset(key); + if (key !== "custom") onChange(presetRange(key)); + }; + + const customValid = customFrom !== "" && customTo !== "" && customFrom <= customTo; + + return ( +
+ + + + {preset === "custom" && ( + <> + setCustomFrom(e.target.value)} + className="h-9 px-2 rounded-md text-sm bg-white border border-stone-200 text-stone-700" + aria-label="From date" + /> + + setCustomTo(e.target.value)} + className="h-9 px-2 rounded-md text-sm bg-white border border-stone-200 text-stone-700" + aria-label="To date" + /> + + + )} +
+ ); +} From 1bafca0df7f46cee22c4ff6494f6c1d3885360a2 Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 05/10] feat: add analytics participant table component --- .../analytics/components/ParticipantTable.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/app/courses/[courseId]/analytics/components/ParticipantTable.tsx diff --git a/src/app/courses/[courseId]/analytics/components/ParticipantTable.tsx b/src/app/courses/[courseId]/analytics/components/ParticipantTable.tsx new file mode 100644 index 0000000..d5ebbe8 --- /dev/null +++ b/src/app/courses/[courseId]/analytics/components/ParticipantTable.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; + +export interface Participant { + userId: string; + name: string; + role: "STUDENT" | "TA" | "PROFESSOR"; + questionsAsked: number; + answersGiven: number; + upvotesReceived: number; +} + +type SortKey = "questionsAsked" | "answersGiven" | "upvotesReceived"; + +const ROLE_BADGE: Record = { + PROFESSOR: "bg-green-100 text-green-800", + TA: "bg-amber-100 text-amber-800", + STUDENT: "bg-stone-100 text-stone-600", +}; + +/** + * Contribution leaderboard, sortable by questions asked (top askers), + * answers given (top answerers), or upvotes received. TA/professor rows are + * highlighted via role badges. + */ +export default function ParticipantTable({ participants }: { participants: Participant[] }) { + const [sortKey, setSortKey] = useState("questionsAsked"); + + if (participants.length === 0) { + return

No participants yet.

; + } + + const sorted = [...participants].sort((a, b) => b[sortKey] - a[sortKey]); + + const headers: { key: SortKey; label: string }[] = [ + { key: "questionsAsked", label: "Questions" }, + { key: "answersGiven", label: "Answers" }, + { key: "upvotesReceived", label: "Upvotes" }, + ]; + + return ( +
+ + + + + + + {headers.map(({ key, label }) => ( + + ))} + + + + {sorted.map((p, i) => ( + + + + + + + + + ))} + +
#NameRole + +
{i + 1}{p.name} + + {p.role} + + {p.questionsAsked}{p.answersGiven}{p.upvotesReceived}
+
+ ); +} From 42c586a97cadae4343a03d1b6217ee207cd7e76e Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 06/10] feat: add analytics engagement chart component --- .../analytics/components/EngagementChart.tsx | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/app/courses/[courseId]/analytics/components/EngagementChart.tsx diff --git a/src/app/courses/[courseId]/analytics/components/EngagementChart.tsx b/src/app/courses/[courseId]/analytics/components/EngagementChart.tsx new file mode 100644 index 0000000..5fcf69d --- /dev/null +++ b/src/app/courses/[courseId]/analytics/components/EngagementChart.tsx @@ -0,0 +1,147 @@ +"use client"; + +export interface WeeklyPoint { + week: number; + weekStart: string; + questions: number; + answers: number; + activeUsers: number; +} + +const SLOT_W = 52; +const HEIGHT = 220; +const PAD_TOP = 12; +const PAD_BOTTOM = 24; +const BAR_W = 10; + +/** + * Weekly engagement chart (SVG, no charting dependency): grouped bars for + * questions/answers with matching trend lines, plus a dashed line for active + * users. Empty weeks render as zero-height bars so the timeline stays + * contiguous. + */ +export default function EngagementChart({ weekly }: { weekly: WeeklyPoint[] }) { + if (weekly.length === 0) { + return ( +

No activity in this time range.

+ ); + } + + const width = weekly.length * SLOT_W; + const bottom = HEIGHT - PAD_BOTTOM; + const max = Math.max(1, ...weekly.map((w) => Math.max(w.questions, w.answers, w.activeUsers))); + + const xAt = (i: number) => i * SLOT_W + SLOT_W / 2; + const yAt = (v: number) => PAD_TOP + (1 - v / max) * (bottom - PAD_TOP); + const points = (get: (w: WeeklyPoint) => number) => + weekly.map((w, i) => `${xAt(i)},${yAt(get(w))}`).join(" "); + + return ( +
+
+ + {/* Baseline */} + + + {/* Bars */} + {weekly.map((w, i) => ( + + + + + ))} + + {/* Trend lines */} + w.questions)} + fill="none" + className="stroke-green-600" + strokeWidth={2} + strokeLinejoin="round" + /> + w.answers)} + fill="none" + className="stroke-amber-500" + strokeWidth={2} + strokeLinejoin="round" + /> + w.activeUsers)} + fill="none" + className="stroke-blue-500" + strokeWidth={2} + strokeDasharray="5 4" + strokeLinejoin="round" + /> + + {/* Data dots on the lines */} + {weekly.map((w, i) => ( + + + + + + ))} + + {/* Week labels + hover tooltips */} + {weekly.map((w, i) => ( + + + W{w.week} + + + {`Week ${w.week} (${w.weekStart})\n${w.questions} questions, ${w.answers} answers, ${w.activeUsers} active users`} + + + ))} + +
+ +
+ + + Questions + + + + Answers + + + + Active users + +
+
+ ); +} From ee2d89e9b583e53a89dd6d9ddb49923e239b8e71 Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 07/10] feat: add course analytics page --- src/app/courses/[courseId]/analytics/page.tsx | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/app/courses/[courseId]/analytics/page.tsx diff --git a/src/app/courses/[courseId]/analytics/page.tsx b/src/app/courses/[courseId]/analytics/page.tsx new file mode 100644 index 0000000..5119dc2 --- /dev/null +++ b/src/app/courses/[courseId]/analytics/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, MessageSquare, MessageCircle, Users, CheckCircle2 } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +import ParticipantTable, { type Participant } from "./components/ParticipantTable"; +import EngagementChart, { type WeeklyPoint } from "./components/EngagementChart"; +import RangeSelector, { type DateRange } from "./components/RangeSelector"; + +interface Analytics { + course: { id: string; code: string; name: string }; + summary: { + totalQuestions: number; + totalAnswers: number; + activeParticipants: number; + answeredRate: number; + }; + participants: Participant[]; + weekly: WeeklyPoint[]; +} + +export default function CourseAnalyticsPage() { + const { courseId } = useParams<{ courseId: string }>(); + const [analytics, setAnalytics] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [range, setRange] = useState({}); + const [refreshing, setRefreshing] = useState(false); + + const handleRangeChange = (newRange: DateRange) => { + setRefreshing(true); + setRange(newRange); + }; + + useEffect(() => { + if (!courseId) return; + let cancelled = false; + const params = new URLSearchParams(); + if (range.from) params.set("from", range.from); + if (range.to) params.set("to", range.to); + const qs = params.toString(); + fetch(`/api/courses/${courseId}/analytics${qs ? `?${qs}` : ""}`) + .then(async (res) => { + const data = await res.json(); + if (cancelled) return; + if (!res.ok) { + setError(data.error ?? "Failed to load analytics."); + } else { + setAnalytics(data); + setError(null); + } + }) + .catch(() => { + if (!cancelled) setError("Failed to load analytics."); + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + setRefreshing(false); + } + }); + return () => { + cancelled = true; + }; + }, [courseId, range]); + + if (loading) { + return ( +
+ Loading analytics… +
+ ); + } + + if (error || !analytics) { + return ( +
+

{error ?? "Failed to load analytics."}

+ + Back to home + +
+ ); + } + + const { course, summary, participants, weekly } = analytics; + + const statCards = [ + { label: "Questions", value: summary.totalQuestions, icon: MessageSquare }, + { label: "Answers", value: summary.totalAnswers, icon: MessageCircle }, + { label: "Active Participants", value: summary.activeParticipants, icon: Users }, + { + label: "Answered Rate", + value: `${Math.round(summary.answeredRate * 100)}%`, + icon: CheckCircle2, + }, + ]; + + return ( +
+
+
+
+ + + Back to lectures + +

+ {course.code} — Engagement Analytics +

+

{course.name}

+
+ +
+ +
+
+ {statCards.map(({ label, value, icon: Icon }) => ( + + + {label} + + + +
{value}
+
+
+ ))} +
+ + + + Weekly Engagement + + + + + + + + + Participants + + + + + +
+
+
+ ); +} From 2c847887f6415b857d3c6352bee75e34b85f961e Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 08/10] feat: add Stats link to professor course viewer --- src/app/classes/ProfCourseViewer.tsx | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/app/classes/ProfCourseViewer.tsx b/src/app/classes/ProfCourseViewer.tsx index 41aa579..0428bda 100644 --- a/src/app/classes/ProfCourseViewer.tsx +++ b/src/app/classes/ProfCourseViewer.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { Settings, + BarChart3, BookOpen, Calendar, PlusCircle, @@ -282,13 +283,25 @@ export default function ProfCourseButtons() { {isStarting ? "Starting..." : "Start Live Session"} - +
+ + +
)} From a74da24544f10ea9bad4070b238c42162dfff40b Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 09/10] test: add analytics validation unit tests --- src/__tests__/analytics-validation.test.ts | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/__tests__/analytics-validation.test.ts diff --git a/src/__tests__/analytics-validation.test.ts b/src/__tests__/analytics-validation.test.ts new file mode 100644 index 0000000..21eea1b --- /dev/null +++ b/src/__tests__/analytics-validation.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { validateAnalyticsRange } from "@/lib/analyticsValidation"; + +describe("validateAnalyticsRange", () => { + it("accepts absent params", () => { + const result = validateAnalyticsRange(null, null); + expect(result.valid).toBe(true); + expect(result.from).toBeUndefined(); + expect(result.to).toBeUndefined(); + }); + + it("parses valid ISO dates", () => { + const result = validateAnalyticsRange("2026-01-05", "2026-04-06", "2026-01-05"); + expect(result.valid).toBe(true); + expect(result.from?.toISOString()).toBe("2026-01-05T00:00:00.000Z"); + expect(result.to?.toISOString()).toBe("2026-04-06T00:00:00.000Z"); + expect(result.weekStart?.toISOString()).toBe("2026-01-05T00:00:00.000Z"); + }); + + it("accepts from without to", () => { + const result = validateAnalyticsRange("2026-01-05", null); + expect(result.valid).toBe(true); + expect(result.from).toBeDefined(); + }); + + it("rejects malformed dates", () => { + expect(validateAnalyticsRange("not-a-date", null).valid).toBe(false); + expect(validateAnalyticsRange(null, "13/13/2026x").valid).toBe(false); + expect(validateAnalyticsRange(null, null, "nope").valid).toBe(false); + }); + + it("rejects from after to", () => { + const result = validateAnalyticsRange("2026-04-06", "2026-01-05"); + expect(result.valid).toBe(false); + expect(result.error).toBe("from must be before or equal to to."); + }); + + it("accepts from equal to to", () => { + expect(validateAnalyticsRange("2026-01-05", "2026-01-05").valid).toBe(true); + }); +}); From 573911b9401c809bb840a1bfef9d8c0996805a25 Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:05:40 -0400 Subject: [PATCH 10/10] test: add course analytics integration tests --- src/__tests__/course-analytics.test.ts | 284 +++++++++++++++++++++++++ vitest.config.mts | 1 + vitest.integration.config.mts | 6 +- 3 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/course-analytics.test.ts diff --git a/src/__tests__/course-analytics.test.ts b/src/__tests__/course-analytics.test.ts new file mode 100644 index 0000000..f0ec4b8 --- /dev/null +++ b/src/__tests__/course-analytics.test.ts @@ -0,0 +1,284 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { PrismaClient } from "../generated/prisma"; +import { + canViewCourseAnalytics, + getCourseAnalytics, + startOfIsoWeek, + fillWeeklyBuckets, +} from "@/lib/courseAnalytics"; + +const prisma = new PrismaClient(); + +beforeAll(async () => { + await prisma.$connect(); +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); + +beforeEach(async () => { + // Clean all tables in dependency order + await prisma.answerUpvote.deleteMany(); + await prisma.questionUpvote.deleteMany(); + await prisma.answer.deleteMany(); + await prisma.question.deleteMany(); + await prisma.slideSet.deleteMany(); + await prisma.session.deleteMany(); + await prisma.courseEnrollment.deleteMany(); + await prisma.course.deleteMany(); + await prisma.user.deleteMany(); +}); + +// Fixed Mondays for stable week bucketing +const WEEK1 = new Date("2026-01-05T10:00:00.000Z"); +const WEEK3 = new Date("2026-01-19T10:00:00.000Z"); + +/** + * Seeds a course with: + * - prof (PROFESSOR), ta (TA), student1/student2 (STUDENT) + * - two sessions (weeks 1 and 3) plus an unrelated second course + * - 3 questions in the course (2 by student1 in week 1, one anonymous; + * 1 by student2 in week 3) with statuses RESOLVED/ANSWERED/OPEN + * - 3 answers (2 by ta, 1 by prof) + * - 1 question in the other course that must never leak in + */ +async function seed() { + const [prof, ta, student1, student2, outsider] = await Promise.all( + ["prof01", "ta01", "stu01", "stu02", "out01"].map((utorid, i) => + prisma.user.create({ + data: { + utorid, + email: `${utorid}@utoronto.ca`, + name: `User ${i + 1}`, + role: "STUDENT", + }, + }) + ) + ); + + const course = await prisma.course.create({ + data: { code: "CSC209", name: "Systems Programming", semester: "W26", createdById: prof.id }, + }); + const otherCourse = await prisma.course.create({ + data: { code: "CSC343", name: "Databases", semester: "W26", createdById: prof.id }, + }); + + await prisma.courseEnrollment.createMany({ + data: [ + { userId: prof.id, courseId: course.id, role: "PROFESSOR" }, + { userId: ta.id, courseId: course.id, role: "TA" }, + { userId: student1.id, courseId: course.id, role: "STUDENT" }, + { userId: student2.id, courseId: course.id, role: "STUDENT" }, + { userId: outsider.id, courseId: otherCourse.id, role: "STUDENT" }, + ], + }); + + const session1 = await prisma.session.create({ + data: { + courseId: course.id, + createdById: prof.id, + title: "Lecture 1", + joinCode: "AAA111", + status: "ENDED", + startTime: WEEK1, + createdAt: WEEK1, + }, + }); + const session2 = await prisma.session.create({ + data: { + courseId: course.id, + createdById: prof.id, + title: "Lecture 3", + joinCode: "BBB222", + status: "ENDED", + startTime: WEEK3, + createdAt: WEEK3, + }, + }); + const otherSession = await prisma.session.create({ + data: { + courseId: otherCourse.id, + createdById: prof.id, + title: "Other", + joinCode: "CCC333", + status: "ENDED", + createdAt: WEEK1, + }, + }); + + const q1 = await prisma.question.create({ + data: { + sessionId: session1.id, + authorId: student1.id, + content: "What is a pointer?", + status: "RESOLVED", + upvoteCount: 3, + createdAt: WEEK1, + }, + }); + // Anonymous — must still be attributed to student1 in aggregate stats + const q2 = await prisma.question.create({ + data: { + sessionId: session1.id, + authorId: student1.id, + content: "Why does fork() return twice?", + isAnonymous: true, + status: "ANSWERED", + upvoteCount: 1, + createdAt: WEEK1, + }, + }); + await prisma.question.create({ + data: { + sessionId: session2.id, + authorId: student2.id, + content: "How do pipes work?", + status: "OPEN", + upvoteCount: 0, + createdAt: WEEK3, + }, + }); + // Question in another course — must not appear in this course's analytics + await prisma.question.create({ + data: { + sessionId: otherSession.id, + authorId: outsider.id, + content: "What is 3NF?", + createdAt: WEEK1, + }, + }); + + await prisma.answer.createMany({ + data: [ + { + questionId: q1.id, + authorId: ta.id, + content: "A memory address.", + upvoteCount: 2, + createdAt: WEEK1, + }, + { + questionId: q2.id, + authorId: ta.id, + content: "Once in each process.", + createdAt: WEEK1, + }, + { + questionId: q1.id, + authorId: prof.id, + content: "See lecture 2 slides.", + createdAt: WEEK3, + }, + ], + }); + + return { prof, ta, student1, student2, outsider, course, otherCourse }; +} + +describe("canViewCourseAnalytics", () => { + it("allows enrolled professors and TAs, rejects students and non-members", async () => { + const { prof, ta, student1, outsider, course } = await seed(); + expect(await canViewCourseAnalytics(prof.id, course.id)).toBe(true); + expect(await canViewCourseAnalytics(ta.id, course.id)).toBe(true); + expect(await canViewCourseAnalytics(student1.id, course.id)).toBe(false); + expect(await canViewCourseAnalytics(outsider.id, course.id)).toBe(false); + }); +}); + +describe("getCourseAnalytics", () => { + it("computes summary totals scoped to the course's sessions", async () => { + const { course } = await seed(); + const { summary } = await getCourseAnalytics(course); + + expect(summary.totalQuestions).toBe(3); // other course's question excluded + expect(summary.totalAnswers).toBe(3); + expect(summary.activeParticipants).toBe(4); // student1, student2, ta, prof + expect(summary.answeredRate).toBeCloseTo(2 / 3); // RESOLVED + ANSWERED of 3 + }); + + it("breaks down participants with role tags and real anonymous attribution", async () => { + const { course, student1, ta, prof } = await seed(); + const { participants } = await getCourseAnalytics(course); + + const s1 = participants.find((p) => p.userId === student1.id); + expect(s1).toMatchObject({ role: "STUDENT", questionsAsked: 2, answersGiven: 0 }); + expect(s1?.upvotesReceived).toBe(4); // 3 + 1, anonymous question included + + const taRow = participants.find((p) => p.userId === ta.id); + expect(taRow).toMatchObject({ role: "TA", questionsAsked: 0, answersGiven: 2 }); + expect(taRow?.upvotesReceived).toBe(2); + + const profRow = participants.find((p) => p.userId === prof.id); + expect(profRow).toMatchObject({ role: "PROFESSOR", answersGiven: 1 }); + }); + + it("buckets weekly engagement contiguously, reporting empty weeks as 0", async () => { + const { course } = await seed(); + const { weekly } = await getCourseAnalytics(course); + + expect(weekly.map((w) => w.week)).toEqual([1, 2, 3]); + expect(weekly[0]).toMatchObject({ weekStart: "2026-01-05", questions: 2, answers: 2 }); + expect(weekly[1]).toMatchObject({ weekStart: "2026-01-12", questions: 0, answers: 0 }); + expect(weekly[2]).toMatchObject({ weekStart: "2026-01-19", questions: 1, answers: 1 }); + expect(weekly[0].activeUsers).toBe(2); // student1 + ta + }); + + it("filters by from/to date range", async () => { + const { course } = await seed(); + const { summary, participants } = await getCourseAnalytics(course, { + from: new Date("2026-01-15T00:00:00.000Z"), + }); + + expect(summary.totalQuestions).toBe(1); // only the week-3 question + expect(summary.totalAnswers).toBe(1); // only the prof's week-3 answer + expect(participants.some((p) => p.questionsAsked === 2)).toBe(false); + }); + + it("returns an empty payload for a course with no sessions", async () => { + const { prof } = await seed(); + const bare = await prisma.course.create({ + data: { code: "CSC108", name: "Intro", semester: "W26", createdById: prof.id }, + }); + const analytics = await getCourseAnalytics(bare); + + expect(analytics.summary.totalQuestions).toBe(0); + expect(analytics.participants).toEqual([]); + expect(analytics.weekly).toEqual([]); + }); +}); + +describe("week bucketing helpers", () => { + it("startOfIsoWeek returns the Monday of the containing week", () => { + expect(startOfIsoWeek(new Date("2026-01-07T15:30:00.000Z")).toISOString()).toBe( + "2026-01-05T00:00:00.000Z" + ); + // Sunday belongs to the week starting the previous Monday + expect(startOfIsoWeek(new Date("2026-01-11T23:00:00.000Z")).toISOString()).toBe( + "2026-01-05T00:00:00.000Z" + ); + expect(startOfIsoWeek(new Date("2026-01-05T00:00:00.000Z")).toISOString()).toBe( + "2026-01-05T00:00:00.000Z" + ); + }); + + it("fillWeeklyBuckets numbers weeks from the anchor and fills gaps", () => { + const rows = [ + { weekStart: new Date("2026-01-12T00:00:00.000Z"), questions: 1, answers: 0, activeUsers: 1 }, + { weekStart: new Date("2026-01-26T00:00:00.000Z"), questions: 2, answers: 3, activeUsers: 2 }, + ]; + const weekly = fillWeeklyBuckets(rows, new Date("2026-01-05T00:00:00.000Z")); + + expect(weekly.map((w) => [w.week, w.weekStart, w.questions])).toEqual([ + [1, "2026-01-05", 0], + [2, "2026-01-12", 1], + [3, "2026-01-19", 0], + [4, "2026-01-26", 2], + ]); + }); + + it("fillWeeklyBuckets returns empty for no data", () => { + expect(fillWeeklyBuckets([], new Date("2026-01-05T00:00:00.000Z"))).toEqual([]); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 7fea523..3be5af5 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -11,6 +11,7 @@ export default defineConfig({ "**/dist/**", "src/__tests__/prisma-schema.test.ts", "src/__tests__/session-join.test.ts", + "src/__tests__/course-analytics.test.ts", ], }, }); diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 21d325b..2b0056b 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -5,7 +5,11 @@ export default defineConfig({ plugins: [tsconfigPaths()], test: { environment: "node", - include: ["src/__tests__/prisma-schema.test.ts", "src/__tests__/session-join.test.ts"], + include: [ + "src/__tests__/prisma-schema.test.ts", + "src/__tests__/session-join.test.ts", + "src/__tests__/course-analytics.test.ts", + ], // Run test files sequentially to avoid database conflicts fileParallelism: false, // Run tests within each file sequentially