diff --git a/codebenders-dashboard/app/api/courses/dfwi/route.ts b/codebenders-dashboard/app/api/courses/dfwi/route.ts new file mode 100644 index 0000000..b077d75 --- /dev/null +++ b/codebenders-dashboard/app/api/courses/dfwi/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/dfwi", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + + const gatewayOnly = searchParams.get("gatewayOnly") === "true" + const minEnrollments = Math.max(1, Number(searchParams.get("minEnrollments") || 10)) + const cohort = searchParams.get("cohort") || "" + const term = searchParams.get("term") || "" + const sortBy = searchParams.get("sortBy") || "dfwi_rate" + const sortDir = searchParams.get("sortDir") === "asc" ? "ASC" : "DESC" + + // Whitelist sort columns to prevent injection + const SORT_COLS: Record = { + dfwi_rate: "dfwi_rate", + enrollments: "enrollments", + } + const orderExpr = SORT_COLS[sortBy] ?? "dfwi_rate" + + const conditions: string[] = [] + const params: unknown[] = [] + + if (gatewayOnly) { + conditions.push("gateway_type IN ('M', 'E')") + } + + if (cohort) { + params.push(cohort) + conditions.push(`cohort = $${params.length}`) + } + + if (term) { + params.push(term) + conditions.push(`academic_term = $${params.length}`) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // minEnrollments goes into HAVING — bind as a param + params.push(minEnrollments) + const minEnrollmentsParam = `$${params.length}` + + const sql = ` + SELECT + course_prefix, + course_number, + MAX(course_name) AS course_name, + MAX(gateway_type) AS gateway_type, + COUNT(*) AS enrollments, + COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) AS dfwi_count, + ROUND( + COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS dfwi_rate, + ROUND( + COUNT(*) FILTER ( + WHERE grade NOT IN ('D', 'F', 'W', 'I') + AND grade IS NOT NULL + AND grade != '' + ) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS pass_rate + FROM course_enrollments + ${where} + GROUP BY course_prefix, course_number + HAVING COUNT(*) >= ${minEnrollmentsParam} + ORDER BY ${orderExpr} ${sortDir} + LIMIT 200 -- capped at 200 rows; add pagination if needed + ` + + try { + const pool = getPool() + const result = await pool.query(sql, params) + + return NextResponse.json({ + courses: result.rows, + total: result.rows.length, + }) + } catch (error) { + console.error("DFWI fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch DFWI data", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/explain-pairing/route.ts b/codebenders-dashboard/app/api/courses/explain-pairing/route.ts new file mode 100644 index 0000000..fc560c0 --- /dev/null +++ b/codebenders-dashboard/app/api/courses/explain-pairing/route.ts @@ -0,0 +1,209 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" +import { generateText } from "ai" +import { createOpenAI } from "@ai-sdk/openai" + +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY || "" }) + +const DELIVERY_LABELS: Record = { + F: "Face-to-Face", + O: "Online", + H: "Hybrid", +} + +export async function POST(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/explain-pairing", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + if (!process.env.OPENAI_API_KEY) { + return NextResponse.json({ error: "OpenAI API key not configured" }, { status: 500 }) + } + + const body = await request.json() + const { prefix_a, number_a, name_a, prefix_b, number_b, name_b } = body + + if (!prefix_a || !number_a || !prefix_b || !number_b) { + return NextResponse.json({ error: "Missing course identifiers" }, { status: 400 }) + } + + const pool = getPool() + + try { + // Query 1: Individual DFWI + pass rates for each course + const [indivRes, deliveryRes, instrRes] = await Promise.all([ + pool.query( + `SELECT + course_prefix, + course_number, + COUNT(*) AS enrollments, + ROUND( + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '') + * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS dfwi_rate, + ROUND( + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '') + * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS pass_rate + FROM course_enrollments + WHERE (course_prefix = $1 AND course_number = $2) + OR (course_prefix = $3 AND course_number = $4) + GROUP BY course_prefix, course_number`, + [prefix_a, number_a, prefix_b, number_b], + ), + + // Query 2: Co-enrollment stats by delivery method + pool.query( + `SELECT + a.delivery_method, + COUNT(*) AS co_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + WHERE a.course_prefix = $1 AND a.course_number = $2 + AND b.course_prefix = $3 AND b.course_number = $4 + GROUP BY a.delivery_method + ORDER BY co_count DESC`, + [prefix_a, number_a, prefix_b, number_b], + ), + + // Query 3: Co-enrollment stats by instructor status + pool.query( + `SELECT + a.instructor_status, + COUNT(*) AS co_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + WHERE a.course_prefix = $1 AND a.course_number = $2 + AND b.course_prefix = $3 AND b.course_number = $4 + GROUP BY a.instructor_status + ORDER BY co_count DESC`, + [prefix_a, number_a, prefix_b, number_b], + ), + ]) + + const courseA = indivRes.rows.find( + r => r.course_prefix === prefix_a && r.course_number === number_a, + ) + const courseB = indivRes.rows.find( + r => r.course_prefix === prefix_b && r.course_number === number_b, + ) + + const byDelivery = deliveryRes.rows.map(r => ({ + delivery_method: DELIVERY_LABELS[r.delivery_method] ?? r.delivery_method ?? "Unknown", + co_count: Number(r.co_count), + both_pass_rate: parseFloat(r.both_pass_rate), + })) + + const byInstructor = instrRes.rows.map(r => ({ + instructor_status: + r.instructor_status === "FT" + ? "Full-Time Instructor" + : r.instructor_status === "PT" + ? "Part-Time Instructor" + : (r.instructor_status ?? "Unknown"), + co_count: Number(r.co_count), + both_pass_rate: parseFloat(r.both_pass_rate), + })) + + const stats = { + courseA: courseA + ? { + dfwi_rate: parseFloat(courseA.dfwi_rate), + pass_rate: parseFloat(courseA.pass_rate), + enrollments: Number(courseA.enrollments), + } + : null, + courseB: courseB + ? { + dfwi_rate: parseFloat(courseB.dfwi_rate), + pass_rate: parseFloat(courseB.pass_rate), + enrollments: Number(courseB.enrollments), + } + : null, + byDelivery, + byInstructor, + } + + // Build prompt context + const labelA = `${prefix_a} ${number_a}${name_a ? ` (${name_a})` : ""}` + const labelB = `${prefix_b} ${number_b}${name_b ? ` (${name_b})` : ""}` + + const statsLineA = courseA + ? `${labelA}: ${courseA.dfwi_rate}% DFWI, ${courseA.pass_rate}% pass rate, ${Number(courseA.enrollments).toLocaleString()} total enrollments` + : `${labelA}: no individual stats available` + + const statsLineB = courseB + ? `${labelB}: ${courseB.dfwi_rate}% DFWI, ${courseB.pass_rate}% pass rate, ${Number(courseB.enrollments).toLocaleString()} total enrollments` + : `${labelB}: no individual stats available` + + const deliverySection = byDelivery.length + ? byDelivery + .map(d => ` ${d.delivery_method}: ${d.co_count} co-enrolled, ${d.both_pass_rate}% both passed`) + .join("\n") + : " No delivery breakdown available" + + const instrSection = byInstructor.length + ? byInstructor + .map(i => ` ${i.instructor_status}: ${i.co_count} co-enrolled, ${i.both_pass_rate}% both passed`) + .join("\n") + : " No instructor breakdown available" + + const llmPrompt = `You are an academic success analyst at a community college. An advisor is reviewing co-enrollment data for two courses students frequently take in the same term. + +INDIVIDUAL COURSE STATS: +- ${statsLineA} +- ${statsLineB} + +CO-ENROLLMENT BREAKDOWN (students taking both courses in the same term): + +By delivery method: +${deliverySection} + +By instructor type: +${instrSection} + +Write a concise analysis (3-4 sentences) that: +1. Explains why students might struggle when taking both courses together (consider workload, cognitive load, prerequisite overlap, or scheduling demands) +2. Highlights which conditions show better or worse outcomes based on the data +3. Ends with one specific, actionable recommendation for advisors + +Be practical and data-driven. Do not speculate beyond what the numbers show.` + + const result = await generateText({ + model: openai("gpt-4o-mini"), + prompt: llmPrompt, + maxOutputTokens: 320, + }) + + return NextResponse.json({ stats, explanation: result.text }) + } catch (error) { + console.error("[explain-pairing] Error:", error) + return NextResponse.json( + { + error: "Failed to generate explanation", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts b/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts new file mode 100644 index 0000000..eae069c --- /dev/null +++ b/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/gateway-funnel", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const mathSql = ` + SELECT + cohort, + COUNT(*) AS attempted, + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') + AND grade IS NOT NULL AND grade <> '') AS passed, + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi + FROM course_enrollments + WHERE gateway_type = 'M' + GROUP BY cohort + ORDER BY cohort + ` + + const englishSql = ` + SELECT + cohort, + COUNT(*) AS attempted, + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') + AND grade IS NOT NULL AND grade <> '') AS passed, + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi + FROM course_enrollments + WHERE gateway_type = 'E' + GROUP BY cohort + ORDER BY cohort + ` + + try { + const pool = getPool() + const [mathResult, englishResult] = await Promise.all([ + pool.query(mathSql), + pool.query(englishSql), + ]) + + return NextResponse.json({ + math: mathResult.rows, + english: englishResult.rows, + }) + } catch (error) { + console.error("Gateway funnel fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch gateway funnel data", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/sequences/route.ts b/codebenders-dashboard/app/api/courses/sequences/route.ts new file mode 100644 index 0000000..e9b418b --- /dev/null +++ b/codebenders-dashboard/app/api/courses/sequences/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/sequences", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const sql = ` + SELECT + a.course_prefix AS prefix_a, + a.course_number AS number_a, + b.course_prefix AS prefix_b, + b.course_number AS number_b, + MAX(a.course_name) AS name_a, + MAX(b.course_name) AS name_b, + COUNT(*) AS co_enrollment_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + AND ( + a.course_prefix < b.course_prefix + OR (a.course_prefix = b.course_prefix AND a.course_number < b.course_number) + ) + GROUP BY a.course_prefix, a.course_number, b.course_prefix, b.course_number + HAVING COUNT(*) >= 20 + ORDER BY co_enrollment_count DESC + LIMIT 20 + ` + + try { + const pool = getPool() + const result = await pool.query(sql) + + return NextResponse.json({ + pairs: result.rows, + }) + } catch (error) { + console.error("Course sequences fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch course sequences", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/courses/page.tsx b/codebenders-dashboard/app/courses/page.tsx new file mode 100644 index 0000000..42eea7f --- /dev/null +++ b/codebenders-dashboard/app/courses/page.tsx @@ -0,0 +1,732 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowDown, ArrowLeft, ArrowUp, ArrowUpDown, Loader2, Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" +import { InfoPopover } from "@/components/info-popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, +} from "recharts" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface CourseRow { + course_prefix: string + course_number: string + course_name: string + gateway_type: string | null + enrollments: number + dfwi_count: number + dfwi_rate: number + pass_rate: number +} + +interface CoursesResponse { + courses: CourseRow[] + total: number +} + +interface FunnelCohort { + cohort: string + attempted: number + passed: number + dfwi: number +} + +interface GatewayFunnelResponse { + math: FunnelCohort[] + english: FunnelCohort[] +} + +interface CoursePair { + prefix_a: string + number_a: string + name_a: string + prefix_b: string + number_b: string + name_b: string + co_enrollment_count: number + both_pass_rate: number +} + +interface SequencesResponse { + pairs: CoursePair[] +} + +interface PairStats { + courseA: { dfwi_rate: number; pass_rate: number; enrollments: number } | null + courseB: { dfwi_rate: number; pass_rate: number; enrollments: number } | null + byDelivery: { delivery_method: string; co_count: number; both_pass_rate: number }[] + byInstructor: { instructor_status: string; co_count: number; both_pass_rate: number }[] +} + +interface ExplainState { + loading: boolean + stats?: PairStats + explanation?: string + error?: string +} + +// ─── Color helpers ──────────────────────────────────────────────────────────── + +function DfwiRate({ value }: { value: number }) { + const v = parseFloat(String(value)) + const pct = v.toFixed(1) + let color = "text-green-600" + if (v >= 50) color = "text-red-600" + else if (v >= 30) color = "text-orange-600" + return {pct}% +} + +function PassRate({ value }: { value: number }) { + const v = parseFloat(String(value)) + const pct = v.toFixed(1) + let color = "text-red-600" + if (v >= 70) color = "text-green-600" + else if (v >= 50) color = "text-yellow-600" + return {pct}% +} + +function GatewayTypeLabel({ type }: { type: string | null }) { + if (!type) return + if (type === "M") return Math Gateway + if (type === "E") return English Gateway + return {type} +} + +// ─── Table header helpers ───────────────────────────────────────────────────── + +function Th({ label, right, info }: { label: string; right?: boolean; info?: React.ReactNode }) { + return ( + + + {label}{info} + + + ) +} + +function SortIcon({ active, dir }: { active: boolean; dir: "asc" | "desc" }) { + if (!active) return + return dir === "asc" + ? + : +} + +function ThSort({ + label, col, sortBy, sortDir, onSort, right, info, +}: { + label: string; col: T; sortBy: T; sortDir: "asc" | "desc" + onSort: (col: T) => void; right?: boolean; info?: React.ReactNode +}) { + return ( + + + + {info} + + + ) +} + +// ─── Stat chip ──────────────────────────────────────────────────────────────── + +function StatChip({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+ {value} + {label} +
+ ) +} + +// ─── Tab helpers ────────────────────────────────────────────────────────────── + +type Tab = "dfwi" | "funnel" | "sequences" + +function TabButton({ id, label, active, onClick }: { id: Tab; label: string; active: boolean; onClick: (t: Tab) => void }) { + return ( + + ) +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function CoursesPage() { + // ── Tab state ── + const [activeTab, setActiveTab] = useState("dfwi") + + // ── DFWI table state ── + const [coursesData, setCoursesData] = useState(null) + const [coursesLoading, setCoursesLoading] = useState(true) + const [coursesError, setCoursesError] = useState(null) + + // ── Funnel state ── + const [funnelData, setFunnelData] = useState(null) + const [funnelLoading, setFunnelLoading] = useState(true) + const [funnelError, setFunnelError] = useState(null) + + // ── Sequences state ── + const [seqData, setSeqData] = useState(null) + const [seqLoading, setSeqLoading] = useState(true) + const [seqError, setSeqError] = useState(null) + + // ── Explain state (keyed by pairing key) ── + const [explainMap, setExplainMap] = useState>({}) + + // ── DFWI table filters + sort ── + const [gatewayOnly, setGatewayOnly] = useState(false) + const [minEnrollments, setMinEnrollments] = useState("10") + const [sortBy, setSortBy] = useState<"dfwi_rate" | "enrollments">("dfwi_rate") + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc") + + // ── Pairings client-side sort ── + const [pairSortBy, setPairSortBy] = useState<"co_enrollment_count" | "both_pass_rate">("co_enrollment_count") + const [pairSortDir, setPairSortDir] = useState<"asc" | "desc">("desc") + + // ── Fetch DFWI courses ── + useEffect(() => { + setCoursesLoading(true) + setCoursesError(null) + const p = new URLSearchParams() + p.set("gatewayOnly", String(gatewayOnly)) + p.set("minEnrollments", minEnrollments) + p.set("sortBy", sortBy) + p.set("sortDir", sortDir) + fetch(`/api/courses/dfwi?${p.toString()}`) + .then(r => r.json()) + .then(d => { setCoursesData(d); setCoursesLoading(false) }) + .catch(e => { setCoursesError(e.message); setCoursesLoading(false) }) + }, [gatewayOnly, minEnrollments, sortBy, sortDir]) + + // ── Fetch gateway funnel ── + useEffect(() => { + setFunnelLoading(true) + setFunnelError(null) + fetch("/api/courses/gateway-funnel") + .then(r => r.json()) + .then(d => { setFunnelData(d); setFunnelLoading(false) }) + .catch(e => { setFunnelError(e.message); setFunnelLoading(false) }) + }, []) + + // ── Fetch sequences ── + useEffect(() => { + setSeqLoading(true) + setSeqError(null) + fetch("/api/courses/sequences") + .then(r => r.json()) + .then(d => { setSeqData(d); setSeqLoading(false) }) + .catch(e => { setSeqError(e.message); setSeqLoading(false) }) + }, []) + + const courses = coursesData?.courses ?? [] + const total = coursesData?.total ?? 0 + const mathData = funnelData?.math ?? [] + const englishData = funnelData?.english ?? [] + + // ── Sort handlers ── + function handleCourseSort(col: "dfwi_rate" | "enrollments") { + if (col === sortBy) setSortDir(d => d === "asc" ? "desc" : "asc") + else { setSortBy(col); setSortDir("desc") } + } + + function handlePairSort(col: "co_enrollment_count" | "both_pass_rate") { + if (col === pairSortBy) setPairSortDir(d => d === "asc" ? "desc" : "asc") + else { setPairSortBy(col); setPairSortDir("desc") } + } + + const sortedPairs = useMemo(() => { + const raw = (seqData?.pairs ?? []).slice(0, 20) + return [...raw].sort((a, b) => { + const av = parseFloat(String(a[pairSortBy])) + const bv = parseFloat(String(b[pairSortBy])) + return pairSortDir === "desc" ? bv - av : av - bv + }) + }, [seqData, pairSortBy, pairSortDir]) + + // ── Explain pairing ── + function pairingKey(pair: CoursePair) { + return `${pair.prefix_a}-${pair.number_a}-${pair.prefix_b}-${pair.number_b}` + } + + async function explainPairing(pair: CoursePair) { + const key = pairingKey(pair) + // Toggle collapse if already loaded + if (explainMap[key] && !explainMap[key].loading) { + setExplainMap(prev => { + const next = { ...prev } + delete next[key] + return next + }) + return + } + setExplainMap(prev => ({ ...prev, [key]: { loading: true } })) + try { + const res = await fetch("/api/courses/explain-pairing", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prefix_a: pair.prefix_a, + number_a: pair.number_a, + name_a: pair.name_a || "", + prefix_b: pair.prefix_b, + number_b: pair.number_b, + name_b: pair.name_b || "", + }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || "Failed to fetch explanation") + setExplainMap(prev => ({ + ...prev, + [key]: { loading: false, stats: data.stats, explanation: data.explanation }, + })) + } catch (e) { + setExplainMap(prev => ({ + ...prev, + [key]: { loading: false, error: e instanceof Error ? e.message : String(e) }, + })) + } + } + + // ─── Render ─────────────────────────────────────────────────────────────── + + return ( +
+
+ + {/* Header */} +
+ + + +
+

Course Analytics

+

+ DFWI rates, gateway funnels, and course co-enrollment patterns +

+
+
+ + {/* Tab bar */} +
+ + + +
+ + {/* ── Tab: DFWI Rates ── */} + {activeTab === "dfwi" && ( +
+ {/* Filter bar */} +
+
+ +
+ +
+
+
+ +

+ {coursesLoading ? "Loading…" : `${total.toLocaleString()} course${total !== 1 ? "s" : ""}`} +

+ + {coursesError && ( +
+ {coursesError} +
+ )} + +
+ + + + + + + {coursesLoading ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((__, j) => ( + + ))} + + )) + ) : courses.length === 0 ? ( + + + + ) : ( + courses.map((c, idx) => ( + + + + + + + + + + )) + )} + +
+ + + + + +

Percentage of enrolled students who received a D, F, W (Withdraw), or I (Incomplete) grade. Higher values indicate courses where students struggle most.

+ + } + /> + +

Percentage of enrolled students who received a passing grade (A through C-). A pass rate below 50% signals a course where more than half of students are not succeeding.

+ + } + /> +
+
+
+ No courses match the current filters. +
+ {c.course_prefix} {c.course_number} + {c.course_name ?? "—"}{c.enrollments.toLocaleString()}{c.dfwi_count.toLocaleString()}
+
+
+ )} + + {/* ── Tab: Gateway Funnel ── */} + {activeTab === "funnel" && ( +
+

+ Enrollment, pass, and DFWI counts for gateway courses, broken down by cohort. +

+ + {funnelError && ( +
+ {funnelError} +
+ )} + + {funnelLoading ? ( +
+ {[0, 1].map(i => ( +
+
+
+
+ ))} +
+ ) : ( +
+
+

Math Gateway

+ {mathData.length === 0 ? ( +

No data available.

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

English Gateway

+ {englishData.length === 0 ? ( +

No data available.

+ ) : ( + + + + + + + + + + + + )} +
+
+ )} +
+ )} + + {/* ── Tab: Co-enrollment Insights ── */} + {activeTab === "sequences" && ( +
+

+ Course pairs most frequently taken in the same term. Click Explain on any row for an AI-powered analysis of why students struggle with the combination. +

+ + {seqError && ( +
+ {seqError} +
+ )} + +
+ + + + + + + {seqLoading ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((__, j) => ( + + ))} + + )) + ) : sortedPairs.length === 0 ? ( + + + + ) : ( + sortedPairs.map(pair => { + const key = pairingKey(pair) + const explainState = explainMap[key] + const isExpanded = !!explainState && !explainState.loading + + return ( + <> + + + + + + + + + {/* Expanded explain panel */} + {isExpanded && ( + + + + )} + + ) + }) + )} + +
+ + + + +
+
+
+ No course pairing data available. +
+ {pair.prefix_a} {pair.number_a} + {pair.name_a && ( + — {pair.name_a} + )} + + {pair.prefix_b} {pair.number_b} + {pair.name_b && ( + — {pair.name_b} + )} + {pair.co_enrollment_count.toLocaleString()} + +
+ {explainState.error ? ( +

{explainState.error}

+ ) : ( +
+ {/* Individual course stats */} +
+ {explainState.stats?.courseA && ( +
+

+ {pair.prefix_a} {pair.number_a} — Individual +

+
+ = 50 ? "text-red-600" : explainState.stats.courseA.dfwi_rate >= 30 ? "text-orange-600" : "text-green-600"} + /> + = 70 ? "text-green-600" : explainState.stats.courseA.pass_rate >= 50 ? "text-yellow-600" : "text-red-600"} + /> + +
+
+ )} + {explainState.stats?.courseB && ( +
+

+ {pair.prefix_b} {pair.number_b} — Individual +

+
+ = 50 ? "text-red-600" : explainState.stats.courseB.dfwi_rate >= 30 ? "text-orange-600" : "text-green-600"} + /> + = 70 ? "text-green-600" : explainState.stats.courseB.pass_rate >= 50 ? "text-yellow-600" : "text-red-600"} + /> + +
+
+ )} +
+ + {/* Delivery + instructor breakdown */} +
+ {explainState.stats?.byDelivery && explainState.stats.byDelivery.length > 0 && ( +
+

By Delivery Method

+
+ {explainState.stats.byDelivery.map(d => ( +
+ {d.delivery_method} + {d.co_count.toLocaleString()} students + + both pass +
+ ))} +
+
+ )} + {explainState.stats?.byInstructor && explainState.stats.byInstructor.length > 0 && ( +
+

By Instructor Type

+
+ {explainState.stats.byInstructor.map(d => ( +
+ {d.instructor_status} + {d.co_count.toLocaleString()} students + + both pass +
+ ))} +
+
+ )} +
+ + {/* LLM explanation */} + {explainState.explanation && ( +
+ +

{explainState.explanation}

+
+ )} +
+ )} +
+
+
+ )} + +
+
+ ) +} diff --git a/codebenders-dashboard/components/nav-header.tsx b/codebenders-dashboard/components/nav-header.tsx index aa3ea14..834681e 100644 --- a/codebenders-dashboard/components/nav-header.tsx +++ b/codebenders-dashboard/components/nav-header.tsx @@ -1,5 +1,7 @@ "use client" +import Link from "next/link" +import { usePathname } from "next/navigation" import { GraduationCap, LogOut } from "lucide-react" import { Button } from "@/components/ui/button" import { signOut } from "@/app/actions/auth" @@ -10,7 +12,15 @@ interface NavHeaderProps { role: Role } +const NAV_LINKS = [ + { href: "/", label: "Dashboard" }, + { href: "/courses", label: "Courses" }, + { href: "/students", label: "Students" }, +] + export function NavHeader({ email, role }: NavHeaderProps) { + const pathname = usePathname() + return (
@@ -21,6 +31,26 @@ export function NavHeader({ email, role }: NavHeaderProps) { Bishop State SSA
+ {/* Nav links */} + + {/* Right side: role badge + email + logout */}
= [ { prefix: "/students", roles: ["admin", "advisor", "ir"] }, + { prefix: "/courses", roles: ["admin", "advisor", "ir", "faculty"] }, { prefix: "/query", roles: ["admin", "advisor", "ir", "faculty"] }, { prefix: "/api/students", roles: ["admin", "advisor", "ir"] }, + { prefix: "/api/courses", roles: ["admin", "advisor", "ir", "faculty"] }, { prefix: "/api/query-history/export", roles: ["admin", "ir"] }, ] diff --git a/codebenders-dashboard/package.json b/codebenders-dashboard/package.json index 9628cab..c9fdbf5 100644 --- a/codebenders-dashboard/package.json +++ b/codebenders-dashboard/package.json @@ -3,7 +3,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint ." }, "dependencies": { "@ai-sdk/openai": "^2.0.56", @@ -34,6 +34,8 @@ "@types/pg": "^8.16.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", + "eslint": "^9.39.3", + "eslint-config-next": "^16.1.6", "postcss": "8.5.6", "tailwindcss": "4.1.16", "tsx": "^4.21.0", diff --git a/migrations/002_course_enrollments.sql b/migrations/002_course_enrollments.sql new file mode 100644 index 0000000..e93ebb9 --- /dev/null +++ b/migrations/002_course_enrollments.sql @@ -0,0 +1,38 @@ +-- Migration 002: course_enrollments table +-- Stores one row per course enrollment from bishop_state_courses.csv + +CREATE TABLE IF NOT EXISTS public.course_enrollments ( + id BIGSERIAL PRIMARY KEY, + student_guid TEXT NOT NULL, + cohort TEXT, + cohort_term TEXT, + academic_year TEXT, + academic_term TEXT, + course_prefix TEXT, + course_number TEXT, + course_name TEXT, + course_cip TEXT, + course_type TEXT, -- CU (credit unit) / CC (co-req) + gateway_type TEXT, -- M (math) / E (English) / N (neither) + is_co_requisite BOOLEAN, + is_core_course BOOLEAN, + core_course_type TEXT, + delivery_method TEXT, -- F (face-to-face) / O (online) / H (hybrid) + grade TEXT, + credits_attempted NUMERIC, + credits_earned NUMERIC, + instructor_status TEXT -- FT / PT +); + +-- Indexes for common query patterns +CREATE INDEX IF NOT EXISTS idx_course_enrollments_student_guid + ON public.course_enrollments (student_guid); + +CREATE INDEX IF NOT EXISTS idx_course_enrollments_course + ON public.course_enrollments (course_prefix, course_number); + +CREATE INDEX IF NOT EXISTS idx_course_enrollments_gateway_type + ON public.course_enrollments (gateway_type); + +CREATE INDEX IF NOT EXISTS idx_course_enrollments_term + ON public.course_enrollments (academic_year, academic_term); diff --git a/scripts/load-course-enrollments.ts b/scripts/load-course-enrollments.ts new file mode 100644 index 0000000..06cc7ed --- /dev/null +++ b/scripts/load-course-enrollments.ts @@ -0,0 +1,265 @@ +/** + * Ingestion script: streams bishop_state_courses.csv and bulk-inserts rows + * into public.course_enrollments in batches of 500. + * + * Usage (from project root): + * NODE_PATH=codebenders-dashboard/node_modules \ + * DB_HOST=127.0.0.1 DB_PORT=54332 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=postgres \ + * codebenders-dashboard/node_modules/.bin/tsx scripts/load-course-enrollments.ts + */ + +import fs from "fs" +import path from "path" +import readline from "readline" +import { Pool } from "pg" + +// --------------------------------------------------------------------------- +// Load .env.local from codebenders-dashboard if it exists +// --------------------------------------------------------------------------- +const envLocalPath = path.resolve(__dirname, "../codebenders-dashboard/.env.local") +if (fs.existsSync(envLocalPath)) { + const lines = fs.readFileSync(envLocalPath, "utf8").split("\n") + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eqIdx = trimmed.indexOf("=") + if (eqIdx === -1) continue + const key = trimmed.slice(0, eqIdx).trim() + const value = trimmed.slice(eqIdx + 1).trim() + if (key && !(key in process.env)) { + process.env[key] = value + } + } + console.log(`Loaded env from ${envLocalPath}`) +} + +// --------------------------------------------------------------------------- +// DB connection +// --------------------------------------------------------------------------- +const pool = new Pool({ + host: process.env.DB_HOST ?? "127.0.0.1", + port: parseInt(process.env.DB_PORT ?? "54332", 10), + user: process.env.DB_USER ?? "postgres", + password: process.env.DB_PASSWORD ?? "postgres", + database: process.env.DB_NAME ?? "postgres", +}) + +// --------------------------------------------------------------------------- +// CSV helpers +// --------------------------------------------------------------------------- +const CSV_PATH = path.resolve(__dirname, "../data/bishop_state_courses.csv") + +/** + * Parse a single CSV line, respecting double-quoted fields. + * Returns an array of raw string values (empty string for missing cells). + */ +function parseCsvLine(line: string): string[] { + const fields: string[] = [] + let current = "" + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const ch = line[i] + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + // Escaped double-quote inside a quoted field + current += '"' + i++ + } else { + inQuotes = !inQuotes + } + } else if (ch === "," && !inQuotes) { + fields.push(current) + current = "" + } else { + current += ch + } + } + fields.push(current) + return fields +} + +/** Convert "Y"/"N" CSV values to boolean (null when neither). */ +function toBoolean(val: string): boolean | null { + if (val === "Y") return true + if (val === "N") return false + return null +} + +/** Convert a string to a numeric value, returning null for blank/non-numeric. */ +function toNumeric(val: string): number | null { + const trimmed = val.trim() + if (trimmed === "" || trimmed === "null" || trimmed === "NULL") return null + const n = parseFloat(trimmed) + return isNaN(n) ? null : n +} + +// --------------------------------------------------------------------------- +// Batch insert +// --------------------------------------------------------------------------- +const BATCH_SIZE = 500 +const LOG_EVERY = 10_000 + +interface Row { + student_guid: string + cohort: string | null + cohort_term: string | null + academic_year: string | null + academic_term: string | null + course_prefix: string | null + course_number: string | null + course_name: string | null + course_cip: string | null + course_type: string | null + gateway_type: string | null + is_co_requisite: boolean | null + is_core_course: boolean | null + core_course_type: string | null + delivery_method: string | null + grade: string | null + credits_attempted: number | null + credits_earned: number | null + instructor_status: string | null +} + +async function insertBatch(client: import("pg").PoolClient, batch: Row[]): Promise { + if (batch.length === 0) return + + // Build parameterized multi-value INSERT + const COLS = [ + "student_guid", "cohort", "cohort_term", "academic_year", "academic_term", + "course_prefix", "course_number", "course_name", "course_cip", "course_type", + "gateway_type", "is_co_requisite", "is_core_course", "core_course_type", + "delivery_method", "grade", "credits_attempted", "credits_earned", "instructor_status", + ] as const + + const numCols = COLS.length + const valuePlaceholders: string[] = [] + const params: unknown[] = [] + + batch.forEach((row, rowIdx) => { + const placeholders = COLS.map( + (_, colIdx) => `$${rowIdx * numCols + colIdx + 1}` + ).join(", ") + valuePlaceholders.push(`(${placeholders})`) + COLS.forEach(col => params.push(row[col])) + }) + + const sql = ` + INSERT INTO public.course_enrollments (${COLS.join(", ")}) + VALUES ${valuePlaceholders.join(", ")} + ` + await client.query(sql, params) +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main(): Promise { + console.log(`Loading course enrollments from: ${CSV_PATH}`) + + const client = await pool.connect() + try { + await client.query("BEGIN") + + // Truncate for idempotent re-runs + console.log("Truncating course_enrollments…") + await client.query("TRUNCATE TABLE public.course_enrollments RESTART IDENTITY") + + const rl = readline.createInterface({ + input: fs.createReadStream(CSV_PATH, { encoding: "utf8" }), + crlfDelay: Infinity, + }) + + let headers: string[] = [] + let batch: Row[] = [] + let totalRows = 0 + let lineNum = 0 + + for await (const rawLine of rl) { + lineNum++ + + // First line is the header + if (lineNum === 1) { + headers = parseCsvLine(rawLine) + continue + } + + const cols = parseCsvLine(rawLine) + + // Helper to get a column value by header name (empty string → null) + const get = (name: string): string | null => { + const idx = headers.indexOf(name) + if (idx === -1) return null + const v = cols[idx]?.trim() ?? "" + return v === "" ? null : v + } + + // C2: validate student_guid; skip row if missing + const student_guid = get("Student_GUID") + if (!student_guid) { + console.warn(`Row ${lineNum}: missing Student_GUID, skipping`) + continue + } + + const row: Row = { + student_guid, + cohort: get("Cohort"), + cohort_term: get("Cohort_Term"), + academic_year: get("Academic_Year"), + academic_term: get("Academic_Term"), + course_prefix: get("Course_Prefix"), + course_number: get("Course_Number"), + course_name: get("Course_Name"), + course_cip: get("Course_CIP"), + course_type: get("Course_Type"), + gateway_type: get("Math_or_English_Gateway"), + is_co_requisite: toBoolean(get("Co_requisite_Course") ?? ""), + is_core_course: toBoolean(get("Core_Course") ?? ""), + core_course_type: get("Core_Course_Type"), + delivery_method: get("Delivery_Method"), + grade: get("Grade"), + credits_attempted: toNumeric(get("Number_of_Credits_Attempted") ?? ""), + credits_earned: toNumeric(get("Number_of_Credits_Earned") ?? ""), + instructor_status: get("Course_Instructor_Employment_Status"), + } + + batch.push(row) + totalRows++ + + if (batch.length >= BATCH_SIZE) { + await insertBatch(client, batch) + batch = [] + } + + if (totalRows % LOG_EVERY === 0) { + console.log(` ...${totalRows.toLocaleString()} rows inserted`) + } + } + + // Flush remaining rows + if (batch.length > 0) { + await insertBatch(client, batch) + } + + await client.query("COMMIT") + + // Final count + const { rows } = await client.query<{ count: string }>( + "SELECT COUNT(*) AS count FROM public.course_enrollments" + ) + console.log(`\nDone. Total rows in DB: ${parseInt(rows[0].count, 10).toLocaleString()}`) + console.log(`CSV rows processed: ${totalRows.toLocaleString()}`) + } catch (err) { + await client.query("ROLLBACK") + throw err + } finally { + client.release() + await pool.end() + } +} + +main().catch(err => { + console.error("Fatal error:", err) + process.exit(1) +})