From 0de2ded74788a49c12718537663e166d0fa77ba0 Mon Sep 17 00:00:00 2001 From: William Hill Date: Sun, 3 May 2026 12:35:55 -0400 Subject: [PATCH 1/2] feat(glossary): metric definitions page + KPI cross-walks (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic #124 slice: add content/metric-glossary.md (PDP / IPEDS / state notes), /glossary with topic + A–Z nav, nav link, deep links from dashboard KPI tooltips, and vitest coverage for required slugs. Co-authored-by: Cursor --- codebenders-dashboard/app/glossary/page.tsx | 117 ++++++++++++++++++ codebenders-dashboard/app/page.tsx | 21 ++++ .../components/nav-header.tsx | 2 + .../content/metric-glossary.md | 51 ++++++++ .../metric-glossary-coverage.test.ts | 26 ++++ .../lib/glossary-constants.ts | 37 ++++++ codebenders-dashboard/lib/metric-glossary.ts | 36 ++++++ 7 files changed, 290 insertions(+) create mode 100644 codebenders-dashboard/app/glossary/page.tsx create mode 100644 codebenders-dashboard/content/metric-glossary.md create mode 100644 codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts create mode 100644 codebenders-dashboard/lib/glossary-constants.ts create mode 100644 codebenders-dashboard/lib/metric-glossary.ts diff --git a/codebenders-dashboard/app/glossary/page.tsx b/codebenders-dashboard/app/glossary/page.tsx new file mode 100644 index 0000000..01818fb --- /dev/null +++ b/codebenders-dashboard/app/glossary/page.tsx @@ -0,0 +1,117 @@ +import Link from "next/link" +import { GLOSSARY_TOPIC_SECTIONS } from "@/lib/glossary-constants" +import { parseGlossaryEntries, readMetricGlossaryMarkdown } from "@/lib/metric-glossary" + +function GlossaryBody({ text }: { text: string }) { + const paragraphs = text.split(/\n\n+/).filter(Boolean) + return ( +
+ {paragraphs.map((p, i) => { + const boldLead = /^\*\*([^*]+)\*\*:\s*([\s\S]*)$/.exec(p) + if (boldLead) { + return ( +

+ {boldLead[1]}: {boldLead[2]} +

+ ) + } + return

{p}

+ })} +
+ ) +} + +export default function GlossaryPage() { + const md = readMetricGlossaryMarkdown() + const entries = parseGlossaryEntries(md) + const allSlugs = new Set(Object.keys(entries)) + const orderedTopics = GLOSSARY_TOPIC_SECTIONS.map((t) => ({ + ...t, + slugs: t.slugOrder.filter((s) => allSlugs.has(s)), + })) + + const alphaSlugs = [...allSlugs].sort((a, b) => a.localeCompare(b)) + + return ( +
+
+
+

Metric glossary

+

+ In-context definitions for dashboard KPIs, with PDP field notes and high-level IPEDS / state + cross-walks. Source:{" "} + content/metric-glossary.md +

+

+ + Back to dashboard + + {" · "} + + Methodology + +

+
+ + + + {orderedTopics.map((topic) => ( +
+

{topic.label}

+ {topic.slugs.map((slug) => { + const body = entries[slug] + if (!body) return null + const title = slug.replace(/-/g, " ") + return ( +
+

{title}

+ +
+ ) + })} +
+ ))} + + +
+
+ ) +} diff --git a/codebenders-dashboard/app/page.tsx b/codebenders-dashboard/app/page.tsx index 9fd84a5..53f516d 100644 --- a/codebenders-dashboard/app/page.tsx +++ b/codebenders-dashboard/app/page.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/select" import { TrendingUp, Users, AlertTriangle, BookOpen, Search, Table2, X } from "lucide-react" import Link from "next/link" +import { GLOSSARY_HREF } from "@/lib/glossary-constants" interface KPIData { overallRetentionRate: string @@ -275,6 +276,11 @@ export default function DashboardPage() {

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

Data source: Retention field from student cohort records (0=Not Retained, 1=Retained).

Use for: Baseline institutional performance metric.

+

+ + Full glossary entry (PDP / IPEDS cross-walk) → + +

} /> @@ -295,6 +301,11 @@ export default function DashboardPage() {
  • First Year GPA (2.9%)
  • Use for: Early identification of at-risk students for proactive intervention.

    +

    + + Full glossary entry (PDP / IPEDS cross-walk) → + +

    } /> @@ -319,6 +330,11 @@ export default function DashboardPage() {
  • HIGH: Priority intervention
  • Recommended actions: Immediate advisor outreach, financial aid review, tutoring referrals.

    +

    + + Full glossary entry (PDP / IPEDS cross-walk) → + +

    } /> @@ -339,6 +355,11 @@ export default function DashboardPage() {
  • <50%: Critical - failing nearly half of courses
  • Why it matters: Strong predictor of retention and credential completion.

    +

    + + Full glossary entry (PDP / IPEDS cross-walk) → + +

    } /> diff --git a/codebenders-dashboard/components/nav-header.tsx b/codebenders-dashboard/components/nav-header.tsx index d995b48..19c0e5d 100644 --- a/codebenders-dashboard/components/nav-header.tsx +++ b/codebenders-dashboard/components/nav-header.tsx @@ -6,6 +6,7 @@ import { GraduationCap, LogOut } from "lucide-react" import { Button } from "@/components/ui/button" import { signOut } from "@/app/actions/auth" import { AI_TRANSPARENCY_HREF } from "@/content/ai-transparency" +import { GLOSSARY_HREF } from "@/lib/glossary-constants" import { ROLE_COLORS, ROLE_LABELS, type Role } from "@/lib/roles" interface NavHeaderProps { @@ -15,6 +16,7 @@ interface NavHeaderProps { const NAV_LINKS: Array<{ href: string; label: string; roles?: Role[] }> = [ { href: "/", label: "Dashboard" }, + { href: GLOSSARY_HREF, label: "Glossary" }, { href: "/courses", label: "Courses" }, { href: "/students", label: "Students" }, { href: "/query", label: "Query" }, diff --git a/codebenders-dashboard/content/metric-glossary.md b/codebenders-dashboard/content/metric-glossary.md new file mode 100644 index 0000000..2c733ec --- /dev/null +++ b/codebenders-dashboard/content/metric-glossary.md @@ -0,0 +1,51 @@ +# Metric glossary + +Single source of truth for dashboard KPI definitions. Each section is keyed by its URL anchor (e.g. `/glossary#overall-retention-rate`). Cross-walks are indicative — institutions should confirm against their PDP documentation, IPEDS submission manuals, and state reporting rules. + +--- + +## overall-retention-rate + +**Plain English:** 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. + +**PDP / analysis-ready:** Uses the cohort retention indicator on `student_level_with_predictions` (historical `Retention` field: 0 = not retained to the next year, 1 = retained) after filters (cohort, enrollment intensity, credential goal) are applied. + +**IPEDS:** Closest published analog is *Fall cohort retention* for first-time full-time students; PDP cohorts may include part-time or mixed populations, so percentages will not match IPEDS one-to-one without aligning cohort definitions. + +**State compliance:** Many state accountability dashboards publish “first-year retention” or “persistence”; map this metric to the state field that uses the same cohort and time window. + +--- + +## avg-predicted-retention + +**Plain English:** 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. + +**PDP / analysis-ready:** Mean of `retention_probability` from the deployed XGBoost retention model on `student_level_with_predictions`. + +**IPEDS:** No direct IPEDS submission — this is an institutional analytics prediction, not an audited outcome count. + +**State compliance:** Treat as early-warning / planning metric unless your state explicitly allows predictive indicators in reporting. + +--- + +## students-at-high-critical-risk + +**Plain English:** Count of students whose composite risk score places them in the **HIGH** or **URGENT** alert bands used for intervention triage. + +**PDP / analysis-ready:** Derived from `at_risk_alert` and related thresholds on `student_level_with_predictions` (see dashboard methodology for the composite formula). + +**IPEDS:** Not an IPEDS field; comparable to internal early-alert counts only. + +**State compliance:** Use for operations; confirm before exporting small subgroup counts externally (FERPA / small-N policies). + +--- + +## avg-course-completion + +**Plain English:** Credits successfully completed divided by credits attempted, expressed as a percentage, aggregated across students in the filter. + +**PDP / analysis-ready:** Computed from course completion fields on `student_level_with_predictions` (credits attempted vs. earned in the modeled year window). + +**IPEDS:** Conceptually related to success rates and progression, but IPEDS collects many distinct measures (e.g., completions by award) — do not assume identity without a written cross-walk. + +**State compliance:** Often parallels “success rate” or “credit completion ratio” in performance funding models; verify denominator rules match your state formula. diff --git a/codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts b/codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts new file mode 100644 index 0000000..debf689 --- /dev/null +++ b/codebenders-dashboard/lib/__tests__/metric-glossary-coverage.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" +import { + DASHBOARD_KPI_GLOSSARY_SLUGS, + GLOSSARY_TOPIC_SECTIONS, +} from "@/lib/glossary-constants" +import { parseGlossaryEntries, readMetricGlossaryMarkdown } from "@/lib/metric-glossary" + +describe("metric glossary coverage (#105)", () => { + it("includes every dashboard KPI slug in metric-glossary.md", () => { + const md = readMetricGlossaryMarkdown() + const entries = parseGlossaryEntries(md) + for (const slug of DASHBOARD_KPI_GLOSSARY_SLUGS) { + expect(entries[slug], `missing ## ${slug} in content/metric-glossary.md`).toBeTruthy() + expect(entries[slug]!.length).toBeGreaterThan(20) + } + }) + + it("topic sections list each KPI slug exactly once", () => { + const listed = GLOSSARY_TOPIC_SECTIONS.flatMap((t) => [...t.slugOrder]) + expect(listed.length).toBe(DASHBOARD_KPI_GLOSSARY_SLUGS.length) + for (const slug of DASHBOARD_KPI_GLOSSARY_SLUGS) { + const n = listed.filter((s) => s === slug).length + expect(n, `slug ${slug} should appear once in GLOSSARY_TOPIC_SECTIONS`).toBe(1) + } + }) +}) diff --git a/codebenders-dashboard/lib/glossary-constants.ts b/codebenders-dashboard/lib/glossary-constants.ts new file mode 100644 index 0000000..99158a4 --- /dev/null +++ b/codebenders-dashboard/lib/glossary-constants.ts @@ -0,0 +1,37 @@ +/** Nav and deep links — safe to import from client components. */ + +export const GLOSSARY_HREF = "/glossary" as const + +/** + * Every slug under `##` in `content/metric-glossary.md` for dashboard KPIs must stay in sync + * (see `lib/__tests__/metric-glossary-coverage.test.ts`). + */ +export const DASHBOARD_KPI_GLOSSARY_SLUGS = [ + "overall-retention-rate", + "avg-predicted-retention", + "students-at-high-critical-risk", + "avg-course-completion", +] as const + +export type DashboardKpiGlossarySlug = (typeof DASHBOARD_KPI_GLOSSARY_SLUGS)[number] + +export const GLOSSARY_TOPIC_SECTIONS: { + id: string + label: string + slugOrder: readonly DashboardKpiGlossarySlug[] +}[] = [ + { + id: "retention", + label: "Retention, risk & predictions", + slugOrder: [ + "overall-retention-rate", + "avg-predicted-retention", + "students-at-high-critical-risk", + ], + }, + { + id: "completion", + label: "Completion & course success", + slugOrder: ["avg-course-completion"], + }, +] diff --git a/codebenders-dashboard/lib/metric-glossary.ts b/codebenders-dashboard/lib/metric-glossary.ts new file mode 100644 index 0000000..416a714 --- /dev/null +++ b/codebenders-dashboard/lib/metric-glossary.ts @@ -0,0 +1,36 @@ +import fs from "fs" +import path from "path" + +export function readMetricGlossaryMarkdown(): string { + const file = path.join(process.cwd(), "content", "metric-glossary.md") + return fs.readFileSync(file, "utf8") +} + +/** Map slug → markdown body (text below `## slug`). */ +export function parseGlossaryEntries(md: string): Record { + const out: Record = {} + const lines = md.split(/\n/) + let current: string | null = null + const buf: string[] = [] + + const flush = () => { + if (current) out[current] = buf.join("\n").trim() + buf.length = 0 + } + + for (const line of lines) { + const m = /^## ([a-z0-9-]+)\s*$/.exec(line) + if (m) { + flush() + current = m[1] + } else if (current) { + buf.push(line) + } + } + flush() + return out +} + +export function glossarySlugSet(md: string): Set { + return new Set(Object.keys(parseGlossaryEntries(md))) +} From ac0c1412699fbb62ec759b6af33f01c14b818052 Mon Sep 17 00:00:00 2001 From: William Hill Date: Sun, 3 May 2026 12:38:34 -0400 Subject: [PATCH 2/2] refactor(glossary): derive KPI slugs from topic sections, centralize topic blocks Single raw topic config, buildGlossaryTopicBlocks + display helper; tests/build unchanged. Co-authored-by: Cursor --- codebenders-dashboard/app/glossary/page.tsx | 32 +++++++------- .../lib/glossary-constants.ts | 43 +++++++++++-------- codebenders-dashboard/lib/metric-glossary.ts | 19 ++++++-- 3 files changed, 56 insertions(+), 38 deletions(-) diff --git a/codebenders-dashboard/app/glossary/page.tsx b/codebenders-dashboard/app/glossary/page.tsx index 01818fb..2ce15c5 100644 --- a/codebenders-dashboard/app/glossary/page.tsx +++ b/codebenders-dashboard/app/glossary/page.tsx @@ -1,13 +1,19 @@ import Link from "next/link" -import { GLOSSARY_TOPIC_SECTIONS } from "@/lib/glossary-constants" -import { parseGlossaryEntries, readMetricGlossaryMarkdown } from "@/lib/metric-glossary" +import { + buildGlossaryTopicBlocks, + formatGlossarySlugForDisplay, + parseGlossaryEntries, + readMetricGlossaryMarkdown, +} from "@/lib/metric-glossary" + +const BOLD_LEAD_PARAGRAPH = /^\*\*([^*]+)\*\*:\s*([\s\S]*)$/ function GlossaryBody({ text }: { text: string }) { const paragraphs = text.split(/\n\n+/).filter(Boolean) return (
    {paragraphs.map((p, i) => { - const boldLead = /^\*\*([^*]+)\*\*:\s*([\s\S]*)$/.exec(p) + const boldLead = BOLD_LEAD_PARAGRAPH.exec(p) if (boldLead) { return (

    @@ -24,13 +30,8 @@ function GlossaryBody({ text }: { text: string }) { export default function GlossaryPage() { const md = readMetricGlossaryMarkdown() const entries = parseGlossaryEntries(md) - const allSlugs = new Set(Object.keys(entries)) - const orderedTopics = GLOSSARY_TOPIC_SECTIONS.map((t) => ({ - ...t, - slugs: t.slugOrder.filter((s) => allSlugs.has(s)), - })) - - const alphaSlugs = [...allSlugs].sort((a, b) => a.localeCompare(b)) + const topicBlocks = buildGlossaryTopicBlocks(entries) + const alphaSlugs = Object.keys(entries).sort((a, b) => a.localeCompare(b)) return (

    @@ -56,7 +57,7 @@ export default function GlossaryPage() { - {orderedTopics.map((topic) => ( + {topicBlocks.map((topic) => (

    {topic.label}

    {topic.slugs.map((slug) => { - const body = entries[slug] - if (!body) return null - const title = slug.replace(/-/g, " ") + const body = entries[slug]! + const title = formatGlossarySlugForDisplay(slug) return (

    {title}

    diff --git a/codebenders-dashboard/lib/glossary-constants.ts b/codebenders-dashboard/lib/glossary-constants.ts index 99158a4..2a86003 100644 --- a/codebenders-dashboard/lib/glossary-constants.ts +++ b/codebenders-dashboard/lib/glossary-constants.ts @@ -3,23 +3,12 @@ export const GLOSSARY_HREF = "/glossary" as const /** - * Every slug under `##` in `content/metric-glossary.md` for dashboard KPIs must stay in sync - * (see `lib/__tests__/metric-glossary-coverage.test.ts`). + * Source of truth for topic groupings and KPI slug order on the glossary page. + * `DASHBOARD_KPI_GLOSSARY_SLUGS` is derived from this list; every `##` section in + * `content/metric-glossary.md` must stay in sync (see + * `lib/__tests__/metric-glossary-coverage.test.ts`). */ -export const DASHBOARD_KPI_GLOSSARY_SLUGS = [ - "overall-retention-rate", - "avg-predicted-retention", - "students-at-high-critical-risk", - "avg-course-completion", -] as const - -export type DashboardKpiGlossarySlug = (typeof DASHBOARD_KPI_GLOSSARY_SLUGS)[number] - -export const GLOSSARY_TOPIC_SECTIONS: { - id: string - label: string - slugOrder: readonly DashboardKpiGlossarySlug[] -}[] = [ +const GLOSSARY_TOPIC_SECTIONS_RAW = [ { id: "retention", label: "Retention, risk & predictions", @@ -27,11 +16,27 @@ export const GLOSSARY_TOPIC_SECTIONS: { "overall-retention-rate", "avg-predicted-retention", "students-at-high-critical-risk", - ], + ] as const, }, { id: "completion", label: "Completion & course success", - slugOrder: ["avg-course-completion"], + slugOrder: ["avg-course-completion"] as const, }, -] +] as const + +export type DashboardKpiGlossarySlug = + (typeof GLOSSARY_TOPIC_SECTIONS_RAW)[number]["slugOrder"][number] + +export const DASHBOARD_KPI_GLOSSARY_SLUGS: readonly DashboardKpiGlossarySlug[] = + GLOSSARY_TOPIC_SECTIONS_RAW.flatMap((topic) => [...topic.slugOrder]) + +export const GLOSSARY_TOPIC_SECTIONS: { + id: string + label: string + slugOrder: readonly DashboardKpiGlossarySlug[] +}[] = GLOSSARY_TOPIC_SECTIONS_RAW.map((topic) => ({ + id: topic.id, + label: topic.label, + slugOrder: topic.slugOrder, +})) diff --git a/codebenders-dashboard/lib/metric-glossary.ts b/codebenders-dashboard/lib/metric-glossary.ts index 416a714..270be93 100644 --- a/codebenders-dashboard/lib/metric-glossary.ts +++ b/codebenders-dashboard/lib/metric-glossary.ts @@ -1,6 +1,8 @@ import fs from "fs" import path from "path" +import { GLOSSARY_TOPIC_SECTIONS } from "@/lib/glossary-constants" + export function readMetricGlossaryMarkdown(): string { const file = path.join(process.cwd(), "content", "metric-glossary.md") return fs.readFileSync(file, "utf8") @@ -13,7 +15,7 @@ export function parseGlossaryEntries(md: string): Record { let current: string | null = null const buf: string[] = [] - const flush = () => { + const flush = (): void => { if (current) out[current] = buf.join("\n").trim() buf.length = 0 } @@ -31,6 +33,17 @@ export function parseGlossaryEntries(md: string): Record { return out } -export function glossarySlugSet(md: string): Set { - return new Set(Object.keys(parseGlossaryEntries(md))) +export function formatGlossarySlugForDisplay(slug: string): string { + return slug.replace(/-/g, " ") +} + +export function buildGlossaryTopicBlocks( + entries: Record +): { id: string; label: string; slugs: string[] }[] { + const present = new Set(Object.keys(entries)) + return GLOSSARY_TOPIC_SECTIONS.map((topic) => ({ + id: topic.id, + label: topic.label, + slugs: topic.slugOrder.filter((slug) => present.has(slug)), + })) }