From 0d7113944b72053d910b1fe2ed168fcdd540ff07 Mon Sep 17 00:00:00 2001 From: William Hill Date: Sun, 3 May 2026 13:49:50 -0400 Subject: [PATCH 1/2] feat(dashboard): PNG/PDF chart export with glossary captions (#106) Add per-chart Export menu (CSV, PNG, PDF) on home risk/retention charts and readiness distribution cards. Captions use Plain English lines from the metric glossary, data source and generated date, and institution branding. Filenames follow bishop-state-community-college__YYYY-MM-DD. Client-side capture via html-to-image; PDF wraps the PNG with jsPDF. Co-authored-by: Cursor --- .../components/chart-export-menu.tsx | 138 ++++++++++++++++++ .../components/readiness-assessment-chart.tsx | 106 ++++++++++++-- .../components/retention-risk-chart.tsx | 60 ++++++-- .../components/risk-alert-chart.tsx | 62 ++++++-- .../metric-glossary-coverage.test.ts | 9 ++ .../lib/chart-export-capture.ts | 53 +++++++ .../lib/chart-export-filename.ts | 18 +++ .../lib/chart-export-glossary.ts | 31 ++++ codebenders-dashboard/package.json | 6 +- 9 files changed, 446 insertions(+), 37 deletions(-) create mode 100644 codebenders-dashboard/components/chart-export-menu.tsx create mode 100644 codebenders-dashboard/lib/chart-export-capture.ts create mode 100644 codebenders-dashboard/lib/chart-export-filename.ts create mode 100644 codebenders-dashboard/lib/chart-export-glossary.ts diff --git a/codebenders-dashboard/components/chart-export-menu.tsx b/codebenders-dashboard/components/chart-export-menu.tsx new file mode 100644 index 0000000..02a8a2d --- /dev/null +++ b/codebenders-dashboard/components/chart-export-menu.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useCallback, useState } 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, downloadChartPdf, downloadDataUrl } from "@/lib/chart-export-capture" + +export interface ChartExportCsvSpec { + headers: string[] + rows: (string | number)[][] +} + +interface ChartExportMenuProps { + exportRef: React.RefObject + chartFileSlug: string + csv?: ChartExportCsvSpec | null + disabled?: boolean + /** Report capture failures (e.g. toast); defaults to console.error */ + onError?: (message: string) => void +} + +function escapeCsvCell(v: string | number): string { + const s = String(v) + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"` + return s +} + +function downloadChartCsv(spec: ChartExportCsvSpec, basename: string): void { + const lines = [ + spec.headers.map(escapeCsvCell).join(","), + ...spec.rows.map((row) => row.map(escapeCsvCell).join(",")), + ] + const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${basename}.csv` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +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) + downloadChartCsv(csv, basename) + }, [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..b421ae9 100644 --- a/codebenders-dashboard/components/readiness-assessment-chart.tsx +++ b/codebenders-dashboard/components/readiness-assessment-chart.tsx @@ -1,11 +1,23 @@ 'use client'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useMemo, useRef } from 'react'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + 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 { ChartExportMenu } from '@/components/chart-export-menu'; +import { getChartExportBlurb } from '@/lib/chart-export-glossary'; +import { CHART_EXPORT_BRAND_LINE } from '@/lib/chart-export-filename'; interface ReadinessData { summary: { @@ -61,6 +73,33 @@ interface ReadinessAssessmentChartProps { } 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 +156,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 +218,36 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs {/* Readiness Level Distribution */} - + -
-
- Readiness Level Distribution - Student readiness categorization + Readiness Level Distribution + Student readiness categorization +

+ {getChartExportBlurb('readiness-assessment')} +

+

+ Data source: /api/dashboard/readiness · + student_level_with_predictions ·{' '} + Generated: {new Date().toLocaleDateString()} +

+ +
+ + +

+ 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 +281,31 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs })}
+ + {CHART_EXPORT_BRAND_LINE} + {/* Score Distribution */} - + Score Distribution Readiness scores grouped by range +

+ {getChartExportBlurb('readiness-assessment')} +

+

+ Data source: /api/dashboard/readiness · + student_level_with_predictions ·{' '} + Generated: {new Date().toLocaleDateString()} +

+ + +
@@ -260,6 +331,9 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs })}
+ + {CHART_EXPORT_BRAND_LINE} +
{/* Top Risk Factors */} diff --git a/codebenders-dashboard/components/retention-risk-chart.tsx b/codebenders-dashboard/components/retention-risk-chart.tsx index 85201d4..075f14a 100644 --- a/codebenders-dashboard/components/retention-risk-chart.tsx +++ b/codebenders-dashboard/components/retention-risk-chart.tsx @@ -1,8 +1,12 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useMemo, useRef } from "react" +import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from "recharts" import { InfoPopover } from "@/components/info-popover" +import { ChartExportMenu } from "@/components/chart-export-menu" +import { getChartExportBlurb } from "@/lib/chart-export-glossary" +import { CHART_EXPORT_BRAND_LINE } from "@/lib/chart-export-filename" interface RetentionRiskData { category: string @@ -23,7 +27,22 @@ 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( + () => + data?.length + ? { + headers: ["Risk Category", "Count", "Percentage"], + rows: data.map((d) => [d.category, d.count, `${Number(d.percentage).toFixed(1)}%`]), + } + : null, + [data] + ) + if (loading) { return ( @@ -68,16 +87,36 @@ 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 +

+ {getChartExportBlurb("retention-risk-funnel")} +

+

+ Data source:{" "} + student_level_with_predictions · retention_probability (XGBoost) ·{" "} + Generated:{" "} + {new Date().toLocaleDateString()} +

+ +
+ {info ? ( + + {info} + + ) : null} + +
+
@@ -119,6 +158,9 @@ export function RetentionRiskChart({ data, loading = false, info }: RetentionRis + + {CHART_EXPORT_BRAND_LINE} +
) } diff --git a/codebenders-dashboard/components/risk-alert-chart.tsx b/codebenders-dashboard/components/risk-alert-chart.tsx index c55de76..2be9e75 100644 --- a/codebenders-dashboard/components/risk-alert-chart.tsx +++ b/codebenders-dashboard/components/risk-alert-chart.tsx @@ -1,8 +1,12 @@ "use client" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useMemo, useRef } from "react" +import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts" import { InfoPopover } from "@/components/info-popover" +import { ChartExportMenu } from "@/components/chart-export-menu" +import { getChartExportBlurb } from "@/lib/chart-export-glossary" +import { CHART_EXPORT_BRAND_LINE } from "@/lib/chart-export-filename" interface RiskAlertData { category: string @@ -43,7 +47,22 @@ 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( + () => + data?.length + ? { + headers: ["Alert Level", "Count", "Percentage"], + rows: data.map((d) => [d.category, d.count, `${Number(d.percentage).toFixed(1)}%`]), + } + : null, + [data] + ) + if (loading) { return ( @@ -88,16 +107,36 @@ 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 +

+ {getChartExportBlurb("risk-alert-distribution")} +

+

+ Data source:{" "} + student_level_with_predictions · at_risk_alert ·{" "} + Generated:{" "} + {new Date().toLocaleDateString()} +

+ +
+ {info ? ( + + {info} + + ) : null} + +
+
@@ -130,7 +169,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 +177,9 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr + + {CHART_EXPORT_BRAND_LINE} +
) } 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..5674adc --- /dev/null +++ b/codebenders-dashboard/lib/chart-export-capture.ts @@ -0,0 +1,53 @@ +import { toPng } from "html-to-image" +import { jsPDF } from "jspdf" + +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 { + const a = document.createElement("a") + a.href = dataUrl + a.download = filename + a.rel = "noopener" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) +} + +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-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" } } From 7bbae5f3d5947a6bce5bb5cd4f022be0862b479e Mon Sep 17 00:00:00 2001 From: William Hill Date: Sun, 3 May 2026 14:35:34 -0400 Subject: [PATCH 2/2] refactor(dashboard): dedupe chart export CSV and card chrome (#106) Extract chart-export-csv, chart-export-card-meta, and shared download/CSV helpers; keep behavior identical to PNG/PDF glossary export. Co-authored-by: Cursor --- .../components/chart-export-card-meta.tsx | 34 ++++++++++++++ .../components/chart-export-menu.tsx | 41 ++++------------ .../components/readiness-assessment-chart.tsx | 47 ++++++++----------- .../components/retention-risk-chart.tsx | 34 +++++--------- .../components/risk-alert-chart.tsx | 34 +++++--------- .../lib/chart-export-capture.ts | 30 +++++++++--- codebenders-dashboard/lib/chart-export-csv.ts | 30 ++++++++++++ 7 files changed, 143 insertions(+), 107 deletions(-) create mode 100644 codebenders-dashboard/components/chart-export-card-meta.tsx create mode 100644 codebenders-dashboard/lib/chart-export-csv.ts 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 index 02a8a2d..8ca42f0 100644 --- a/codebenders-dashboard/components/chart-export-menu.tsx +++ b/codebenders-dashboard/components/chart-export-menu.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useState } from "react" +import { useCallback, useState, type RefObject } from "react" import { Download, FileImage, FileSpreadsheet, FileType } from "lucide-react" import { Button } from "@/components/ui/button" import { @@ -12,15 +12,16 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { buildChartExportBasename } from "@/lib/chart-export-filename" -import { captureElementToPngDataUrl, downloadChartPdf, downloadDataUrl } from "@/lib/chart-export-capture" - -export interface ChartExportCsvSpec { - headers: string[] - rows: (string | number)[][] -} +import { + captureElementToPngDataUrl, + downloadChartExportCsv, + downloadChartPdf, + downloadDataUrl, +} from "@/lib/chart-export-capture" +import type { ChartExportCsvSpec } from "@/lib/chart-export-csv" interface ChartExportMenuProps { - exportRef: React.RefObject + exportRef: RefObject chartFileSlug: string csv?: ChartExportCsvSpec | null disabled?: boolean @@ -28,28 +29,6 @@ interface ChartExportMenuProps { onError?: (message: string) => void } -function escapeCsvCell(v: string | number): string { - const s = String(v) - if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"` - return s -} - -function downloadChartCsv(spec: ChartExportCsvSpec, basename: string): void { - const lines = [ - spec.headers.map(escapeCsvCell).join(","), - ...spec.rows.map((row) => row.map(escapeCsvCell).join(",")), - ] - const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${basename}.csv` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) -} - export function ChartExportMenu({ exportRef, chartFileSlug, @@ -88,7 +67,7 @@ export function ChartExportMenu({ const onCsv = useCallback(() => { if (!csv) return const basename = buildChartExportBasename(chartFileSlug) - downloadChartCsv(csv, basename) + downloadChartExportCsv(csv, `${basename}.csv`) }, [chartFileSlug, csv]) return ( diff --git a/codebenders-dashboard/components/readiness-assessment-chart.tsx b/codebenders-dashboard/components/readiness-assessment-chart.tsx index b421ae9..004d000 100644 --- a/codebenders-dashboard/components/readiness-assessment-chart.tsx +++ b/codebenders-dashboard/components/readiness-assessment-chart.tsx @@ -6,7 +6,6 @@ import { CardAction, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; @@ -15,9 +14,12 @@ 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'; -import { getChartExportBlurb } from '@/lib/chart-export-glossary'; -import { CHART_EXPORT_BRAND_LINE } from '@/lib/chart-export-filename'; interface ReadinessData { summary: { @@ -72,6 +74,9 @@ 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); @@ -222,14 +227,10 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs Readiness Level Distribution Student readiness categorization -

- {getChartExportBlurb('readiness-assessment')} -

-

- Data source: /api/dashboard/readiness · - student_level_with_predictions ·{' '} - Generated: {new Date().toLocaleDateString()} -

+ + + /api/dashboard/readiness · student_level_with_predictions · +
@@ -243,7 +244,7 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs
@@ -281,9 +282,7 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs })}
- - {CHART_EXPORT_BRAND_LINE} - +
{/* Score Distribution */} @@ -291,18 +290,14 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs Score Distribution Readiness scores grouped by range -

- {getChartExportBlurb('readiness-assessment')} -

-

- Data source: /api/dashboard/readiness · - student_level_with_predictions ·{' '} - Generated: {new Date().toLocaleDateString()} -

+ + + /api/dashboard/readiness · student_level_with_predictions · + @@ -331,9 +326,7 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs })} - - {CHART_EXPORT_BRAND_LINE} - +
{/* Top Risk Factors */} diff --git a/codebenders-dashboard/components/retention-risk-chart.tsx b/codebenders-dashboard/components/retention-risk-chart.tsx index 075f14a..7f37d97 100644 --- a/codebenders-dashboard/components/retention-risk-chart.tsx +++ b/codebenders-dashboard/components/retention-risk-chart.tsx @@ -1,12 +1,16 @@ "use client" import { useMemo, useRef } from "react" -import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +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 { getChartExportBlurb } from "@/lib/chart-export-glossary" -import { CHART_EXPORT_BRAND_LINE } from "@/lib/chart-export-filename" +import { buildCategoryCountPercentageCsv } from "@/lib/chart-export-csv" interface RetentionRiskData { category: string @@ -34,12 +38,7 @@ export function RetentionRiskChart({ data, loading = false, info }: RetentionRis const csvSpec = useMemo( () => - data?.length - ? { - headers: ["Risk Category", "Count", "Percentage"], - rows: data.map((d) => [d.category, d.count, `${Number(d.percentage).toFixed(1)}%`]), - } - : null, + buildCategoryCountPercentageCsv(data, ["Risk Category", "Count", "Percentage"] as const), [data] ) @@ -94,15 +93,10 @@ export function RetentionRiskChart({ data, loading = false, info }: RetentionRis Retention Risk Funnel {totalStudents.toLocaleString()} total students -

- {getChartExportBlurb("retention-risk-funnel")} -

-

- Data source:{" "} - student_level_with_predictions · retention_probability (XGBoost) ·{" "} - Generated:{" "} - {new Date().toLocaleDateString()} -

+ + + student_level_with_predictions · retention_probability (XGBoost) · +
{info ? ( @@ -158,9 +152,7 @@ export function RetentionRiskChart({ data, loading = false, info }: RetentionRis - - {CHART_EXPORT_BRAND_LINE} - + ) } diff --git a/codebenders-dashboard/components/risk-alert-chart.tsx b/codebenders-dashboard/components/risk-alert-chart.tsx index 2be9e75..9728649 100644 --- a/codebenders-dashboard/components/risk-alert-chart.tsx +++ b/codebenders-dashboard/components/risk-alert-chart.tsx @@ -1,12 +1,16 @@ "use client" import { useMemo, useRef } from "react" -import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +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 { getChartExportBlurb } from "@/lib/chart-export-glossary" -import { CHART_EXPORT_BRAND_LINE } from "@/lib/chart-export-filename" +import { buildCategoryCountPercentageCsv } from "@/lib/chart-export-csv" interface RiskAlertData { category: string @@ -54,12 +58,7 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr const csvSpec = useMemo( () => - data?.length - ? { - headers: ["Alert Level", "Count", "Percentage"], - rows: data.map((d) => [d.category, d.count, `${Number(d.percentage).toFixed(1)}%`]), - } - : null, + buildCategoryCountPercentageCsv(data, ["Alert Level", "Count", "Percentage"] as const), [data] ) @@ -114,15 +113,10 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr Risk Alert Distribution {totalStudents.toLocaleString()} total students -

- {getChartExportBlurb("risk-alert-distribution")} -

-

- Data source:{" "} - student_level_with_predictions · at_risk_alert ·{" "} - Generated:{" "} - {new Date().toLocaleDateString()} -

+ + + student_level_with_predictions · at_risk_alert · +
{info ? ( @@ -177,9 +171,7 @@ export function RiskAlertChart({ data, loading = false, info }: RiskAlertChartPr - - {CHART_EXPORT_BRAND_LINE} - + ) } diff --git a/codebenders-dashboard/lib/chart-export-capture.ts b/codebenders-dashboard/lib/chart-export-capture.ts index 5674adc..ceebc88 100644 --- a/codebenders-dashboard/lib/chart-export-capture.ts +++ b/codebenders-dashboard/lib/chart-export-capture.ts @@ -1,6 +1,18 @@ 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 @@ -21,13 +33,17 @@ export async function captureElementToPngDataUrl(node: HTMLElement): Promise { 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)}%`]), + } +}