+ 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.
-
-
- {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
+
)
}
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"
}
}