diff --git a/codebenders-dashboard/components/chart-export-card-meta.tsx b/codebenders-dashboard/components/chart-export-card-meta.tsx new file mode 100644 index 0000000..7e34f8e --- /dev/null +++ b/codebenders-dashboard/components/chart-export-card-meta.tsx @@ -0,0 +1,34 @@ +"use client" + +import type { ReactElement, ReactNode } from "react" + +import { CardFooter } from "@/components/ui/card" +import type { MetricGlossarySlug } from "@/lib/glossary-constants" +import { CHART_EXPORT_BRAND_LINE } from "@/lib/chart-export-filename" +import { getChartExportBlurb } from "@/lib/chart-export-glossary" + +export function ChartExportGlossaryBlurb(props: { slug: MetricGlossarySlug }): ReactElement { + return ( +

+ {getChartExportBlurb(props.slug)} +

+ ) +} + +export function ChartExportDataSourceLine(props: { children: ReactNode }): ReactElement { + return ( +

+ Data source: {props.children}{" "} + Generated:{" "} + {new Date().toLocaleDateString()} +

+ ) +} + +export function ChartExportBrandFooter(): ReactElement { + return ( + + {CHART_EXPORT_BRAND_LINE} + + ) +} diff --git a/codebenders-dashboard/components/chart-export-menu.tsx b/codebenders-dashboard/components/chart-export-menu.tsx new file mode 100644 index 0000000..8ca42f0 --- /dev/null +++ b/codebenders-dashboard/components/chart-export-menu.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useCallback, useState, type RefObject } from "react" +import { Download, FileImage, FileSpreadsheet, FileType } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { buildChartExportBasename } from "@/lib/chart-export-filename" +import { + captureElementToPngDataUrl, + downloadChartExportCsv, + downloadChartPdf, + downloadDataUrl, +} from "@/lib/chart-export-capture" +import type { ChartExportCsvSpec } from "@/lib/chart-export-csv" + +interface ChartExportMenuProps { + exportRef: RefObject + chartFileSlug: string + csv?: ChartExportCsvSpec | null + disabled?: boolean + /** Report capture failures (e.g. toast); defaults to console.error */ + onError?: (message: string) => void +} + +export function ChartExportMenu({ + exportRef, + chartFileSlug, + csv, + disabled = false, + onError, +}: ChartExportMenuProps) { + const [busy, setBusy] = useState(false) + + const runExport = useCallback( + async (kind: "png" | "pdf") => { + const report = onError ?? ((m: string) => console.error(m)) + const el = exportRef.current + if (!el) { + report("Chart export: missing element") + return + } + setBusy(true) + try { + const basename = buildChartExportBasename(chartFileSlug) + if (kind === "png") { + const dataUrl = await captureElementToPngDataUrl(el) + downloadDataUrl(dataUrl, `${basename}.png`) + } else { + await downloadChartPdf(el, `${basename}.pdf`) + } + } catch (e) { + report(e instanceof Error ? e.message : "Chart export failed") + } finally { + setBusy(false) + } + }, + [chartFileSlug, exportRef, onError] + ) + + const onCsv = useCallback(() => { + if (!csv) return + const basename = buildChartExportBasename(chartFileSlug) + downloadChartExportCsv(csv, `${basename}.csv`) + }, [chartFileSlug, csv]) + + return ( + + + + + + Export chart + + {csv ? ( + + + CSV + + ) : null} + void runExport("png")} + disabled={busy} + className="gap-2" + > + + PNG + + void runExport("pdf")} + disabled={busy} + className="gap-2" + > + + PDF + + + + ) +} diff --git a/codebenders-dashboard/components/readiness-assessment-chart.tsx b/codebenders-dashboard/components/readiness-assessment-chart.tsx index 13c6650..004d000 100644 --- a/codebenders-dashboard/components/readiness-assessment-chart.tsx +++ b/codebenders-dashboard/components/readiness-assessment-chart.tsx @@ -1,11 +1,25 @@ 'use client'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useMemo, useRef } from 'react'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { AlertCircle, TrendingUp, Users, Target, AlertTriangle } from 'lucide-react'; import { InfoPopover } from '@/components/info-popover'; import { GlossaryMetricEntryLink } from '@/components/glossary-metric-entry-link'; +import { + ChartExportBrandFooter, + ChartExportDataSourceLine, + ChartExportGlossaryBlurb, +} from '@/components/chart-export-card-meta'; +import { ChartExportMenu } from '@/components/chart-export-menu'; interface ReadinessData { summary: { @@ -60,7 +74,37 @@ interface ReadinessAssessmentChartProps { error?: string; } +const READINESS_LEVEL_CHART_SLUG = 'readiness-level-distribution' as const; +const READINESS_SCORE_CHART_SLUG = 'readiness-score-distribution' as const; + export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAssessmentChartProps) { + const levelDistExportRef = useRef(null); + const scoreDistExportRef = useRef(null); + + const levelCsvSpec = useMemo(() => { + if (!data?.distribution?.length) return null; + const total = data.summary.total_students; + return { + headers: ['Readiness level', 'Count', 'Avg score (0-1)', 'Percent of cohort'], + rows: data.distribution.map((level) => { + const pct = total > 0 ? `${((level.count / total) * 100).toFixed(1)}%` : '0%'; + return [level.readiness_level, level.count, level.avg_score, pct]; + }), + }; + }, [data]); + + const scoreCsvSpec = useMemo(() => { + if (!data?.score_distribution?.length) return null; + const total = data.summary.total_students; + return { + headers: ['Score range', 'Count', 'Percent of cohort'], + rows: data.score_distribution.map((row) => { + const pct = total > 0 ? `${((row.count / total) * 100).toFixed(1)}%` : '0%'; + return [row.score_range, row.count, pct]; + }), + }; + }, [data]); + if (isLoading) { return ( @@ -117,7 +161,6 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs const totalStudents = summary.total_students; const avgScore = parseFloat(summary.avg_score); const highPct = totalStudents > 0 ? ((summary.high_count / totalStudents) * 100).toFixed(1) : '0'; - const mediumPct = totalStudents > 0 ? ((summary.medium_count / totalStudents) * 100).toFixed(1) : '0'; const lowPct = totalStudents > 0 ? ((summary.low_count / totalStudents) * 100).toFixed(1) : '0'; return ( @@ -180,21 +223,32 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs {/* Readiness Level Distribution */} - + -
-
- Readiness Level Distribution - Student readiness categorization + Readiness Level Distribution + Student readiness categorization + + + /api/dashboard/readiness · student_level_with_predictions · + + +
+ + +

+ AI-powered assessment analyzing student preparation, engagement, and success indicators. High + readiness indicates students are well-positioned for success. +

+ +
+
+
- -

- AI-powered assessment analyzing student preparation, engagement, and success indicators. High readiness - indicates students are well-positioned for success. -

- -
-
+
@@ -228,13 +282,25 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs })}
+ {/* Score Distribution */} - + Score Distribution Readiness scores grouped by range + + + /api/dashboard/readiness · student_level_with_predictions · + + + +
@@ -260,6 +326,7 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs })}
+
{/* Top Risk Factors */} diff --git a/codebenders-dashboard/components/retention-risk-chart.tsx b/codebenders-dashboard/components/retention-risk-chart.tsx index 85201d4..7f37d97 100644 --- a/codebenders-dashboard/components/retention-risk-chart.tsx +++ b/codebenders-dashboard/components/retention-risk-chart.tsx @@ -1,8 +1,16 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useMemo, useRef } from "react" +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from "recharts" import { InfoPopover } from "@/components/info-popover" +import { + ChartExportBrandFooter, + ChartExportDataSourceLine, + ChartExportGlossaryBlurb, +} from "@/components/chart-export-card-meta" +import { ChartExportMenu } from "@/components/chart-export-menu" +import { buildCategoryCountPercentageCsv } from "@/lib/chart-export-csv" interface RetentionRiskData { category: string @@ -23,7 +31,17 @@ const COLORS = { "Low Risk": "#22c55e", // green } +const CHART_FILE_SLUG = "retention-risk-funnel" as const + export function RetentionRiskChart({ data, loading = false, info }: RetentionRiskChartProps) { + const exportRef = useRef(null) + + const csvSpec = useMemo( + () => + buildCategoryCountPercentageCsv(data, ["Risk Category", "Count", "Percentage"] as const), + [data] + ) + if (loading) { return ( @@ -68,16 +86,31 @@ export function RetentionRiskChart({ data, loading = false, info }: RetentionRis percentage: item.percentage, })) + const totalStudents = data.reduce((sum, item) => sum + Number(item.count), 0) + return ( - + -
- Retention Risk Funnel - {info && {info}} -
- - {data.reduce((sum, item) => sum + Number(item.count), 0).toLocaleString()} total students - + Retention Risk Funnel + {totalStudents.toLocaleString()} total students + + + student_level_with_predictions · retention_probability (XGBoost) · + + +
+ {info ? ( + + {info} + + ) : null} + +
+
@@ -119,6 +152,7 @@ export function RetentionRiskChart({ data, loading = false, info }: RetentionRis +
) } diff --git a/codebenders-dashboard/components/risk-alert-chart.tsx b/codebenders-dashboard/components/risk-alert-chart.tsx index c55de76..9728649 100644 --- a/codebenders-dashboard/components/risk-alert-chart.tsx +++ b/codebenders-dashboard/components/risk-alert-chart.tsx @@ -1,8 +1,16 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useMemo, useRef } from "react" +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts" import { InfoPopover } from "@/components/info-popover" +import { + ChartExportBrandFooter, + ChartExportDataSourceLine, + ChartExportGlossaryBlurb, +} from "@/components/chart-export-card-meta" +import { ChartExportMenu } from "@/components/chart-export-menu" +import { buildCategoryCountPercentageCsv } from "@/lib/chart-export-csv" interface RiskAlertData { category: string @@ -43,7 +51,17 @@ 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) { + const exportRef = useRef(null) + + const csvSpec = useMemo( + () => + buildCategoryCountPercentageCsv(data, ["Alert Level", "Count", "Percentage"] as const), + [data] + ) + if (loading) { return ( @@ -88,16 +106,31 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr percentage: item.percentage, })) + const totalStudents = data.reduce((sum, item) => sum + Number(item.count), 0) + return ( - + -
- Risk Alert Distribution - {info && {info}} -
- - {data.reduce((sum, item) => sum + Number(item.count), 0).toLocaleString()} total students - + Risk Alert Distribution + {totalStudents.toLocaleString()} total students + + + student_level_with_predictions · at_risk_alert · + + +
+ {info ? ( + + {info} + + ) : null} + +
+
@@ -130,7 +163,7 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr { + formatter={(value) => { const item = data.find(d => d.category === value) return `${value} (${item?.count.toLocaleString() || 0})` }} @@ -138,6 +171,7 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr +
) } diff --git a/codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts b/codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts index de47fd7..46e6cbe 100644 --- a/codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts +++ b/codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { GLOSSARY_TOPIC_SECTIONS, METRIC_GLOSSARY_INDEX_SLUGS } from "@/lib/glossary-constants" +import { CHART_EXPORT_GLOSSARY_PLAIN } from "@/lib/chart-export-glossary" import { parseGlossaryEntries, readMetricGlossaryMarkdown } from "@/lib/metric-glossary" describe("metric glossary coverage (#105 / #124)", () => { @@ -16,4 +17,12 @@ describe("metric glossary coverage (#105 / #124)", () => { const listedSlugs = GLOSSARY_TOPIC_SECTIONS.flatMap((t) => [...t.slugOrder]) expect(listedSlugs).toEqual([...METRIC_GLOSSARY_INDEX_SLUGS]) }) + + it("chart export blurbs cover every indexed glossary slug (#106)", () => { + for (const slug of METRIC_GLOSSARY_INDEX_SLUGS) { + const plain = CHART_EXPORT_GLOSSARY_PLAIN[slug] + expect(plain, `add Plain English to chart-export-glossary for ${slug}`).toBeTruthy() + expect(plain!.length).toBeGreaterThan(20) + } + }) }) diff --git a/codebenders-dashboard/lib/chart-export-capture.ts b/codebenders-dashboard/lib/chart-export-capture.ts new file mode 100644 index 0000000..ceebc88 --- /dev/null +++ b/codebenders-dashboard/lib/chart-export-capture.ts @@ -0,0 +1,69 @@ +import { toPng } from "html-to-image" +import { jsPDF } from "jspdf" + +import { serializeChartExportCsv, type ChartExportCsvSpec } from "@/lib/chart-export-csv" + +export function triggerFileDownload(href: string, filename: string): void { + const a = document.createElement("a") + a.href = href + a.download = filename + a.rel = "noopener" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) +} + +export async function captureElementToPngDataUrl(node: HTMLElement): Promise { + if (typeof document !== "undefined" && document.fonts?.ready) { + await document.fonts.ready + } + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + + return toPng(node, { + pixelRatio: 2, + backgroundColor: "#ffffff", + cacheBust: true, + filter: (n) => { + if (!(n instanceof HTMLElement)) return true + return !n.hasAttribute("data-chart-export-exclude") + }, + }) +} + +export function downloadDataUrl(dataUrl: string, filename: string): void { + triggerFileDownload(dataUrl, filename) +} + +export function downloadChartExportCsv(spec: ChartExportCsvSpec, filename: string): void { + const blob = new Blob([serializeChartExportCsv(spec)], { type: "text/csv;charset=utf-8" }) + const url = URL.createObjectURL(blob) + try { + triggerFileDownload(url, filename) + } finally { + URL.revokeObjectURL(url) + } +} + +export async function downloadChartPdf(node: HTMLElement, filename: string): Promise { + const dataUrl = await captureElementToPngDataUrl(node) + const img = new Image() + await new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = () => reject(new Error("Chart export: failed to load PNG for PDF")) + img.src = dataUrl + }) + + const w = img.naturalWidth + const h = img.naturalHeight + const orientation = w >= h ? "landscape" : "portrait" + const pdf = new jsPDF({ + orientation, + unit: "px", + format: [w, h], + hotfixes: ["px_scaling"], + }) + pdf.addImage(dataUrl, "PNG", 0, 0, w, h, undefined, "FAST") + pdf.save(filename) +} diff --git a/codebenders-dashboard/lib/chart-export-csv.ts b/codebenders-dashboard/lib/chart-export-csv.ts new file mode 100644 index 0000000..e15388f --- /dev/null +++ b/codebenders-dashboard/lib/chart-export-csv.ts @@ -0,0 +1,30 @@ +export interface ChartExportCsvSpec { + headers: string[] + rows: (string | number)[][] +} + +function escapeCsvCell(v: string | number): string { + const s = String(v) + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"` + return s +} + +export function serializeChartExportCsv(spec: ChartExportCsvSpec): string { + const lines = [ + spec.headers.map(escapeCsvCell).join(","), + ...spec.rows.map((row) => row.map(escapeCsvCell).join(",")), + ] + return lines.join("\n") +} + +/** CSV for charts with category, count, and percentage columns. */ +export function buildCategoryCountPercentageCsv( + data: { category: string; count: number; percentage: number }[] | null | undefined, + columnHeaders: readonly [string, string, string] +): ChartExportCsvSpec | null { + if (!data?.length) return null + return { + headers: [...columnHeaders], + rows: data.map((d) => [d.category, d.count, `${Number(d.percentage).toFixed(1)}%`]), + } +} diff --git a/codebenders-dashboard/lib/chart-export-filename.ts b/codebenders-dashboard/lib/chart-export-filename.ts new file mode 100644 index 0000000..ff7b543 --- /dev/null +++ b/codebenders-dashboard/lib/chart-export-filename.ts @@ -0,0 +1,18 @@ +/** Institution segment for export filenames (issue #106: __.ext). */ +export const CHART_EXPORT_INSTITUTION_SLUG = "bishop-state-community-college" as const + +export const CHART_EXPORT_INSTITUTION_NAME = "Bishop State Community College" as const + +export const CHART_EXPORT_BRAND_LINE = `${CHART_EXPORT_INSTITUTION_NAME} · Student Success Dashboard` as const + +export function chartExportDateStamp(d = new Date()): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, "0") + const day = String(d.getDate()).padStart(2, "0") + return `${y}-${m}-${day}` +} + +export function buildChartExportBasename(chartFileSlug: string, d = new Date()): string { + const safe = chartFileSlug.replace(/[^a-z0-9-]+/gi, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() + return `${CHART_EXPORT_INSTITUTION_SLUG}_${safe}_${chartExportDateStamp(d)}` +} diff --git a/codebenders-dashboard/lib/chart-export-glossary.ts b/codebenders-dashboard/lib/chart-export-glossary.ts new file mode 100644 index 0000000..6a2aff8 --- /dev/null +++ b/codebenders-dashboard/lib/chart-export-glossary.ts @@ -0,0 +1,31 @@ +import type { MetricGlossarySlug } from "@/lib/glossary-constants" + +/** + * Plain-English lines from `content/metric-glossary.md` (## sections). + * Keep in sync when glossary changes; used for chart PNG/PDF captions. + */ +export const CHART_EXPORT_GLOSSARY_PLAIN: Record = { + "overall-retention-rate": + 'Share of students in the selected cohort who are still enrolled (or completed) one year later — a common "year-to-year" retention view for the students you are filtering.', + "avg-predicted-retention": + "Average of the model's estimated probability (0-100%) that each student in the filtered set will retain — not the same as historical retention above.", + "students-at-high-critical-risk": + "Count of students whose composite risk score places them in the HIGH or URGENT alert bands used for intervention triage.", + "avg-course-completion": + "Credits successfully completed divided by credits attempted, expressed as a percentage, aggregated across students in the filter.", + "risk-alert-distribution": + "Pie chart of students grouped by composite risk alert band (LOW, MODERATE, HIGH, URGENT) used for triage — not the same as the retention-probability-only funnel chart.", + "retention-risk-funnel": + "Horizontal bar chart of students by model retention risk category (Critical / High / Moderate / Low) from predicted retention probability alone.", + "readiness-assessment": + "PDP-aligned readiness index — composite score and High / Medium / Low bands summarizing academic, engagement, and ML-risk components for the filtered cohort.", +} + +const SENTENCE_END = /(?<=[.!?])\s+/ + +/** Up to two sentences for slide-friendly captions. */ +export function getChartExportBlurb(slug: MetricGlossarySlug): string { + const text = CHART_EXPORT_GLOSSARY_PLAIN[slug] + const parts = text.split(SENTENCE_END).filter(Boolean) + return parts.slice(0, 2).join(" ") +} diff --git a/codebenders-dashboard/package.json b/codebenders-dashboard/package.json index 37a8483..03ac6d2 100644 --- a/codebenders-dashboard/package.json +++ b/codebenders-dashboard/package.json @@ -20,6 +20,8 @@ "ai": "^5.0.81", "class-variance-authority": "^0.7.1", "clsx": "2.1.1", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", "lucide-react": "0.548.0", "next": "^16.1.6", "papaparse": "^5.5.3", @@ -34,7 +36,6 @@ "zod": "^3.24.1" }, "devDependencies": { - "yaml": "^2.8.0", "@types/node": "24.9.2", "@types/papaparse": "^5.5.2", "@types/pg": "^8.16.0", @@ -46,6 +47,7 @@ "tailwindcss": "4.1.16", "tsx": "^4.21.0", "typescript": "5.9.3", - "vitest": "^4.1.2" + "vitest": "^4.1.2", + "yaml": "^2.8.0" } }