Skip to content

Commit d6a0f3a

Browse files
authored
feat: metric definitions glossary + IPEDS/state cross-walks (#105) (#135)
feat: metric definitions glossary + IPEDS/state cross-walks (#105) Closes #105
1 parent b738f0b commit d6a0f3a

7 files changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import Link from "next/link"
2+
import {
3+
buildGlossaryTopicBlocks,
4+
formatGlossarySlugForDisplay,
5+
parseGlossaryEntries,
6+
readMetricGlossaryMarkdown,
7+
} from "@/lib/metric-glossary"
8+
9+
const BOLD_LEAD_PARAGRAPH = /^\*\*([^*]+)\*\*:\s*([\s\S]*)$/
10+
11+
function GlossaryBody({ text }: { text: string }) {
12+
const paragraphs = text.split(/\n\n+/).filter(Boolean)
13+
return (
14+
<div className="space-y-3 text-sm text-muted-foreground">
15+
{paragraphs.map((p, i) => {
16+
const boldLead = BOLD_LEAD_PARAGRAPH.exec(p)
17+
if (boldLead) {
18+
return (
19+
<p key={i}>
20+
<span className="font-medium text-foreground">{boldLead[1]}:</span> {boldLead[2]}
21+
</p>
22+
)
23+
}
24+
return <p key={i}>{p}</p>
25+
})}
26+
</div>
27+
)
28+
}
29+
30+
export default function GlossaryPage() {
31+
const md = readMetricGlossaryMarkdown()
32+
const entries = parseGlossaryEntries(md)
33+
const topicBlocks = buildGlossaryTopicBlocks(entries)
34+
const alphaSlugs = Object.keys(entries).sort((a, b) => a.localeCompare(b))
35+
36+
return (
37+
<div className="min-h-screen bg-background">
38+
<div className="container mx-auto max-w-4xl p-6 space-y-10">
39+
<header className="space-y-2 border-b border-border pb-6">
40+
<h1 className="text-3xl font-bold tracking-tight">Metric glossary</h1>
41+
<p className="text-muted-foreground">
42+
In-context definitions for dashboard KPIs, with PDP field notes and high-level IPEDS / state
43+
cross-walks. Source:{" "}
44+
<code className="text-xs bg-muted px-1 py-0.5 rounded">content/metric-glossary.md</code>
45+
</p>
46+
<p className="text-sm text-muted-foreground">
47+
<Link href="/" className="text-primary underline-offset-4 hover:underline">
48+
Back to dashboard
49+
</Link>
50+
{" · "}
51+
<Link href="/methodology" className="text-primary underline-offset-4 hover:underline">
52+
Methodology
53+
</Link>
54+
</p>
55+
</header>
56+
57+
<nav aria-label="On this page" className="rounded-lg border border-border bg-muted/30 p-4 space-y-3">
58+
<h2 className="text-sm font-semibold text-foreground">By topic</h2>
59+
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
60+
{topicBlocks.map((t) => (
61+
<li key={t.id}>
62+
<a href={`#topic-${t.id}`} className="text-primary underline-offset-4 hover:underline">
63+
{t.label}
64+
</a>
65+
</li>
66+
))}
67+
</ul>
68+
<h2 className="text-sm font-semibold text-foreground pt-2">A–Z</h2>
69+
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground">
70+
{alphaSlugs.map((slug) => (
71+
<li key={slug}>
72+
<a href={`#${slug}`} className="hover:text-foreground underline-offset-2 hover:underline">
73+
{formatGlossarySlugForDisplay(slug)}
74+
</a>
75+
</li>
76+
))}
77+
</ul>
78+
</nav>
79+
80+
{topicBlocks.map((topic) => (
81+
<section key={topic.id} id={`topic-${topic.id}`} className="space-y-6 scroll-mt-20">
82+
<h2 className="text-xl font-semibold tracking-tight border-b border-border pb-2">{topic.label}</h2>
83+
{topic.slugs.map((slug) => {
84+
const body = entries[slug]!
85+
const title = formatGlossarySlugForDisplay(slug)
86+
return (
87+
<article key={slug} id={slug} className="scroll-mt-20 space-y-2 rounded-lg border border-border p-5">
88+
<h3 className="text-lg font-medium capitalize">{title}</h3>
89+
<GlossaryBody text={body} />
90+
</article>
91+
)
92+
})}
93+
</section>
94+
))}
95+
96+
<footer className="text-xs text-muted-foreground border-t border-border pt-6">
97+
<p>
98+
Epic tracking:{" "}
99+
<a
100+
href="https://github.com/devcolor/codebenders-datathon/issues/124"
101+
className="text-primary underline-offset-4 hover:underline"
102+
>
103+
#124 AASCU convening follow-ups
104+
</a>
105+
{" · "}Issue{" "}
106+
<a
107+
href="https://github.com/devcolor/codebenders-datathon/issues/105"
108+
className="text-primary underline-offset-4 hover:underline"
109+
>
110+
#105
111+
</a>
112+
</p>
113+
</footer>
114+
</div>
115+
</div>
116+
)
117+
}

codebenders-dashboard/app/page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "@/components/ui/select"
1717
import { TrendingUp, Users, AlertTriangle, BookOpen, Search, Table2, X } from "lucide-react"
1818
import Link from "next/link"
19+
import { GLOSSARY_HREF } from "@/lib/glossary-constants"
1920

2021
interface KPIData {
2122
overallRetentionRate: string
@@ -275,6 +276,11 @@ export default function DashboardPage() {
275276
<p><strong>What it shows:</strong> Percentage of students retained year-to-year based on historical data.</p>
276277
<p className="mt-2"><strong>Data source:</strong> Retention field from student cohort records (0=Not Retained, 1=Retained).</p>
277278
<p className="mt-2"><strong>Use for:</strong> Baseline institutional performance metric.</p>
279+
<p className="mt-3">
280+
<Link href={`${GLOSSARY_HREF}#overall-retention-rate`} className="text-primary underline-offset-4 hover:underline text-xs font-medium">
281+
Full glossary entry (PDP / IPEDS cross-walk) →
282+
</Link>
283+
</p>
278284
</>
279285
}
280286
/>
@@ -295,6 +301,11 @@ export default function DashboardPage() {
295301
<li>First Year GPA (2.9%)</li>
296302
</ul>
297303
<p className="mt-2"><strong>Use for:</strong> Early identification of at-risk students for proactive intervention.</p>
304+
<p className="mt-3">
305+
<Link href={`${GLOSSARY_HREF}#avg-predicted-retention`} className="text-primary underline-offset-4 hover:underline text-xs font-medium">
306+
Full glossary entry (PDP / IPEDS cross-walk) →
307+
</Link>
308+
</p>
298309
</>
299310
}
300311
/>
@@ -319,6 +330,11 @@ export default function DashboardPage() {
319330
<li><strong>HIGH:</strong> Priority intervention</li>
320331
</ul>
321332
<p className="mt-2"><strong>Recommended actions:</strong> Immediate advisor outreach, financial aid review, tutoring referrals.</p>
333+
<p className="mt-3">
334+
<Link href={`${GLOSSARY_HREF}#students-at-high-critical-risk`} className="text-primary underline-offset-4 hover:underline text-xs font-medium">
335+
Full glossary entry (PDP / IPEDS cross-walk) →
336+
</Link>
337+
</p>
322338
</>
323339
}
324340
/>
@@ -339,6 +355,11 @@ export default function DashboardPage() {
339355
<li>&lt;50%: Critical - failing nearly half of courses</li>
340356
</ul>
341357
<p className="mt-2"><strong>Why it matters:</strong> Strong predictor of retention and credential completion.</p>
358+
<p className="mt-3">
359+
<Link href={`${GLOSSARY_HREF}#avg-course-completion`} className="text-primary underline-offset-4 hover:underline text-xs font-medium">
360+
Full glossary entry (PDP / IPEDS cross-walk) →
361+
</Link>
362+
</p>
342363
</>
343364
}
344365
/>

codebenders-dashboard/components/nav-header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { GraduationCap, LogOut } from "lucide-react"
66
import { Button } from "@/components/ui/button"
77
import { signOut } from "@/app/actions/auth"
88
import { AI_TRANSPARENCY_HREF } from "@/content/ai-transparency"
9+
import { GLOSSARY_HREF } from "@/lib/glossary-constants"
910
import { ROLE_COLORS, ROLE_LABELS, type Role } from "@/lib/roles"
1011

1112
interface NavHeaderProps {
@@ -15,6 +16,7 @@ interface NavHeaderProps {
1516

1617
const NAV_LINKS: Array<{ href: string; label: string; roles?: Role[] }> = [
1718
{ href: "/", label: "Dashboard" },
19+
{ href: GLOSSARY_HREF, label: "Glossary" },
1820
{ href: "/courses", label: "Courses" },
1921
{ href: "/students", label: "Students" },
2022
{ href: "/query", label: "Query" },
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Metric glossary
2+
3+
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.
4+
5+
---
6+
7+
## overall-retention-rate
8+
9+
**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.
10+
11+
**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.
12+
13+
**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.
14+
15+
**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.
16+
17+
---
18+
19+
## avg-predicted-retention
20+
21+
**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.
22+
23+
**PDP / analysis-ready:** Mean of `retention_probability` from the deployed XGBoost retention model on `student_level_with_predictions`.
24+
25+
**IPEDS:** No direct IPEDS submission — this is an institutional analytics prediction, not an audited outcome count.
26+
27+
**State compliance:** Treat as early-warning / planning metric unless your state explicitly allows predictive indicators in reporting.
28+
29+
---
30+
31+
## students-at-high-critical-risk
32+
33+
**Plain English:** Count of students whose composite risk score places them in the **HIGH** or **URGENT** alert bands used for intervention triage.
34+
35+
**PDP / analysis-ready:** Derived from `at_risk_alert` and related thresholds on `student_level_with_predictions` (see dashboard methodology for the composite formula).
36+
37+
**IPEDS:** Not an IPEDS field; comparable to internal early-alert counts only.
38+
39+
**State compliance:** Use for operations; confirm before exporting small subgroup counts externally (FERPA / small-N policies).
40+
41+
---
42+
43+
## avg-course-completion
44+
45+
**Plain English:** Credits successfully completed divided by credits attempted, expressed as a percentage, aggregated across students in the filter.
46+
47+
**PDP / analysis-ready:** Computed from course completion fields on `student_level_with_predictions` (credits attempted vs. earned in the modeled year window).
48+
49+
**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.
50+
51+
**State compliance:** Often parallels “success rate” or “credit completion ratio” in performance funding models; verify denominator rules match your state formula.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest"
2+
import {
3+
DASHBOARD_KPI_GLOSSARY_SLUGS,
4+
GLOSSARY_TOPIC_SECTIONS,
5+
} from "@/lib/glossary-constants"
6+
import { parseGlossaryEntries, readMetricGlossaryMarkdown } from "@/lib/metric-glossary"
7+
8+
describe("metric glossary coverage (#105)", () => {
9+
it("includes every dashboard KPI slug in metric-glossary.md", () => {
10+
const md = readMetricGlossaryMarkdown()
11+
const entries = parseGlossaryEntries(md)
12+
for (const slug of DASHBOARD_KPI_GLOSSARY_SLUGS) {
13+
expect(entries[slug], `missing ## ${slug} in content/metric-glossary.md`).toBeTruthy()
14+
expect(entries[slug]!.length).toBeGreaterThan(20)
15+
}
16+
})
17+
18+
it("topic sections list each KPI slug exactly once", () => {
19+
const listed = GLOSSARY_TOPIC_SECTIONS.flatMap((t) => [...t.slugOrder])
20+
expect(listed.length).toBe(DASHBOARD_KPI_GLOSSARY_SLUGS.length)
21+
for (const slug of DASHBOARD_KPI_GLOSSARY_SLUGS) {
22+
const n = listed.filter((s) => s === slug).length
23+
expect(n, `slug ${slug} should appear once in GLOSSARY_TOPIC_SECTIONS`).toBe(1)
24+
}
25+
})
26+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/** Nav and deep links — safe to import from client components. */
2+
3+
export const GLOSSARY_HREF = "/glossary" as const
4+
5+
/**
6+
* Source of truth for topic groupings and KPI slug order on the glossary page.
7+
* `DASHBOARD_KPI_GLOSSARY_SLUGS` is derived from this list; every `##` section in
8+
* `content/metric-glossary.md` must stay in sync (see
9+
* `lib/__tests__/metric-glossary-coverage.test.ts`).
10+
*/
11+
const GLOSSARY_TOPIC_SECTIONS_RAW = [
12+
{
13+
id: "retention",
14+
label: "Retention, risk & predictions",
15+
slugOrder: [
16+
"overall-retention-rate",
17+
"avg-predicted-retention",
18+
"students-at-high-critical-risk",
19+
] as const,
20+
},
21+
{
22+
id: "completion",
23+
label: "Completion & course success",
24+
slugOrder: ["avg-course-completion"] as const,
25+
},
26+
] as const
27+
28+
export type DashboardKpiGlossarySlug =
29+
(typeof GLOSSARY_TOPIC_SECTIONS_RAW)[number]["slugOrder"][number]
30+
31+
export const DASHBOARD_KPI_GLOSSARY_SLUGS: readonly DashboardKpiGlossarySlug[] =
32+
GLOSSARY_TOPIC_SECTIONS_RAW.flatMap((topic) => [...topic.slugOrder])
33+
34+
export const GLOSSARY_TOPIC_SECTIONS: {
35+
id: string
36+
label: string
37+
slugOrder: readonly DashboardKpiGlossarySlug[]
38+
}[] = GLOSSARY_TOPIC_SECTIONS_RAW.map((topic) => ({
39+
id: topic.id,
40+
label: topic.label,
41+
slugOrder: topic.slugOrder,
42+
}))
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import fs from "fs"
2+
import path from "path"
3+
4+
import { GLOSSARY_TOPIC_SECTIONS } from "@/lib/glossary-constants"
5+
6+
export function readMetricGlossaryMarkdown(): string {
7+
const file = path.join(process.cwd(), "content", "metric-glossary.md")
8+
return fs.readFileSync(file, "utf8")
9+
}
10+
11+
/** Map slug → markdown body (text below `## slug`). */
12+
export function parseGlossaryEntries(md: string): Record<string, string> {
13+
const out: Record<string, string> = {}
14+
const lines = md.split(/\n/)
15+
let current: string | null = null
16+
const buf: string[] = []
17+
18+
const flush = (): void => {
19+
if (current) out[current] = buf.join("\n").trim()
20+
buf.length = 0
21+
}
22+
23+
for (const line of lines) {
24+
const m = /^## ([a-z0-9-]+)\s*$/.exec(line)
25+
if (m) {
26+
flush()
27+
current = m[1]
28+
} else if (current) {
29+
buf.push(line)
30+
}
31+
}
32+
flush()
33+
return out
34+
}
35+
36+
export function formatGlossarySlugForDisplay(slug: string): string {
37+
return slug.replace(/-/g, " ")
38+
}
39+
40+
export function buildGlossaryTopicBlocks(
41+
entries: Record<string, string>
42+
): { id: string; label: string; slugs: string[] }[] {
43+
const present = new Set(Object.keys(entries))
44+
return GLOSSARY_TOPIC_SECTIONS.map((topic) => ({
45+
id: topic.id,
46+
label: topic.label,
47+
slugs: topic.slugOrder.filter((slug) => present.has(slug)),
48+
}))
49+
}

0 commit comments

Comments
 (0)