diff --git a/backend/app.py b/backend/app.py
index fa772d5..ca77f4e 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -608,6 +608,7 @@ async def get_school_details(request: Request, urn: int):
"parent_view": supplementary.get("parent_view"),
"census": supplementary.get("census"),
"admissions": supplementary.get("admissions"),
+ "admissions_history": supplementary.get("admissions_history") or [],
"sen_detail": supplementary.get("sen_detail"),
"phonics": supplementary.get("phonics"),
"deprivation": supplementary.get("deprivation"),
diff --git a/backend/data_loader.py b/backend/data_loader.py
index 503b22b..2fbaab2 100644
--- a/backend/data_loader.py
+++ b/backend/data_loader.py
@@ -477,10 +477,9 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
else None
)
- # Admissions (latest year)
- a = safe_query(FactAdmissions, "urn", "year")
- result["admissions"] = (
- {
+ # Admissions — all years, oldest first (for the multi-year trend view).
+ def _admissions_row(a):
+ return {
"year": a.year,
"school_phase": a.school_phase,
"places_offered": a.places_offered,
@@ -491,9 +490,25 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
"oversubscription_ratio": a.oversubscription_ratio,
"oversubscribed": a.oversubscribed,
}
- if a
- else None
- )
+
+ try:
+ admissions_rows = (
+ db.query(FactAdmissions)
+ .filter(FactAdmissions.urn == urn)
+ .order_by(FactAdmissions.year.asc())
+ .all()
+ )
+ except Exception as e:
+ import logging
+ logging.getLogger(__name__).error("admissions history query failed: %s", e)
+ db.rollback()
+ admissions_rows = []
+
+ history = [_admissions_row(a) for a in admissions_rows]
+ result["admissions_history"] = history
+ # Keep the single latest-year object for backwards-compatible consumers
+ # (hero chips, etc.).
+ result["admissions"] = history[-1] if history else None
# SEN detail — not available in current marts
result["sen_detail"] = None
diff --git a/mockups/admissions-history.html b/mockups/admissions-history.html
new file mode 100644
index 0000000..7002242
--- /dev/null
+++ b/mockups/admissions-history.html
@@ -0,0 +1,312 @@
+
+
+
+
+
+Multi-year admissions — mockups
+
+
+
+
+
+
+
+
+
+
How Hard to Get In — multi-year
+
Three ways to surface admissions history on the school detail page. Sample data: an oversubscribed primary where first-choice odds have tightened from 95% → 68% over three years.
+
+
+
+
Proposed Header toggle
+
Keeps today's single-year view as the default. A segmented toggle in the card header switches between "This year" and "3-year trend" (Option C). Try it — click the toggle.
+
+
+
+
How Hard to Get Into This School
+
+ This year
+ 3-year trend
+
+
+
+
+
+
+
+
How many places were offered? 60
+
How many families wanted this school first? 88
+
How many got their first choice? 60of 88 (68%)
+
How many applied in total? 241
+
+
+
+
+
+
+
First-choice offer rate
+
+
+
+
+ 100%
+ 75%
+ 50%
+
+
+
+
+ 95%
+ 81%
+ 68%
+ 2021/22
+ 2022/23
+ 2023/24
+
+
+
+
This year (2023/24), 88 families put it first for 60 places — 241 applications in total.
+
+
+
+
+
Below: the standalone option mockups for reference.
+
+
+
Option C Recommended
+
Trend verdict + sparkline, with the full year-by-year table behind a disclosure. Answers the parent's question first, rewards the curious second. Collapses to today's single-year view when only one year exists.
+
+
+ How Hard to Get Into This School
+
+
+
First-choice offer rate
+
+
+
+
+
+ 100%
+ 75%
+ 50%
+
+
+
+
+
+ 95%
+ 81%
+ 68%
+ 2021/22
+ 2022/23
+ 2023/24
+
+
+
+ This year (2023/24), 88 families put it first for 60 places — 241 applications in total.
+
+
+ ▸ See full 3-year breakdown
+
+
+
+ Year Places 1st-pref apps 1st-choice rate Total apps
+
+
+ 2023/24 60 88 68% 241
+ 2022/23 60 74 81% 198
+ 2021/22 60 63 95% 150
+
+
+
+
+
+
+
+
Option A · lite
+
Trend-aware headline + sparkline inside the existing card. Smallest change; shows the shape of the trend but not the per-year numbers.
+
+
+ How Hard to Get Into This School
+
+
+ Getting harder to get into ↗
+
+ Oversubscribed in each of the last 3 years.
+
+
+
First-choice offer rate
+
+
+
+
+
+ 95%
+ 81%
+ 68%
+ 2021/22
+ 2022/23
+ 2023/24
+
+
+
+ Latest (2023/24): 60 places · 88 first-choice · 241 total applications.
+
+
+
+
Option B · table
+
Compact year-by-year table of every metric. Maximum transparency; leaves the parent to spot the trend themselves and is the densest on mobile.
+
+
+ How Hard to Get Into This School
+ Three-year admissions history
+
+
+ Year Places 1st-pref apps 1st-choice rate Total apps Status
+
+
+ 2023/24 60 88 68% 241 Over
+ 2022/23 60 74 81% 198 Over
+ 2021/22 60 63 95% 150 OK
+
+
+ i A falling first-choice rate means competition is rising. “Places” is the number offered this round, roughly the intake size.
+
+
+
+
+
+
diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx
index 6a7b804..5dbb62e 100644
--- a/nextjs-app/app/school/[slug]/page.tsx
+++ b/nextjs-app/app/school/[slug]/page.tsx
@@ -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}
diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css
index a3612fc..aabce08 100644
--- a/nextjs-app/components/SchoolDetailView.module.css
+++ b/nextjs-app/components/SchoolDetailView.module.css
@@ -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;
diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx
index 5eb057d..57633c8 100644
--- a/nextjs-app/components/SchoolDetailView.tsx
+++ b/nextjs-app/components/SchoolDetailView.tsx
@@ -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 (
+
+ {gridVals.map((gv) => (
+
+
+ {gv}%
+
+ ))}
+
+ {pts.map((p, i) => {
+ const isLast = i === pts.length - 1;
+ return (
+
+
+ {Math.round(p.v)}%
+ {formatAcademicYear(p.year)}
+
+ );
+ })}
+
+ );
+}
+
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('');
+ 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(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
@@ -880,60 +941,80 @@ export function SchoolDetailView({
{/* How Hard to Get In */}
{admissions && (
- How Hard to Get Into This School ({formatAcademicYear(admissions.year)})
+
+
+ How Hard to Get Into This School{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}
+
+ {showAdmissionsTrend && (
+
+ setAdmissionsView('year')}>
+ This year
+
+ setAdmissionsView('trend')}>
+ {admissionsHistory.length}-year trend
+
+
+ )}
+
- {admissions.oversubscribed != null && (
-
-
- This school is{' '}
-
- {admissions.oversubscribed ? 'oversubscribed' : 'not oversubscribed'}
-
- .
-
-
- {admissions.oversubscribed ? 'Demand exceeds capacity.' : 'Supply meets demand.'}
-
+
+ {/* This-year Q&A */}
+
+
+ {admissions.places_offered != null && (
+
+
How many places were offered?
+ {admissions.places_offered}
+
+ )}
+ {admissions.first_preference_applications != null && (
+
+
How many families wanted this school first?
+ {admissions.first_preference_applications}
+
+ )}
+ {admissions.first_preference_offer_pct != null && (
+
+
How many got their first choice?
+
+ {admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
+ <>
+ {admissions.first_preference_offers}
+
+ of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
+
+ >
+ ) : (
+ formatPercentage(admissions.first_preference_offer_pct)
+ )}
+
+
+ )}
+ {admissions.total_applications != null && (
+
+
How many applied in total?
+ {admissions.total_applications.toLocaleString()}
+
+ )}
+
- )}
-
- {admissions.places_offered != null && (
-
-
How many places were offered?
- {admissions.places_offered}
-
- )}
- {admissions.first_preference_applications != null && (
-
-
How many families wanted this school first?
- {admissions.first_preference_applications}
-
- )}
- {admissions.first_preference_offer_pct != null && (
-
-
How many got their first choice?
-
- {admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
- <>
- {admissions.first_preference_offers}
-
- of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
-
- >
- ) : (
- formatPercentage(admissions.first_preference_offer_pct)
+ {/* Multi-year trend */}
+ {showAdmissionsTrend && (
+
+
First-choice offer rate
+
+
+ This year ({formatAcademicYear(admissions.year)}),{' '}
+ {admissions.first_preference_applications != null && (
+ <>{admissions.first_preference_applications} families put it first for >
)}
-
+ {admissions.places_offered != null && <>{admissions.places_offered} places>}
+ {admissions.total_applications != null && ` — ${admissions.total_applications.toLocaleString()} applications in total`}.
+
)}
- {admissions.total_applications != null && (
-
-
How many applied in total?
- {admissions.total_applications.toLocaleString()}
-
- )}
-
+
)}
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts
index d268d29..2c5c557 100644
--- a/nextjs-app/lib/types.ts
+++ b/nextjs-app/lib/types.ts
@@ -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;