From 4de7e559e919f42c20a043aeb05f1da7667f202a Mon Sep 17 00:00:00 2001 From: Tudor Date: Fri, 19 Jun 2026 18:41:03 +0100 Subject: [PATCH] feat(admissions): surface multi-year admissions trend on school detail 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 --- backend/app.py | 1 + backend/data_loader.py | 29 +- mockups/admissions-history.html | 312 ++++++++++++++++++ nextjs-app/app/school/[slug]/page.tsx | 3 +- .../components/SchoolDetailView.module.css | 142 ++++++++ nextjs-app/components/SchoolDetailView.tsx | 179 +++++++--- nextjs-app/lib/types.ts | 2 + 7 files changed, 611 insertions(+), 57 deletions(-) create mode 100644 mockups/admissions-history.html 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

+
+ + +
+
+ +
+ +
+
+
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
+
+
+ + + +
+
+ +

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 +
+ + + + + + + + + +
YearPlaces1st-pref apps1st-choice rateTotal apps
2023/24608868%241
2022/23607481%198
2021/22606395%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

+ + + + + + + + + +
YearPlaces1st-pref apps1st-choice rateTotal appsStatus
2023/24608868%241Over
2022/23607481%198Over
2021/22606395%150OK
+
iA 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 && ( +
+ + +
+ )} +
- {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) + {/* Multi-year trend */} + {showAdmissionsTrend && ( +
+ {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;