feat(admissions): surface multi-year admissions trend on school detail
Build and Push Docker Images / Build Backend (FastAPI) (pull_request) Successful in 22s
Build and Push Docker Images / Build Frontend (Next.js) (pull_request) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (pull_request) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (pull_request) Has been skipped

The school detail page only showed the latest admissions year. We store
every year, which is more decision-relevant for parents (the trend and its
consistency matter more than a single noisy year).

Backend now returns the full admissions_history (oldest first) alongside the
existing latest-year object. The primary SchoolDetailView gains a header
toggle ("This year | N-year trend") that swaps the Q&A for an SVG sparkline
of the first-choice offer rate. The toggle only appears when >=2 years carry
an offer rate; otherwise it falls back to the single-year card. Both views
share one CSS-grid cell so switching causes no layout shift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-06-19 18:41:03 +01:00
co-authored by Claude Opus 4.8
parent e7c26a83db
commit 4de7e559e9
7 changed files with 611 additions and 57 deletions
+2 -1
View File
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound();
}
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -206,6 +206,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
parentView={parent_view ?? null}
census={census ?? null}
admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
senDetail={sen_detail ?? null}
phonics={phonics ?? null}
deprivation={deprivation ?? null}
@@ -1295,6 +1295,148 @@
}
}
/* ── Admissions: header + view toggle ── */
.admissionsHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.admissionsHeader .sectionTitle {
margin-bottom: 0;
}
.admissionsSeg {
display: inline-flex;
background: var(--bg-secondary, #f3ede4);
border-radius: 999px;
padding: 3px;
gap: 2px;
flex: none;
}
.admissionsSeg button {
appearance: none;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted, #6d685f);
padding: 0.4rem 0.9rem;
border-radius: 999px;
white-space: nowrap;
transition: background 0.15s ease, color 0.15s ease;
}
.admissionsSeg button[aria-pressed="true"] {
background: var(--bg-card, #fff);
color: var(--text-primary, #1a1612);
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.admissionsSeg button:hover[aria-pressed="false"] {
color: var(--text-secondary, #5c564d);
}
/* Stack both views in one grid cell so the card sizes to the taller view —
toggling modes never shifts layout. */
.admissionsViewport {
display: grid;
}
.admissionsViewYear,
.admissionsViewTrend {
grid-area: 1 / 1;
}
.admissionsViewYear[hidden],
.admissionsViewTrend[hidden] {
display: block;
visibility: hidden;
pointer-events: none;
}
/* The this-year rows spread to fill the height reserved by the taller view. */
.admissionsViewYear {
display: flex;
flex-direction: column;
}
.admissionsViewYear .admissionsQa {
flex: 1;
justify-content: space-between;
}
.admissionsChartCap {
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
margin-bottom: 0.5rem;
}
.admissionsChart {
width: 100%;
height: auto;
display: block;
}
.admissionsGrid {
stroke: var(--border-color, #e5dfd5);
stroke-width: 1;
}
.admissionsAxis {
font-size: 12.5px;
fill: var(--text-muted, #6d685f);
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
}
.admissionsLine {
stroke: var(--accent-coral, #e07256);
stroke-width: 3;
}
.admissionsDot {
fill: var(--accent-coral, #e07256);
}
.admissionsDotLast {
fill: var(--accent-coral, #e07256);
stroke: var(--bg-card, #fff);
stroke-width: 2;
}
.admissionsPtLabel {
font-size: 13px;
font-weight: 700;
fill: var(--text-primary, #1a1612);
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
}
.admissionsPtLabel[data-last="true"] {
fill: var(--accent-coral-dark, #c45a3f);
}
.admissionsTrendSummary {
font-size: 1rem;
color: var(--text-secondary, #5c564d);
margin: 1.25rem 0 0;
padding-top: 1.1rem;
border-top: 1px solid var(--border-color, #e5dfd5);
line-height: 1.5;
}
.admissionsTrendSummary strong {
color: var(--text-primary, #1a1612);
}
/* ── History accordion ── */
.historyDisclosure {
margin-top: 1rem;
+130 -49
View File
@@ -58,6 +58,62 @@ function progressClass(val: number | null | undefined): string {
return '';
}
/**
* Compact SVG sparkline of the first-choice offer rate across admissions years.
* Renders nothing unless at least two years carry an offer-rate value.
*/
function OfferRateTrend({ history }: { history: SchoolAdmissions[] }) {
const pts = history
.filter((h) => h.first_preference_offer_pct != null)
.map((h) => ({ year: h.year, v: h.first_preference_offer_pct as number }));
if (pts.length < 2) return null;
const W = 520, H = 118;
const padL = 44, padR = 20, padT = 16, padB = 34;
const plotW = W - padL - padR, plotH = H - padT - padB;
const values = pts.map((p) => p.v);
let lo = Math.max(0, Math.floor(Math.min(...values) / 10) * 10);
let hi = Math.min(100, Math.ceil(Math.max(...values) / 10) * 10);
// Guarantee a minimum span so small year-to-year moves aren't exaggerated.
if (hi - lo < 30) {
hi = Math.min(100, lo + 30);
if (hi - lo < 30) lo = Math.max(0, hi - 30);
}
const x = (i: number) => padL + (plotW * i) / (pts.length - 1);
const y = (v: number) => padT + plotH * (1 - (v - lo) / (hi - lo));
const gridVals = [hi, Math.round((hi + lo) / 2), lo];
const polyline = pts.map((p, i) => `${x(i)},${y(p.v)}`).join(' ');
return (
<svg
className={styles.admissionsChart}
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={`First-choice offer rate from ${formatAcademicYear(pts[0].year)} to ${formatAcademicYear(pts[pts.length - 1].year)}`}
>
{gridVals.map((gv) => (
<g key={gv}>
<line x1={padL} y1={y(gv)} x2={W - padR} y2={y(gv)} className={styles.admissionsGrid} />
<text x={padL - 8} y={y(gv) + 4} textAnchor="end" className={styles.admissionsAxis}>{gv}%</text>
</g>
))}
<polyline points={polyline} fill="none" className={styles.admissionsLine} strokeLinecap="round" strokeLinejoin="round" />
{pts.map((p, i) => {
const isLast = i === pts.length - 1;
return (
<g key={p.year}>
<circle cx={x(i)} cy={y(p.v)} r={isLast ? 6 : 5} className={isLast ? styles.admissionsDotLast : styles.admissionsDot} />
<text x={x(i)} y={y(p.v) - 9} textAnchor="middle" className={styles.admissionsPtLabel} data-last={isLast}>{Math.round(p.v)}%</text>
<text x={x(i)} y={H - 12} textAnchor="middle" className={styles.admissionsAxis}>{formatAcademicYear(p.year)}</text>
</g>
);
})}
</svg>
);
}
interface SchoolDetailViewProps {
schoolInfo: School;
yearlyData: SchoolResult[];
@@ -66,6 +122,7 @@ interface SchoolDetailViewProps {
parentView: OfstedParentView | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
senDetail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;
@@ -74,13 +131,17 @@ interface SchoolDetailViewProps {
export function SchoolDetailView({
schoolInfo, yearlyData, absenceData,
ofsted, parentView, census, admissions, senDetail, phonics, deprivation, finance,
ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
}: SchoolDetailViewProps) {
const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>('');
const [admissionsView, setAdmissionsView] = useState<'year' | 'trend'>('year');
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2;
const sectionNavRef = useRef<HTMLElement | null>(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
@@ -880,60 +941,80 @@ export function SchoolDetailView({
{/* How Hard to Get In */}
{admissions && (
<section id="admissions" className={styles.card}>
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
<div className={styles.admissionsHeader}>
<h2 className={styles.sectionTitle}>
How Hard to Get Into This School{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}
</h2>
{showAdmissionsTrend && (
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
<button type="button" aria-pressed={admissionsView === 'year'} onClick={() => setAdmissionsView('year')}>
This year
</button>
<button type="button" aria-pressed={admissionsView === 'trend'} onClick={() => setAdmissionsView('trend')}>
{admissionsHistory.length}-year trend
</button>
</div>
)}
</div>
{admissions.oversubscribed != null && (
<div className={styles.admissionsVerdict}>
<div className={styles.admissionsVerdictHeadline}>
This school is{' '}
<span className={admissions.oversubscribed ? styles.admissionsVerdictOver : styles.admissionsVerdictUnder}>
{admissions.oversubscribed ? 'oversubscribed' : 'not oversubscribed'}
</span>
.
</div>
<div className={styles.admissionsVerdictSub}>
{admissions.oversubscribed ? 'Demand exceeds capacity.' : 'Supply meets demand.'}
</div>
<div className={styles.admissionsViewport}>
{/* This-year Q&A */}
<div className={styles.admissionsViewYear} hidden={showAdmissionsTrend && admissionsView !== 'year'}>
<dl className={styles.admissionsQa}>
{admissions.places_offered != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many places were offered?</dt>
<dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many families wanted this school first?</dt>
<dd className={styles.admissionsQaAnswer}>{admissions.first_preference_applications}</dd>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many got their first choice?</dt>
<dd className={styles.admissionsQaAnswer}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
<>
{admissions.first_preference_offers}
<span className={styles.admissionsQaAnswerSub}>
of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
</span>
</>
) : (
formatPercentage(admissions.first_preference_offer_pct)
)}
</dd>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many applied in total?</dt>
<dd className={styles.admissionsQaAnswer}>{admissions.total_applications.toLocaleString()}</dd>
</div>
)}
</dl>
</div>
)}
<dl className={styles.admissionsQa}>
{admissions.places_offered != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many places were offered?</dt>
<dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd>
</div>
)}
{admissions.first_preference_applications != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many families wanted this school first?</dt>
<dd className={styles.admissionsQaAnswer}>{admissions.first_preference_applications}</dd>
</div>
)}
{admissions.first_preference_offer_pct != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many got their first choice?</dt>
<dd className={styles.admissionsQaAnswer}>
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
<>
{admissions.first_preference_offers}
<span className={styles.admissionsQaAnswerSub}>
of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
</span>
</>
) : (
formatPercentage(admissions.first_preference_offer_pct)
{/* Multi-year trend */}
{showAdmissionsTrend && (
<div className={styles.admissionsViewTrend} hidden={admissionsView !== 'trend'}>
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
<OfferRateTrend history={admissionsHistory} />
<p className={styles.admissionsTrendSummary}>
This year ({formatAcademicYear(admissions.year)}),{' '}
{admissions.first_preference_applications != null && (
<><strong>{admissions.first_preference_applications}</strong> families put it first for </>
)}
</dd>
{admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
{admissions.total_applications != null && `${admissions.total_applications.toLocaleString()} applications in total`}.
</p>
</div>
)}
{admissions.total_applications != null && (
<div className={styles.admissionsQaRow}>
<dt className={styles.admissionsQaQuestion}>How many applied in total?</dt>
<dd className={styles.admissionsQaAnswer}>{admissions.total_applications.toLocaleString()}</dd>
</div>
)}
</dl>
</div>
</section>
)}
+2
View File
@@ -315,6 +315,8 @@ export interface SchoolDetailsResponse {
parent_view: OfstedParentView | null;
census: SchoolCensus | null;
admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[];
sen_detail: SenDetail | null;
phonics: Phonics | null;
deprivation: SchoolDeprivation | null;