From 7dcb02c4eb7a4ea065b86cb81a018120568518b7 Mon Sep 17 00:00:00 2001 From: William Hill Date: Sun, 3 May 2026 15:09:33 -0400 Subject: [PATCH] feat(dashboard): data lineage drawer and GET /api/lineage (#107) Add lineage API with dashboard filters, latest upload_history ingest, transformation steps, and paginated source rows from student_level_with_predictions. Row-level identifiers respect the same RBAC as the student roster. Wire KPI values, risk-alert pie slices, and roster cells to open a dialog with lineage details. Add shared dashboard filter helpers and Radix dialog UI. Co-authored-by: Cursor --- .../app/api/lineage/route.ts | 250 ++++++++++++++ codebenders-dashboard/app/page.tsx | 23 ++ codebenders-dashboard/app/students/page.tsx | 122 ++++++- .../components/data-lineage-drawer.tsx | 310 ++++++++++++++++++ codebenders-dashboard/components/kpi-card.tsx | 26 +- .../components/risk-alert-chart.tsx | 25 +- .../components/ui/dialog.tsx | 102 ++++++ .../lib/dashboard-filters.ts | 75 +++++ codebenders-dashboard/lib/lineage-config.ts | 187 +++++++++++ codebenders-dashboard/lib/lineage-types.ts | 38 +++ codebenders-dashboard/package.json | 1 + 11 files changed, 1135 insertions(+), 24 deletions(-) create mode 100644 codebenders-dashboard/app/api/lineage/route.ts create mode 100644 codebenders-dashboard/components/data-lineage-drawer.tsx create mode 100644 codebenders-dashboard/components/ui/dialog.tsx create mode 100644 codebenders-dashboard/lib/dashboard-filters.ts create mode 100644 codebenders-dashboard/lib/lineage-config.ts create mode 100644 codebenders-dashboard/lib/lineage-types.ts diff --git a/codebenders-dashboard/app/api/lineage/route.ts b/codebenders-dashboard/app/api/lineage/route.ts new file mode 100644 index 0000000..f0035a5 --- /dev/null +++ b/codebenders-dashboard/app/api/lineage/route.ts @@ -0,0 +1,250 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" +import { + buildStudentLevelDashboardConditionsAliased, + optionalDashboardFilterRecord, + type DashboardFilterParams, +} from "@/lib/dashboard-filters" +import { + LINEAGE_STUDENT_LEVEL_SCHEMA_IDS, + isLineageMetricId, + lineageStepsForMetric, + isRosterLineageField, + rosterFieldLineageLabel, + type LineageMetricId, +} from "@/lib/lineage-config" +import { SCHEMAS } from "@/lib/upload-schemas" + +const MAX_PAGE_SIZE = 100 + +type LineageUploadHistoryRow = { + id: string + filename: string + file_type: string + uploaded_at: Date + status: string + user_email: string | null + rows_inserted: number + rows_skipped: number + error_count: number + has_validation_report: boolean +} + +const METRIC_LABEL: Record = { + overall_retention: "Overall retention rate", + avg_predicted_retention: "Average predicted retention", + high_critical_risk_count: "Students at high / critical risk", + avg_course_completion: "Average course completion", + risk_alert_segment: "Risk alert segment", + retention_risk_segment: "Retention risk segment", + roster_cell: "Roster value", +} + +function metricDescription(metric: LineageMetricId, category: string | null): string { + switch (metric) { + case "risk_alert_segment": + return category + ? `Students in the “${category}” slice of the risk alert distribution.` + : "A slice of the risk alert distribution." + case "retention_risk_segment": + return category + ? `Students in the “${category}” retention-probability band.` + : "A slice of the retention risk distribution." + case "roster_cell": + return "Single student field as shown on the roster." + default: + return METRIC_LABEL[metric] + } +} + +function appendMetricPredicate( + metric: LineageMetricId, + category: string | null, + studentGuid: string | null, + conditions: string[], + values: unknown[] +): { error?: string } { + switch (metric) { + case "high_critical_risk_count": + conditions.push(`s.at_risk_alert IN ('HIGH', 'URGENT')`) + return {} + case "risk_alert_segment": + if (!category?.trim()) return { error: "category is required for risk_alert_segment" } + values.push(category.trim()) + conditions.push(`s.at_risk_alert = $${values.length}`) + return {} + case "retention_risk_segment": + if (!category?.trim()) return { error: "category is required for retention_risk_segment" } + values.push(category.trim()) + conditions.push(`s.retention_risk_category = $${values.length}`) + return {} + case "roster_cell": + if (!studentGuid?.trim()) return { error: "studentGuid is required for roster_cell" } + values.push(studentGuid.trim()) + conditions.push(`s."Student_GUID" = $${values.length}`) + return {} + default: + return {} + } +} + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const metricRaw = searchParams.get("metric") || "" + if (!isLineageMetricId(metricRaw)) { + return NextResponse.json( + { error: "Invalid or missing metric", allowed: "overall_retention | avg_predicted_retention | …" }, + { status: 400 } + ) + } + const metric = metricRaw + + const cohort = searchParams.get("cohort") ?? "" + const enrollmentType = searchParams.get("enrollmentType") ?? "" + const credentialType = searchParams.get("credentialType") ?? "" + const category = searchParams.get("category") ?? "" + const studentGuid = searchParams.get("studentGuid") ?? "" + const fieldRaw = searchParams.get("field") ?? "" + const categoryForApi = category || null + + if (metric === "roster_cell") { + if (!isRosterLineageField(fieldRaw)) { + return NextResponse.json({ error: "Invalid or missing field for roster_cell" }, { status: 400 }) + } + } + + const page = Math.max(1, Number(searchParams.get("page") || 1)) + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(searchParams.get("pageSize") || 50))) + const offset = (page - 1) * pageSize + + const showIdentifiers = canAccess("/api/students", role) + + const filterParams: DashboardFilterParams = { cohort, enrollmentType, credentialType } + const { conditions: filterConds, values: filterVals } = + metric === "roster_cell" + ? { conditions: [] as string[], values: [] as unknown[] } + : buildStudentLevelDashboardConditionsAliased(filterParams, "s") + + const conditions = [...filterConds] + const values = [...filterVals] + + const predErr = appendMetricPredicate(metric, categoryForApi, studentGuid || null, conditions, values) + if (predErr.error) { + return NextResponse.json({ error: predErr.error }, { status: 400 }) + } + + const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + const pool = getPool() + + try { + const [countRes, uploadRes] = await Promise.all([ + pool.query<{ c: number }>( + `SELECT COUNT(*)::int AS c FROM student_level_with_predictions s ${whereSql}`, + values + ), + pool.query( + `SELECT id, filename, file_type, uploaded_at, status, user_email, rows_inserted, rows_skipped, error_count, + (validation_report IS NOT NULL) AS has_validation_report + FROM upload_history + WHERE file_type = ANY($1::text[]) + ORDER BY uploaded_at DESC + LIMIT 1`, + [LINEAGE_STUDENT_LEVEL_SCHEMA_IDS] + ), + ]) + + const rowCount = Number(countRes.rows[0]?.c ?? 0) + const uploadRow = uploadRes.rows[0] + + const schemaMeta = uploadRow ? SCHEMAS.find((x) => x.id === uploadRow.file_type) : undefined + + let sourceRows: Record[] | undefined + if (showIdentifiers && rowCount > 0) { + const dataSql = ` + SELECT + s."Student_GUID" AS student_guid, + s."Cohort" AS cohort, + s."Enrollment_Intensity_First_Term" AS enrollment_intensity, + s."Retention" AS retention, + ROUND((s.retention_probability * 100)::numeric, 1) AS retention_pct, + s.at_risk_alert, + s.retention_risk_category, + ROUND((s.course_completion_rate * 100)::numeric, 1) AS course_completion_pct + FROM student_level_with_predictions s + ${whereSql} + ORDER BY s."Student_GUID" + LIMIT $${values.length + 1} OFFSET $${values.length + 2} + ` + const dataRes = await pool.query(dataSql, [...values, pageSize, offset]) + sourceRows = dataRes.rows as Record[] + } + + const steps = lineageStepsForMetric(metric) + if (metric === "roster_cell" && isRosterLineageField(fieldRaw)) { + const rf = rosterFieldLineageLabel(fieldRaw) + steps.push({ + order: 4, + title: rf.label, + detail: rf.detail, + }) + } + + const uploadEvent = uploadRow + ? { + id: Number(uploadRow.id), + filename: uploadRow.filename, + fileType: uploadRow.file_type, + schemaLabel: schemaMeta?.label ?? uploadRow.file_type, + uploadedAt: uploadRow.uploaded_at.toISOString(), + status: uploadRow.status, + userEmail: uploadRow.user_email, + rowsInserted: Number(uploadRow.rows_inserted ?? 0), + rowsSkipped: Number(uploadRow.rows_skipped ?? 0), + errorCount: Number(uploadRow.error_count ?? 0), + hasValidationReport: Boolean(uploadRow.has_validation_report), + } + : null + + return NextResponse.json({ + metricId: metric, + metricLabel: METRIC_LABEL[metric], + metricDescription: metricDescription(metric, categoryForApi), + field: metric === "roster_cell" ? fieldRaw : undefined, + filters: metric === "roster_cell" ? {} : optionalDashboardFilterRecord(filterParams), + dimension: category || undefined, + aggregate: { + rowCount, + summary: + metric === "roster_cell" + ? "One student row." + : `${rowCount.toLocaleString()} student(s) match the current filters and metric scope.`, + }, + sourceRowsVisible: showIdentifiers, + sourceRowsRestrictedMessage: showIdentifiers + ? undefined + : "Row-level identifiers are available to Admin, Advisor, and IR roles (same access as the student roster).", + sourceRows: + showIdentifiers && sourceRows + ? { page, pageSize, total: rowCount, rows: sourceRows } + : undefined, + uploadEvent, + transformationSteps: steps, + }) + } catch (error) { + console.error("Lineage API error:", error) + return NextResponse.json( + { + error: "Failed to load lineage", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/page.tsx b/codebenders-dashboard/app/page.tsx index a0a2b2f..409f4e2 100644 --- a/codebenders-dashboard/app/page.tsx +++ b/codebenders-dashboard/app/page.tsx @@ -17,6 +17,9 @@ import { import { TrendingUp, Users, AlertTriangle, BookOpen, Search, Table2, X } from "lucide-react" import Link from "next/link" import { GlossaryMetricEntryLink } from "@/components/glossary-metric-entry-link" +import { useDataLineage } from "@/components/data-lineage-drawer" +import { optionalDashboardFilterRecord } from "@/lib/dashboard-filters" +import type { LineageMetricId } from "@/lib/lineage-config" interface KPIData { overallRetentionRate: string @@ -60,6 +63,7 @@ const ENROLLMENT_TYPES = ["Full-Time", "Part-Time"] const CREDENTIAL_TYPES = ["Certificate", "Associate", "Bachelor"] export default function DashboardPage() { + const { drawer: lineageDrawer, openLineage } = useDataLineage() const [kpis, setKpis] = useState(null) const [riskAlerts, setRiskAlerts] = useState([]) const [retentionRisk, setRetentionRisk] = useState([]) @@ -85,6 +89,13 @@ export default function DashboardPage() { return qs ? `?${qs}` : "" } + function openKpiLineage(metric: LineageMetricId) { + openLineage({ + metric, + ...optionalDashboardFilterRecord({ cohort, enrollmentType, credentialType }), + }) + } + useEffect(() => { const qs = buildFilterParams() @@ -271,6 +282,7 @@ export default function DashboardPage() { icon={TrendingUp} subtitle={kpis ? `${kpis.totalStudents.toLocaleString()} total students` : undefined} loading={loading} + onLineageClick={loading ? undefined : () => openKpiLineage("overall_retention")} info={ <>

What it shows: Percentage of students retained year-to-year based on historical data.

@@ -286,6 +298,7 @@ export default function DashboardPage() { icon={Users} subtitle="ML model prediction" loading={loading} + onLineageClick={loading ? undefined : () => openKpiLineage("avg_predicted_retention")} info={ <>

Model: XGBoost Classifier trained on 31 features including demographics, academic prep, and course performance.

@@ -307,6 +320,7 @@ export default function DashboardPage() { icon={AlertTriangle} subtitle="Require immediate intervention" loading={loading} + onLineageClick={loading ? undefined : () => openKpiLineage("high_critical_risk_count")} info={ <>

How it's calculated: Composite risk score combining:

@@ -332,6 +346,7 @@ export default function DashboardPage() { icon={BookOpen} subtitle="Credits earned / attempted" loading={loading} + onLineageClick={loading ? undefined : () => openKpiLineage("avg_course_completion")} info={ <>

Formula: (Total credits earned ÷ Total credits attempted) × 100

@@ -354,6 +369,13 @@ export default function DashboardPage() { + openLineage({ + metric: "risk_alert_segment", + category, + ...optionalDashboardFilterRecord({ cohort, enrollmentType, credentialType }), + }) + } info={ <>

What it shows: Distribution of students across risk alert levels (URGENT, HIGH, MODERATE, LOW).

@@ -427,6 +449,7 @@ export default function DashboardPage() { + {lineageDrawer} ) } diff --git a/codebenders-dashboard/app/students/page.tsx b/codebenders-dashboard/app/students/page.tsx index c987c75..7e14bcf 100644 --- a/codebenders-dashboard/app/students/page.tsx +++ b/codebenders-dashboard/app/students/page.tsx @@ -6,8 +6,9 @@ import { useRouter } from "next/navigation" import { ArrowLeft, ArrowUpDown, ArrowUp, ArrowDown, Download, Search, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" import { InfoPopover } from "@/components/info-popover" +import { useDataLineage, type LineageOpenRequest } from "@/components/data-lineage-drawer" +import type { RosterLineageField } from "@/lib/lineage-config" import { Select, SelectContent, @@ -62,6 +63,10 @@ const ENROLLMENT_TYPES = [ { value: "Part-Time", label: "Part-time" }, ] as const +function rosterCellLineage(studentGuid: string, field: RosterLineageField): LineageOpenRequest { + return { metric: "roster_cell", studentGuid, field } +} + // ─── Badge helpers ──────────────────────────────────────────────────────────── function AlertBadge({ level }: { level: StudentRow["at_risk_alert"] }) { @@ -120,6 +125,7 @@ function SortIcon({ active, dir }: { active: boolean; dir: "asc" | "desc" }) { export default function StudentsPage() { const router = useRouter() + const { drawer: lineageDrawer, openLineage } = useDataLineage() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -500,31 +506,86 @@ export default function StudentsPage() { {s.student_guid ? s.student_guid.slice(0, 12) + "…" : "—"} - {s.cohort ?? "—"} - - {s.enrollment_intensity === "Full-Time" || s.enrollment_intensity === "Full Time" || s.enrollment_intensity === "FT" - ? "Full-time" - : s.enrollment_intensity === "Part-Time" || s.enrollment_intensity === "Part Time" || s.enrollment_intensity === "PT" - ? "Part-time" - : s.enrollment_intensity ?? "—"} + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "cohort"))} + > + {s.cohort ?? "—"} + + + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "enrollment_intensity"))} + > + {s.enrollment_intensity === "Full-Time" || s.enrollment_intensity === "Full Time" || s.enrollment_intensity === "FT" + ? "Full-time" + : s.enrollment_intensity === "Part-Time" || s.enrollment_intensity === "Part Time" || s.enrollment_intensity === "PT" + ? "Part-time" + : s.enrollment_intensity ?? "—"} + + + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "at_risk_alert"))} + > + + + + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "retention_pct"))} + > + + - - - + e.stopPropagation()}>
{s.readiness_pct !== null && ( - {s.readiness_pct}% + openLineage(rosterCellLineage(s.student_guid, "readiness_pct"))} + > + {s.readiness_pct}% + )}
- - - - - {s.time_to_credential ? `${s.time_to_credential} yr` : "—"} + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "gateway_math_pct"))} + > + + + + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "gateway_english_pct"))} + > + + + + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "gpa_risk_pct"))} + > + + + + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "time_to_credential"))} + > + {s.time_to_credential ? `${s.time_to_credential} yr` : "—"} + + + e.stopPropagation()}> + openLineage(rosterCellLineage(s.student_guid, "credential_type"))} + > + {s.credential_type ?? "—"} + - {s.credential_type ?? "—"} )) )} @@ -545,10 +606,35 @@ export default function StudentsPage() { )} + {lineageDrawer} ) } +function LineageValueButton({ + children, + onLineage, + className = "", +}: { + children: React.ReactNode + onLineage: () => void + className?: string +}) { + return ( + + ) +} + // ─── Table header helpers ───────────────────────────────────────────────────── function Th({ label, info }: { label: string; info?: React.ReactNode }) { diff --git a/codebenders-dashboard/components/data-lineage-drawer.tsx b/codebenders-dashboard/components/data-lineage-drawer.tsx new file mode 100644 index 0000000..f151adf --- /dev/null +++ b/codebenders-dashboard/components/data-lineage-drawer.tsx @@ -0,0 +1,310 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import type { LineageMetricId } from "@/lib/lineage-config" +import type { LineageApiResponse } from "@/lib/lineage-types" +import { GitBranch, Loader2 } from "lucide-react" + +export type LineageOpenRequest = { + metric: LineageMetricId + cohort?: string + enrollmentType?: string + credentialType?: string + category?: string + studentGuid?: string + field?: string +} + +function buildLineageUrl(req: LineageOpenRequest, page: number, pageSize: number) { + const p = new URLSearchParams() + p.set("metric", req.metric) + p.set("page", String(page)) + p.set("pageSize", String(pageSize)) + if (req.cohort) p.set("cohort", req.cohort) + if (req.enrollmentType) p.set("enrollmentType", req.enrollmentType) + if (req.credentialType) p.set("credentialType", req.credentialType) + if (req.category) p.set("category", req.category) + if (req.studentGuid) p.set("studentGuid", req.studentGuid) + if (req.field) p.set("field", req.field) + return `/api/lineage?${p.toString()}` +} + +export function useDataLineage() { + const [open, setOpen] = useState(false) + const [request, setRequest] = useState(null) + const [page, setPage] = useState(1) + + const openLineage = useCallback((r: LineageOpenRequest) => { + setRequest(r) + setPage(1) + setOpen(true) + }, []) + + const drawer = ( + { + setOpen(v) + if (!v) { + setRequest(null) + setPage(1) + } + }} + request={request} + /> + ) + + return { drawer, openLineage } +} + +type DataLineageDrawerProps = { + open: boolean + page: number + onPageChange: (page: number) => void + onOpenChange: (open: boolean) => void + request: LineageOpenRequest | null +} + +export function DataLineageDrawer({ open, page, onPageChange, onOpenChange, request }: DataLineageDrawerProps) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState(null) + + useEffect(() => { + if (!open || !request) { + return + } + + let cancelled = false + const run = async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(buildLineageUrl(request, page, 50)) + const json = await res.json() + if (!res.ok) { + throw new Error(json.error || "Failed to load lineage") + } + if (!cancelled) { + setData(json as LineageApiResponse) + } + } catch (e) { + if (!cancelled) { + setData(null) + setError(e instanceof Error ? e.message : "Failed to load lineage") + } + } finally { + if (!cancelled) setLoading(false) + } + } + + void run() + return () => { + cancelled = true + } + }, [open, request, page]) + + const totalPages = + data?.sourceRows?.total != null && data.sourceRows.pageSize > 0 + ? Math.max(1, Math.ceil(data.sourceRows.total / data.sourceRows.pageSize)) + : 1 + + return ( + + + + + + Data lineage + + +
+ {data ? ( + <> +

{data.metricLabel}

+

+ Upload event, source rows, and transformation chain for this aggregate. +

+ + ) : request ? ( +

Loading metric details…

+ ) : null} +
+
+
+ + {loading && ( +
+ + Loading lineage… +
+ )} + + {error && ( +
{error}
+ )} + + {!loading && data && ( +
+
+

+ Metric +

+

{data.metricLabel}

+

{data.metricDescription}

+ {data.field ? ( +

+ Column: {data.field} +

+ ) : null} +

{data.aggregate.summary}

+
+ +
+

+ Latest ingest event +

+ {data.uploadEvent ? ( +
    +
  • + File: {data.uploadEvent.filename} +
  • +
  • + Schema: {data.uploadEvent.schemaLabel}{" "} + ({data.uploadEvent.fileType}) +
  • +
  • + Uploaded:{" "} + {new Date(data.uploadEvent.uploadedAt).toLocaleString()} +
  • +
  • + Status: {data.uploadEvent.status} · rows + inserted {data.uploadEvent.rowsInserted.toLocaleString()} + {data.uploadEvent.hasValidationReport ? " · validation report stored" : ""} +
  • + {data.uploadEvent.userEmail ? ( +
  • + By: {data.uploadEvent.userEmail} +
  • + ) : null} +
+ ) : ( +

No upload history found for student-level schemas yet.

+ )} +
+ +
+

+ Transformation chain +

+
    + {data.transformationSteps + .slice() + .sort((a, b) => a.order - b.order) + .map((step) => ( +
  1. + {step.title} +

    {step.detail}

    +
  2. + ))} +
+
+ + {data.sourceRowsRestrictedMessage ? ( +
+

+ Source rows +

+

{data.sourceRowsRestrictedMessage}

+
+ ) : null} + + {data.sourceRows && data.sourceRows.rows.length > 0 ? ( +
+

+ Source rows (paginated) +

+

+ Showing {data.sourceRows.rows.length} of {data.sourceRows.total.toLocaleString()} · page{" "} + {data.sourceRows.page} of {totalPages} +

+
+ + + + + + + + + + + + + {data.sourceRows.rows.map((row, i) => ( + + + + + + + + + ))} + +
Student GUIDCohortRetention %AlertRisk bandCourse compl. %
+ {String(row.student_guid ?? "").slice(0, 14)}… + {String(row.cohort ?? "—")}{row.retention_pct != null ? String(row.retention_pct) : "—"}{String(row.at_risk_alert ?? "—")}{String(row.retention_risk_category ?? "—")} + {row.course_completion_pct != null ? String(row.course_completion_pct) : "—"} +
+
+ {totalPages > 1 ? ( +
+ + + Page {page} / {totalPages} + + +
+ ) : null} +
+ ) : null} + + {!data.sourceRowsRestrictedMessage && + data.sourceRowsVisible && + data.aggregate.rowCount > 0 && + (!data.sourceRows || data.sourceRows.rows.length === 0) ? ( +

No rows returned for this page.

+ ) : null} +
+ )} +
+
+ ) +} diff --git a/codebenders-dashboard/components/kpi-card.tsx b/codebenders-dashboard/components/kpi-card.tsx index 5ec5d6f..c12473e 100644 --- a/codebenders-dashboard/components/kpi-card.tsx +++ b/codebenders-dashboard/components/kpi-card.tsx @@ -13,9 +13,20 @@ interface KPICardProps { } loading?: boolean info?: React.ReactNode + /** Opens data lineage drawer (issue #107). */ + onLineageClick?: () => void } -export function KPICard({ title, value, icon: Icon, subtitle, trend, loading = false, info }: KPICardProps) { +export function KPICard({ + title, + value, + icon: Icon, + subtitle, + trend, + loading = false, + info, + onLineageClick, +}: KPICardProps) { if (loading) { return ( @@ -46,7 +57,18 @@ export function KPICard({ title, value, icon: Icon, subtitle, trend, loading = f {Icon && } -
{value}
+ {onLineageClick ? ( + + ) : ( +
{value}
+ )} {subtitle &&

{subtitle}

} {trend && (

diff --git a/codebenders-dashboard/components/risk-alert-chart.tsx b/codebenders-dashboard/components/risk-alert-chart.tsx index 9728649..6f48ed1 100644 --- a/codebenders-dashboard/components/risk-alert-chart.tsx +++ b/codebenders-dashboard/components/risk-alert-chart.tsx @@ -22,6 +22,8 @@ interface RiskAlertChartProps { data: RiskAlertData[] loading?: boolean info?: React.ReactNode + /** Click a slice to open data lineage for that alert level (issue #107). */ + onSegmentLineage?: (category: string) => void } const COLORS = { @@ -53,7 +55,9 @@ const CustomLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: an const CHART_FILE_SLUG = "risk-alert-distribution" as const -export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartProps) { +type PieSlicePayload = { name?: string } + +export function RiskAlertChart({ data, loading = false, info, onSegmentLineage }: RiskAlertChartProps) { const exportRef = useRef(null) const csvSpec = useMemo( @@ -70,7 +74,10 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr Risk Alert Distribution {info && {info}} - Students by risk level + + Students by risk level + {onSegmentLineage ? · Click a slice for data lineage : null} +

@@ -89,7 +96,10 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr Risk Alert Distribution {info && {info}}
- Students by risk level + + Students by risk level + {onSegmentLineage ? · Click a slice for data lineage : null} +
@@ -112,7 +122,10 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr Risk Alert Distribution - {totalStudents.toLocaleString()} total students + + {totalStudents.toLocaleString()} total students + {onSegmentLineage ? · Click a slice for data lineage : null} + student_level_with_predictions · at_risk_alert · @@ -144,6 +157,10 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr outerRadius={100} fill="#8884d8" dataKey="value" + className={onSegmentLineage ? "cursor-pointer outline-none" : undefined} + onClick={(slice: PieSlicePayload) => { + if (slice.name && onSegmentLineage) onSegmentLineage(slice.name) + }} > {chartData.map((entry, index) => ( diff --git a/codebenders-dashboard/components/ui/dialog.tsx b/codebenders-dashboard/components/ui/dialog.tsx new file mode 100644 index 0000000..fb46e33 --- /dev/null +++ b/codebenders-dashboard/components/ui/dialog.tsx @@ -0,0 +1,102 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/codebenders-dashboard/lib/dashboard-filters.ts b/codebenders-dashboard/lib/dashboard-filters.ts new file mode 100644 index 0000000..224a1d5 --- /dev/null +++ b/codebenders-dashboard/lib/dashboard-filters.ts @@ -0,0 +1,75 @@ +/** + * Shared WHERE clause for dashboard pages that filter `student_level_with_predictions` + * (home KPIs, charts, lineage). Column names match existing API routes. + */ +export type DashboardFilterParams = { + cohort: string + enrollmentType: string + credentialType: string +} + +/** Query-string style object for lineage UI (omit empty keys). */ +export function optionalDashboardFilterRecord( + params: DashboardFilterParams +): Record { + return { + ...(params.cohort ? { cohort: params.cohort } : {}), + ...(params.enrollmentType ? { enrollmentType: params.enrollmentType } : {}), + ...(params.credentialType ? { credentialType: params.credentialType } : {}), + } +} + +export function buildStudentLevelDashboardWhere(params: DashboardFilterParams): { clause: string; values: unknown[] } { + const conditions: string[] = [] + const values: unknown[] = [] + + if (params.cohort) { + values.push(params.cohort) + conditions.push(`"Cohort" = $${values.length}`) + } + if (params.enrollmentType) { + values.push(params.enrollmentType) + conditions.push(`"Enrollment_Intensity_First_Term" = $${values.length}`) + } + if (params.credentialType) { + values.push(params.credentialType) + conditions.push(`predicted_credential_label = $${values.length}`) + } + + const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + return { clause, values } +} + +/** Alias columns with table prefix `s.` for use in subqueries / JOINs. */ +export function buildStudentLevelDashboardWhereAliased( + params: DashboardFilterParams, + tableAlias = "s" +): { clause: string; values: unknown[] } { + const { conditions, values } = buildStudentLevelDashboardConditionsAliased(params, tableAlias) + const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + return { clause, values } +} + +/** Condition fragments (no `WHERE`) so callers can `AND` with lineage predicates. */ +export function buildStudentLevelDashboardConditionsAliased( + params: DashboardFilterParams, + tableAlias = "s" +): { conditions: string[]; values: unknown[] } { + const conditions: string[] = [] + const values: unknown[] = [] + + if (params.cohort) { + values.push(params.cohort) + conditions.push(`${tableAlias}."Cohort" = $${values.length}`) + } + if (params.enrollmentType) { + values.push(params.enrollmentType) + conditions.push(`${tableAlias}."Enrollment_Intensity_First_Term" = $${values.length}`) + } + if (params.credentialType) { + values.push(params.credentialType) + conditions.push(`${tableAlias}.predicted_credential_label = $${values.length}`) + } + + return { conditions, values } +} diff --git a/codebenders-dashboard/lib/lineage-config.ts b/codebenders-dashboard/lib/lineage-config.ts new file mode 100644 index 0000000..5c7bf7d --- /dev/null +++ b/codebenders-dashboard/lib/lineage-config.ts @@ -0,0 +1,187 @@ +import { SCHEMAS, type UploadSchema } from "@/lib/upload-schemas" + +/** Upload schema IDs that write to `student_level_with_predictions` (for latest-ingest lookup). */ +export const LINEAGE_STUDENT_LEVEL_SCHEMA_IDS: string[] = Array.from( + new Set( + SCHEMAS.filter((s: UploadSchema) => s.targetTable === "student_level_with_predictions").map( + (s) => s.id + ) + ) +) + +export const LINEAGE_METRICS = [ + "overall_retention", + "avg_predicted_retention", + "high_critical_risk_count", + "avg_course_completion", + "risk_alert_segment", + "retention_risk_segment", + "roster_cell", +] as const + +export type LineageMetricId = (typeof LINEAGE_METRICS)[number] + +export function isLineageMetricId(s: string): s is LineageMetricId { + return (LINEAGE_METRICS as readonly string[]).includes(s) +} + +export type LineageTransformStep = { + order: number + title: string + detail: string +} + +export function lineageStepsForMetric(metric: LineageMetricId): LineageTransformStep[] { + const base: LineageTransformStep[] = [ + { + order: 1, + title: "Institutional ingest", + detail: + "Rows are loaded into `student_level_with_predictions` via the admin upload wizard (PDP / AR / submission layouts). Each commit records an `upload_history` event with validation summary.", + }, + { + order: 2, + title: "Analysis-ready view", + detail: + "The dashboard reads the `student_level_with_predictions` view (one row per student). Dashboard filters (cohort, enrollment intensity, predicted credential) scope the same row set used for KPIs and charts.", + }, + ] + + switch (metric) { + case "overall_retention": + return [ + ...base, + { + order: 3, + title: "Aggregate: historical retention", + detail: + "KPI = AVG(`Retention`) × 100 over filtered students. `Retention` is the cohort year-to-year retention indicator from PDP-style source data (0 = not retained, 1 = retained).", + }, + ] + case "avg_predicted_retention": + return [ + ...base, + { + order: 3, + title: "Model: retention probability", + detail: + "KPI = AVG(`retention_probability`) × 100. Probabilities come from the deployed XGBoost retention classifier (features include demographics, placement, GPA, and course performance). Values are refreshed when prediction scores are merged into the student-level table (upload or ML pipeline).", + }, + ] + case "high_critical_risk_count": + return [ + ...base, + { + order: 3, + title: "Composite: alert bucketing", + detail: + "Count of students where `at_risk_alert` is HIGH or URGENT. Alerts combine inverted retention probability with GPA, course completion, and credit-progress thresholds (see methodology).", + }, + ] + case "avg_course_completion": + return [ + ...base, + { + order: 3, + title: "Aggregate: course completion rate", + detail: + "KPI = AVG(`course_completion_rate`) × 100 over filtered students (credits earned ÷ attempted in the modeled window).", + }, + ] + case "risk_alert_segment": + return [ + ...base, + { + order: 3, + title: "Slice: risk alert level", + detail: + "Chart segment counts students with a given `at_risk_alert` value (URGENT, HIGH, MODERATE, LOW) within the filtered cohort.", + }, + ] + case "retention_risk_segment": + return [ + ...base, + { + order: 3, + title: "Slice: retention probability band", + detail: + "Chart segment buckets students by `retention_risk_category` derived from `retention_probability` cut points (Critical / High / Moderate / Low risk).", + }, + ] + case "roster_cell": + return [ + ...base, + { + order: 3, + title: "Roster column", + detail: + "Value shown is taken from the same `student_level_with_predictions` row (and readiness join where applicable) as the student roster API.", + }, + ] + } +} + +export const ROSTER_LINEAGE_FIELD_KEYS = [ + "retention_pct", + "readiness_pct", + "gateway_math_pct", + "gateway_english_pct", + "gpa_risk_pct", + "time_to_credential", + "credential_type", + "at_risk_alert", + "cohort", + "enrollment_intensity", +] as const + +export type RosterLineageField = (typeof ROSTER_LINEAGE_FIELD_KEYS)[number] + +export function isRosterLineageField(s: string): s is RosterLineageField { + return (ROSTER_LINEAGE_FIELD_KEYS as readonly string[]).includes(s) +} + +export function rosterFieldLineageLabel(field: RosterLineageField): { label: string; detail: string } { + const map = { + retention_pct: { + label: "Predicted retention %", + detail: "`retention_probability` on `student_level_with_predictions`, scaled ×100 and rounded (XGBoost retention model).", + }, + readiness_pct: { + label: "Readiness %", + detail: "`readiness_score` from `llm_recommendations`, joined by `Student_GUID`, scaled ×100 and rounded.", + }, + gateway_math_pct: { + label: "Gateway math %", + detail: "`gateway_math_probability` on `student_level_with_predictions` (ML model output).", + }, + gateway_english_pct: { + label: "Gateway English %", + detail: "`gateway_english_probability` on `student_level_with_predictions` (ML model output).", + }, + gpa_risk_pct: { + label: "Low-GPA risk %", + detail: "`low_gpa_probability` on `student_level_with_predictions` (ML model output).", + }, + time_to_credential: { + label: "Time to credential", + detail: "`predicted_time_to_credential` regression output on `student_level_with_predictions`.", + }, + credential_type: { + label: "Credential type", + detail: "`predicted_credential_label` on `student_level_with_predictions` (predicted credential category).", + }, + at_risk_alert: { + label: "At-risk alert", + detail: "`at_risk_alert` composite bucket (URGENT / HIGH / MODERATE / LOW) on `student_level_with_predictions`.", + }, + cohort: { + label: "Cohort", + detail: "`Cohort` dimension on `student_level_with_predictions` (PDP cohort label).", + }, + enrollment_intensity: { + label: "Enrollment intensity", + detail: "`Enrollment_Intensity_First_Term` on `student_level_with_predictions`.", + }, + } satisfies Record + return map[field] +} diff --git a/codebenders-dashboard/lib/lineage-types.ts b/codebenders-dashboard/lib/lineage-types.ts new file mode 100644 index 0000000..25a4535 --- /dev/null +++ b/codebenders-dashboard/lib/lineage-types.ts @@ -0,0 +1,38 @@ +import type { LineageTransformStep } from "@/lib/lineage-config" + +export type LineageUploadEvent = { + id: number + filename: string + fileType: string + schemaLabel: string + uploadedAt: string + status: string + userEmail: string | null + rowsInserted: number + rowsSkipped: number + errorCount: number + hasValidationReport: boolean +} + +export type LineageApiResponse = { + metricId: string + metricLabel: string + metricDescription: string + field?: string + filters: Record + dimension?: string + aggregate: { + rowCount: number + summary: string + } + sourceRowsVisible: boolean + sourceRowsRestrictedMessage?: string + sourceRows?: { + page: number + pageSize: number + total: number + rows: Record[] + } + uploadEvent: LineageUploadEvent | null + transformationSteps: LineageTransformStep[] +} diff --git a/codebenders-dashboard/package.json b/codebenders-dashboard/package.json index 03ac6d2..e9c27af 100644 --- a/codebenders-dashboard/package.json +++ b/codebenders-dashboard/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@ai-sdk/openai": "^2.0.56", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-popover": "^1.1.15",