Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions codebenders-dashboard/components/chart-export-card-meta.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-xs text-muted-foreground leading-snug max-w-prose">
{getChartExportBlurb(props.slug)}
</p>
)
}

export function ChartExportDataSourceLine(props: { children: ReactNode }): ReactElement {
return (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground/90">Data source:</span> {props.children}{" "}
<span className="font-medium text-foreground/90">Generated:</span>{" "}
{new Date().toLocaleDateString()}
</p>
)
}

export function ChartExportBrandFooter(): ReactElement {
return (
<CardFooter className="border-t border-border pt-6 text-xs text-muted-foreground">
{CHART_EXPORT_BRAND_LINE}
</CardFooter>
)
}
117 changes: 117 additions & 0 deletions codebenders-dashboard/components/chart-export-menu.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1 h-8"
disabled={disabled || busy}
data-chart-export-exclude
aria-label="Export chart"
>
<Download className="h-3.5 w-3.5" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Export chart</DropdownMenuLabel>
<DropdownMenuSeparator />
{csv ? (
<DropdownMenuItem onClick={onCsv} className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
CSV
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() => void runExport("png")}
disabled={busy}
className="gap-2"
>
<FileImage className="h-4 w-4" />
PNG
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void runExport("pdf")}
disabled={busy}
className="gap-2"
>
<FileType className="h-4 w-4" />
PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
99 changes: 83 additions & 16 deletions codebenders-dashboard/components/readiness-assessment-chart.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const scoreDistExportRef = useRef<HTMLDivElement>(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 (
<Card className="w-full">
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -180,21 +223,32 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs
</div>

{/* Readiness Level Distribution */}
<Card>
<Card ref={levelDistExportRef}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Readiness Level Distribution</CardTitle>
<CardDescription>Student readiness categorization</CardDescription>
<CardTitle>Readiness Level Distribution</CardTitle>
<CardDescription>Student readiness categorization</CardDescription>
<ChartExportGlossaryBlurb slug="readiness-assessment" />
<ChartExportDataSourceLine>
/api/dashboard/readiness · student_level_with_predictions ·
</ChartExportDataSourceLine>
<CardAction>
<div className="flex items-center gap-1">
<span data-chart-export-exclude>
<InfoPopover title="Readiness Assessment">
<p>
AI-powered assessment analyzing student preparation, engagement, and success indicators. High
readiness indicates students are well-positioned for success.
</p>
<GlossaryMetricEntryLink slug="readiness-assessment" />
</InfoPopover>
</span>
<ChartExportMenu
exportRef={levelDistExportRef}
chartFileSlug={READINESS_LEVEL_CHART_SLUG}
csv={levelCsvSpec}
/>
</div>
<InfoPopover title="Readiness Assessment">
<p>
AI-powered assessment analyzing student preparation, engagement, and success indicators. High readiness
indicates students are well-positioned for success.
</p>
<GlossaryMetricEntryLink slug="readiness-assessment" />
</InfoPopover>
</div>
</CardAction>
</CardHeader>
<CardContent>
<div className="space-y-4">
Expand Down Expand Up @@ -228,13 +282,25 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs
})}
</div>
</CardContent>
<ChartExportBrandFooter />
</Card>

{/* Score Distribution */}
<Card>
<Card ref={scoreDistExportRef}>
<CardHeader>
<CardTitle>Score Distribution</CardTitle>
<CardDescription>Readiness scores grouped by range</CardDescription>
<ChartExportGlossaryBlurb slug="readiness-assessment" />
<ChartExportDataSourceLine>
/api/dashboard/readiness · student_level_with_predictions ·
</ChartExportDataSourceLine>
<CardAction>
<ChartExportMenu
exportRef={scoreDistExportRef}
chartFileSlug={READINESS_SCORE_CHART_SLUG}
csv={scoreCsvSpec}
/>
</CardAction>
</CardHeader>
<CardContent>
<div className="space-y-3">
Expand All @@ -260,6 +326,7 @@ export function ReadinessAssessmentChart({ data, isLoading, error }: ReadinessAs
})}
</div>
</CardContent>
<ChartExportBrandFooter />
</Card>

{/* Top Risk Factors */}
Expand Down
52 changes: 43 additions & 9 deletions codebenders-dashboard/components/retention-risk-chart.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<HTMLDivElement>(null)

const csvSpec = useMemo(
() =>
buildCategoryCountPercentageCsv(data, ["Risk Category", "Count", "Percentage"] as const),
[data]
)

if (loading) {
return (
<Card>
Expand Down Expand Up @@ -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 (
<Card>
<Card ref={exportRef}>
<CardHeader>
<div className="flex items-center">
<CardTitle>Retention Risk Funnel</CardTitle>
{info && <InfoPopover title="Retention Risk Funnel">{info}</InfoPopover>}
</div>
<CardDescription>
{data.reduce((sum, item) => sum + Number(item.count), 0).toLocaleString()} total students
</CardDescription>
<CardTitle>Retention Risk Funnel</CardTitle>
<CardDescription>{totalStudents.toLocaleString()} total students</CardDescription>
<ChartExportGlossaryBlurb slug="retention-risk-funnel" />
<ChartExportDataSourceLine>
student_level_with_predictions · retention_probability (XGBoost) ·
</ChartExportDataSourceLine>
<CardAction>
<div className="flex items-center gap-1">
{info ? (
<span data-chart-export-exclude>
<InfoPopover title="Retention Risk Funnel">{info}</InfoPopover>
</span>
) : null}
<ChartExportMenu
exportRef={exportRef}
chartFileSlug={CHART_FILE_SLUG}
csv={csvSpec}
/>
</div>
</CardAction>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
Expand Down Expand Up @@ -119,6 +152,7 @@ export function RetentionRiskChart({ data, loading = false, info }: RetentionRis
</BarChart>
</ResponsiveContainer>
</CardContent>
<ChartExportBrandFooter />
</Card>
)
}
Expand Down
Loading
Loading