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
+1
View File
@@ -608,6 +608,7 @@ async def get_school_details(request: Request, urn: int):
"parent_view": supplementary.get("parent_view"), "parent_view": supplementary.get("parent_view"),
"census": supplementary.get("census"), "census": supplementary.get("census"),
"admissions": supplementary.get("admissions"), "admissions": supplementary.get("admissions"),
"admissions_history": supplementary.get("admissions_history") or [],
"sen_detail": supplementary.get("sen_detail"), "sen_detail": supplementary.get("sen_detail"),
"phonics": supplementary.get("phonics"), "phonics": supplementary.get("phonics"),
"deprivation": supplementary.get("deprivation"), "deprivation": supplementary.get("deprivation"),
+22 -7
View File
@@ -477,10 +477,9 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
else None else None
) )
# Admissions (latest year) # Admissions — all years, oldest first (for the multi-year trend view).
a = safe_query(FactAdmissions, "urn", "year") def _admissions_row(a):
result["admissions"] = ( return {
{
"year": a.year, "year": a.year,
"school_phase": a.school_phase, "school_phase": a.school_phase,
"places_offered": a.places_offered, "places_offered": a.places_offered,
@@ -491,9 +490,25 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
"oversubscription_ratio": a.oversubscription_ratio, "oversubscription_ratio": a.oversubscription_ratio,
"oversubscribed": a.oversubscribed, "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 # SEN detail — not available in current marts
result["sen_detail"] = None result["sen_detail"] = None
+312
View File
@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Multi-year admissions — mockups</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
--accent-coral:#e07256; --accent-coral-dark:#c45a3f; --accent-teal:#2d7d7d;
--accent-gold:#c9a227; --accent-gold-text:#7a6800;
--coral-bg:rgba(224,114,86,.12); --teal-bg:rgba(45,125,125,.12); --gold-bg:rgba(201,162,39,.12);
--trend-up:#16a34a; --trend-down:#e07256;
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06); --shadow-md:0 4px 20px rgba(26,22,18,.1);
--radius-sm:4px; --radius-md:8px; --radius-lg:16px;
--serif:'Playfair Display',Georgia,serif; --sans:'DM Sans',-apple-system,sans-serif;
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.6;padding:48px 20px 96px}
.wrap{max-width:760px;margin:0 auto}
.pagehead{margin-bottom:40px}
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:32px;letter-spacing:-.01em}
.pagehead p{color:var(--text-secondary);margin-top:8px;font-size:15px}
.optlabel{display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent-teal);margin:56px 0 6px}
.optlabel .rec{background:var(--teal-bg);color:var(--accent-teal);padding:2px 8px;border-radius:999px;letter-spacing:.02em;text-transform:none;font-weight:600}
.optdesc{color:var(--text-muted);font-size:14px;margin-bottom:16px;max-width:60ch}
/* card shell mimicking SchoolDetailView .card */
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow);padding:28px 28px 26px}
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em;margin-bottom:18px}
/* ===== verdict block ===== */
.verdict{display:flex;gap:14px;align-items:flex-start;padding:16px 18px;border-radius:var(--radius-md);margin-bottom:22px}
.verdict.hard{background:var(--coral-bg)}
.verdict.easing{background:var(--teal-bg)}
.verdict .vicon{font-size:20px;line-height:1.2}
.verdict .vhead{font-weight:700;font-size:17px;letter-spacing:-.01em}
.verdict.hard .vhead{color:var(--accent-coral-dark)}
.verdict.easing .vhead{color:var(--accent-teal)}
.verdict .vsub{color:var(--text-secondary);font-size:14px;margin-top:3px}
.trendtag{display:inline-flex;align-items:center;gap:5px;font-weight:600}
.trendtag.up{color:var(--trend-down)} /* harder = coral */
.trendtag.down{color:var(--trend-up)} /* easier = green */
/* ===== chart ===== */
.chartwrap{margin:6px 0 4px}
.chartcap{font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px}
.chart{width:100%;height:auto;display:block}
.axisyr{font-size:11px;fill:var(--text-muted);font-family:var(--sans)}
.ptlbl{font-size:12px;font-weight:700;font-family:var(--sans)}
/* latest line */
.latest{margin-top:18px;padding-top:16px;border-top:1px solid var(--border);font-size:14px;color:var(--text-secondary)}
.latest b{color:var(--text-primary)}
/* ===== Q&A (existing style) ===== */
.qa{display:grid;gap:0}
.qa .row{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:12px 0;border-bottom:1px solid var(--border)}
.qa .row:last-child{border-bottom:none}
.qa .q{color:var(--text-secondary);font-size:14px}
.qa .a{font-weight:700;font-size:16px;white-space:nowrap}
.qa .a .sub{font-weight:400;color:var(--text-muted);font-size:13px;margin-left:6px}
/* ===== table ===== */
table{width:100%;border-collapse:collapse;font-size:14px}
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);padding:0 10px 10px;border-bottom:1px solid var(--border)}
thead th:first-child{text-align:left}
tbody td{text-align:right;padding:12px 10px;border-bottom:1px solid var(--border)}
tbody td:first-child{text-align:left;font-weight:600}
tbody tr:last-child td{border-bottom:none}
tbody tr.latest-row{background:var(--bg-secondary)}
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px}
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
.note{display:flex;gap:8px;margin-top:16px;font-size:13px;color:var(--text-muted)}
.note .i{flex:none;width:16px;height:16px;border-radius:50%;background:var(--bg-secondary);color:var(--text-muted);font-size:11px;font-weight:700;display:grid;place-items:center;margin-top:2px}
/* ===== disclosure ===== */
.disclosure{margin-top:18px;border-top:1px solid var(--border);padding-top:6px}
.disclosure>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;padding:10px 0;font-weight:600;font-size:14px;color:var(--accent-teal)}
.disclosure>summary::-webkit-details-marker{display:none}
.disclosure>summary .chev{transition:transform .2s ease}
.disclosure[open]>summary .chev{transform:rotate(90deg)}
.disclosure .body{padding-top:8px}
/* hero chips preview */
.heroctx{margin:10px 0 14px;font-size:12px;color:var(--text-muted)}
.chips{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px}
.hchip{border-radius:var(--radius-md);padding:12px 16px;min-width:170px;background:var(--coral-bg)}
.hchip .t{font-weight:700;font-size:14px;color:var(--accent-coral-dark)}
.hchip .s{font-size:12px;color:var(--text-secondary);margin-top:2px}
/* ===== card header with segmented toggle ===== */
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
.cardhead .sectionTitle{margin-bottom:0}
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px;flex:none}
.seg button{appearance:none;border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;color:var(--text-muted);
padding:6px 14px;border-radius:999px;transition:background .15s ease,color .15s ease;white-space:nowrap}
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
.seg button:hover[aria-pressed="false"]{color:var(--text-secondary)}
/* Stack both views in one grid cell so the card sizes to the taller view —
switching modes never shifts layout. */
.viewport{display:grid}
.viewport > .view{grid-area:1 / 1}
.viewport > .view[hidden]{display:block;visibility:hidden;pointer-events:none}
/* Default view fills the reserved height: rows spread to consume the slack
left by the taller trend view, so there's no empty gap below. */
#view-year{display:flex;flex-direction:column}
#view-year .qa{flex:1;display:flex;flex-direction:column;justify-content:space-between}
/* Larger type in the proposed card so content carries the height
instead of empty space between small rows. */
#proposed .qa .row{padding:16px 0}
#proposed .qa .q{font-size:16px}
#proposed .qa .a{font-size:20px}
#proposed .qa .a .sub{font-size:14px}
#proposed .chartcap{font-size:13px}
#proposed .latest{font-size:16px;margin-top:22px;padding-top:18px}
#proposed .axisyr{font-size:12.5px}
#proposed .ptlbl{font-size:13px}
@media(max-width:600px){
.card{padding:22px 18px}
.qa .a{font-size:15px}
thead th,tbody td{padding-left:6px;padding-right:6px}
}
</style>
</head>
<body>
<div class="wrap">
<div class="pagehead">
<h1>How Hard to Get In — multi-year</h1>
<p>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.</p>
</div>
<!-- ============ PROPOSED: current view + header toggle to Option C ============ -->
<div class="optlabel">Proposed <span class="rec">Header toggle</span></div>
<p class="optdesc">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.</p>
<section class="card" id="proposed">
<div class="cardhead">
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
<div class="seg" role="group" aria-label="Admissions view">
<button data-show="year" aria-pressed="true" type="button">This year</button>
<button data-show="trend" aria-pressed="false" type="button">3-year trend</button>
</div>
</div>
<div class="viewport">
<!-- DEFAULT: current single-year Q&A -->
<div class="view" id="view-year">
<div class="qa">
<div class="row"><span class="q">How many places were offered?</span><span class="a">60</span></div>
<div class="row"><span class="q">How many families wanted this school first?</span><span class="a">88</span></div>
<div class="row"><span class="q">How many got their first choice?</span><span class="a">60<span class="sub">of 88 (68%)</span></span></div>
<div class="row"><span class="q">How many applied in total?</span><span class="a">241</span></div>
</div>
</div>
<!-- SWAPPED: Option C trend view -->
<div class="view" id="view-trend" hidden>
<div class="chartwrap">
<div class="chartcap">First-choice offer rate</div>
<svg class="chart" viewBox="0 0 520 118" role="img" aria-label="First-choice offer rate falling from 95% in 2021/22 to 68% in 2023/24">
<line x1="44" y1="16" x2="500" y2="16" stroke="#e5dfd5" stroke-width="1"/>
<line x1="44" y1="50" x2="500" y2="50" stroke="#e5dfd5" stroke-width="1"/>
<line x1="44" y1="84" x2="500" y2="84" stroke="#e5dfd5" stroke-width="1"/>
<text x="36" y="20" text-anchor="end" class="axisyr">100%</text>
<text x="36" y="54" text-anchor="end" class="axisyr">75%</text>
<text x="36" y="88" text-anchor="end" class="axisyr">50%</text>
<polyline points="110,21 290,41 470,59" fill="none" stroke="var(--accent-coral)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="110" cy="21" r="5" fill="var(--accent-coral)"/>
<circle cx="290" cy="41" r="5" fill="var(--accent-coral)"/>
<circle cx="470" cy="59" r="6" fill="var(--accent-coral)" stroke="#fff" stroke-width="2"/>
<text x="110" y="13" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">95%</text>
<text x="290" y="33" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">81%</text>
<text x="470" y="51" text-anchor="middle" class="ptlbl" fill="var(--accent-coral-dark)">68%</text>
<text x="110" y="106" text-anchor="middle" class="axisyr">2021/22</text>
<text x="290" y="106" text-anchor="middle" class="axisyr">2022/23</text>
<text x="470" y="106" text-anchor="middle" class="axisyr">2023/24</text>
</svg>
</div>
<div class="latest">This year (2023/24), <b>88 families</b> put it first for <b>60 places</b> — 241 applications in total.</div>
</div>
</div>
</section>
<p class="optdesc" style="margin-top:14px">Below: the standalone option mockups for reference.</p>
<!-- ============ OPTION C (RECOMMENDED) ============ -->
<div class="optlabel">Option C <span class="rec">Recommended</span></div>
<p class="optdesc">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.</p>
<section class="card">
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
<div class="chartwrap">
<div class="chartcap">First-choice offer rate</div>
<svg class="chart" viewBox="0 0 520 170" role="img" aria-label="First-choice offer rate falling from 95% in 2021/22 to 68% in 2023/24">
<!-- gridlines -->
<line x1="40" y1="20" x2="500" y2="20" stroke="#e5dfd5" stroke-width="1"/>
<line x1="40" y1="70" x2="500" y2="70" stroke="#e5dfd5" stroke-width="1"/>
<line x1="40" y1="120" x2="500" y2="120" stroke="#e5dfd5" stroke-width="1"/>
<text x="32" y="24" text-anchor="end" class="axisyr">100%</text>
<text x="32" y="74" text-anchor="end" class="axisyr">75%</text>
<text x="32" y="124" text-anchor="end" class="axisyr">50%</text>
<!-- 95% -> y=30 ; 81% -> y=58 ; 68% -> y=84 (y = 20 + (100-v)*2) -->
<polyline points="110,30 290,58 470,84" fill="none" stroke="var(--accent-coral)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="110" cy="30" r="5" fill="var(--accent-coral)"/>
<circle cx="290" cy="58" r="5" fill="var(--accent-coral)"/>
<circle cx="470" cy="84" r="6" fill="var(--accent-coral)" stroke="#fff" stroke-width="2"/>
<text x="110" y="20" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">95%</text>
<text x="290" y="48" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">81%</text>
<text x="470" y="74" text-anchor="middle" class="ptlbl" fill="var(--accent-coral-dark)">68%</text>
<text x="110" y="150" text-anchor="middle" class="axisyr">2021/22</text>
<text x="290" y="150" text-anchor="middle" class="axisyr">2022/23</text>
<text x="470" y="150" text-anchor="middle" class="axisyr">2023/24</text>
</svg>
</div>
<div class="latest">This year (2023/24), <b>88 families</b> put it first for <b>60 places</b> — 241 applications in total.</div>
<details class="disclosure">
<summary><span class="chev"></span> See full 3-year breakdown</summary>
<div class="body">
<table>
<thead>
<tr><th>Year</th><th>Places</th><th>1st-pref apps</th><th>1st-choice rate</th><th>Total apps</th></tr>
</thead>
<tbody>
<tr class="latest-row"><td>2023/24</td><td>60</td><td>88</td><td>68%</td><td>241</td></tr>
<tr><td>2022/23</td><td>60</td><td>74</td><td>81%</td><td>198</td></tr>
<tr><td>2021/22</td><td>60</td><td>63</td><td>95%</td><td>150</td></tr>
</tbody>
</table>
</div>
</details>
</section>
<!-- ============ OPTION A (LITE) ============ -->
<div class="optlabel">Option A <span style="color:var(--text-muted);text-transform:none;letter-spacing:0;font-weight:500">· lite</span></div>
<p class="optdesc">Trend-aware headline + sparkline inside the existing card. Smallest change; shows the shape of the trend but not the per-year numbers.</p>
<section class="card">
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
<div style="font-weight:700;font-size:18px;letter-spacing:-.01em;margin-bottom:4px">
Getting harder to get into <span class="trendtag up"></span>
</div>
<div style="color:var(--text-secondary);font-size:14px;margin-bottom:18px">Oversubscribed in each of the last 3 years.</div>
<div class="chartwrap">
<div class="chartcap">First-choice offer rate</div>
<svg class="chart" viewBox="0 0 520 150" role="img" aria-label="First-choice offer rate falling from 95% to 68%">
<polyline points="60,30 260,58 460,84" fill="none" stroke="var(--accent-coral)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="60" cy="30" r="5" fill="var(--accent-coral)"/>
<circle cx="260" cy="58" r="5" fill="var(--accent-coral)"/>
<circle cx="460" cy="84" r="6" fill="var(--accent-coral)" stroke="#fff" stroke-width="2"/>
<text x="60" y="20" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">95%</text>
<text x="260" y="48" text-anchor="middle" class="ptlbl" fill="var(--text-primary)">81%</text>
<text x="460" y="74" text-anchor="middle" class="ptlbl" fill="var(--accent-coral-dark)">68%</text>
<text x="60" y="120" text-anchor="middle" class="axisyr">2021/22</text>
<text x="260" y="120" text-anchor="middle" class="axisyr">2022/23</text>
<text x="460" y="120" text-anchor="middle" class="axisyr">2023/24</text>
</svg>
</div>
<div class="latest">Latest (2023/24): <b>60 places</b> · <b>88 first-choice</b> · 241 total applications.</div>
</section>
<!-- ============ OPTION B (TABLE) ============ -->
<div class="optlabel">Option B <span style="color:var(--text-muted);text-transform:none;letter-spacing:0;font-weight:500">· table</span></div>
<p class="optdesc">Compact year-by-year table of every metric. Maximum transparency; leaves the parent to spot the trend themselves and is the densest on mobile.</p>
<section class="card">
<h2 class="sectionTitle">How Hard to Get Into This School</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:18px">Three-year admissions history</p>
<table>
<thead>
<tr><th>Year</th><th>Places</th><th>1st-pref apps</th><th>1st-choice rate</th><th>Total apps</th><th>Status</th></tr>
</thead>
<tbody>
<tr class="latest-row"><td>2023/24</td><td>60</td><td>88</td><td>68%</td><td>241</td><td><span class="pill over">Over</span></td></tr>
<tr><td>2022/23</td><td>60</td><td>74</td><td>81%</td><td>198</td><td><span class="pill over">Over</span></td></tr>
<tr><td>2021/22</td><td>60</td><td>63</td><td>95%</td><td>150</td><td><span class="pill ok">OK</span></td></tr>
</tbody>
</table>
<div class="note"><span class="i">i</span><span>A falling first-choice rate means competition is rising. “Places” is the number offered this round, roughly the intake size.</span></div>
</section>
</div>
<script>
const proposed = document.getElementById('proposed');
const views = { year: document.getElementById('view-year'), trend: document.getElementById('view-trend') };
const toggles = proposed.querySelectorAll('.seg button');
toggles.forEach(btn => {
btn.addEventListener('click', () => {
const show = btn.dataset.show;
views.year.hidden = show !== 'year';
views.trend.hidden = show !== 'trend';
toggles.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.show === show)));
});
});
</script>
</body>
</html>
+2 -1
View File
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound(); 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 // Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); 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} parentView={parent_view ?? null}
census={census ?? null} census={census ?? null}
admissions={admissions ?? null} admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []}
senDetail={sen_detail ?? null} senDetail={sen_detail ?? null}
phonics={phonics ?? null} phonics={phonics ?? null}
deprivation={deprivation ?? 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 ── */ /* ── History accordion ── */
.historyDisclosure { .historyDisclosure {
margin-top: 1rem; margin-top: 1rem;
+130 -49
View File
@@ -58,6 +58,62 @@ function progressClass(val: number | null | undefined): string {
return ''; 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 { interface SchoolDetailViewProps {
schoolInfo: School; schoolInfo: School;
yearlyData: SchoolResult[]; yearlyData: SchoolResult[];
@@ -66,6 +122,7 @@ interface SchoolDetailViewProps {
parentView: OfstedParentView | null; parentView: OfstedParentView | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
senDetail: SenDetail | null; senDetail: SenDetail | null;
phonics: Phonics | null; phonics: Phonics | null;
deprivation: SchoolDeprivation | null; deprivation: SchoolDeprivation | null;
@@ -74,13 +131,17 @@ interface SchoolDetailViewProps {
export function SchoolDetailView({ export function SchoolDetailView({
schoolInfo, yearlyData, absenceData, schoolInfo, yearlyData, absenceData,
ofsted, parentView, census, admissions, senDetail, phonics, deprivation, finance, ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
}: SchoolDetailViewProps) { }: SchoolDetailViewProps) {
const router = useRouter(); const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison(); const { addSchool, removeSchool, isSelected } = useComparison();
const isInComparison = isSelected(schoolInfo.urn); const isInComparison = isSelected(schoolInfo.urn);
const [activeSection, setActiveSection] = useState<string>(''); 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 sectionNavRef = useRef<HTMLElement | null>(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false); const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
@@ -880,60 +941,80 @@ export function SchoolDetailView({
{/* How Hard to Get In */} {/* How Hard to Get In */}
{admissions && ( {admissions && (
<section id="admissions" className={styles.card}> <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.admissionsViewport}>
<div className={styles.admissionsVerdict}> {/* This-year Q&A */}
<div className={styles.admissionsVerdictHeadline}> <div className={styles.admissionsViewYear} hidden={showAdmissionsTrend && admissionsView !== 'year'}>
This school is{' '} <dl className={styles.admissionsQa}>
<span className={admissions.oversubscribed ? styles.admissionsVerdictOver : styles.admissionsVerdictUnder}> {admissions.places_offered != null && (
{admissions.oversubscribed ? 'oversubscribed' : 'not oversubscribed'} <div className={styles.admissionsQaRow}>
</span> <dt className={styles.admissionsQaQuestion}>How many places were offered?</dt>
. <dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd>
</div> </div>
<div className={styles.admissionsVerdictSub}> )}
{admissions.oversubscribed ? 'Demand exceeds capacity.' : 'Supply meets demand.'} {admissions.first_preference_applications != null && (
</div> <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> </div>
)}
<dl className={styles.admissionsQa}> {/* Multi-year trend */}
{admissions.places_offered != null && ( {showAdmissionsTrend && (
<div className={styles.admissionsQaRow}> <div className={styles.admissionsViewTrend} hidden={admissionsView !== 'trend'}>
<dt className={styles.admissionsQaQuestion}>How many places were offered?</dt> <div className={styles.admissionsChartCap}>First-choice offer rate</div>
<dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd> <OfferRateTrend history={admissionsHistory} />
</div> <p className={styles.admissionsTrendSummary}>
)} This year ({formatAcademicYear(admissions.year)}),{' '}
{admissions.first_preference_applications != null && ( {admissions.first_preference_applications != null && (
<div className={styles.admissionsQaRow}> <><strong>{admissions.first_preference_applications}</strong> families put it first for </>
<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> {admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
{admissions.total_applications != null && `${admissions.total_applications.toLocaleString()} applications in total`}.
</p>
</div> </div>
)} )}
{admissions.total_applications != null && ( </div>
<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>
</section> </section>
)} )}
+2
View File
@@ -315,6 +315,8 @@ export interface SchoolDetailsResponse {
parent_view: OfstedParentView | null; parent_view: OfstedParentView | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */
admissions_history: SchoolAdmissions[];
sen_detail: SenDetail | null; sen_detail: SenDetail | null;
phonics: Phonics | null; phonics: Phonics | null;
deprivation: SchoolDeprivation | null; deprivation: SchoolDeprivation | null;