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
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user