feat(admissions): show the last distance offered where councils publish it #101
No files matched your search
@@ -5,3 +5,6 @@ __pycache__/
|
|||||||
pipeline/transform/target/
|
pipeline/transform/target/
|
||||||
pipeline/transform/logs/
|
pipeline/transform/logs/
|
||||||
pipeline/transform/.user.yml
|
pipeline/transform/.user.yml
|
||||||
|
|
||||||
|
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
||||||
|
.playwright-mcp/
|
||||||
@@ -632,6 +632,7 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"census": supplementary.get("census"),
|
"census": supplementary.get("census"),
|
||||||
"admissions": supplementary.get("admissions"),
|
"admissions": supplementary.get("admissions"),
|
||||||
"admissions_history": supplementary.get("admissions_history") or [],
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
|
"admission_distance": supplementary.get("admission_distance"),
|
||||||
"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"),
|
||||||
|
|||||||
+34
-1
@@ -18,7 +18,7 @@ from .config import settings
|
|||||||
from .database import SessionLocal, engine
|
from .database import SessionLocal, engine
|
||||||
from .models import (
|
from .models import (
|
||||||
DimSchool, DimLocation, KS2Performance,
|
DimSchool, DimLocation, KS2Performance,
|
||||||
FactOfstedInspection, FactAdmissions,
|
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
||||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||||
@@ -723,6 +723,17 @@ def _admissions_row_dict(a) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _admission_distance_dict(d) -> dict:
|
||||||
|
"""Serialize one fact_admission_distance row for API responses."""
|
||||||
|
return {
|
||||||
|
"year": d.year,
|
||||||
|
"distance_m": d.distance_m,
|
||||||
|
"route_count": d.route_count,
|
||||||
|
"la_name": d.la_name,
|
||||||
|
"distance_unit_raw": d.distance_unit_raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _census_dict(pc) -> dict:
|
def _census_dict(pc) -> dict:
|
||||||
return {
|
return {
|
||||||
"year": pc.year,
|
"year": pc.year,
|
||||||
@@ -759,6 +770,7 @@ def _empty_supplementary() -> dict:
|
|||||||
"census": None,
|
"census": None,
|
||||||
"admissions": None,
|
"admissions": None,
|
||||||
"admissions_history": [],
|
"admissions_history": [],
|
||||||
|
"admission_distance": None,
|
||||||
"sen_detail": None,
|
"sen_detail": None,
|
||||||
"phonics": None,
|
"phonics": None,
|
||||||
"deprivation": None,
|
"deprivation": None,
|
||||||
@@ -837,6 +849,27 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
|||||||
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||||
_safe(_admissions)
|
_safe(_admissions)
|
||||||
|
|
||||||
|
# Last distance offered — latest year per URN.
|
||||||
|
#
|
||||||
|
# Only the most recent year is served. Coverage is ragged (a school may
|
||||||
|
# have 2021 and 2026 but nothing between), so a history array would invite
|
||||||
|
# a trend line drawn through gaps that are absences of publication rather
|
||||||
|
# than absences of a cut-off.
|
||||||
|
def _admission_distance():
|
||||||
|
rows = (
|
||||||
|
db.query(FactAdmissionDistance)
|
||||||
|
.filter(FactAdmissionDistance.urn.in_(urns))
|
||||||
|
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
seen = set()
|
||||||
|
for d in rows:
|
||||||
|
if d.urn in seen:
|
||||||
|
continue
|
||||||
|
seen.add(d.urn)
|
||||||
|
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
|
||||||
|
_safe(_admission_distance)
|
||||||
|
|
||||||
# Deprivation — one row per URN.
|
# Deprivation — one row per URN.
|
||||||
def _deprivation():
|
def _deprivation():
|
||||||
rows = (
|
rows = (
|
||||||
|
|||||||
@@ -188,6 +188,37 @@ class FactAdmissions(Base):
|
|||||||
admissions_policy = Column(String(100))
|
admissions_policy = Column(String(100))
|
||||||
|
|
||||||
|
|
||||||
|
class FactAdmissionDistance(Base):
|
||||||
|
"""Last distance offered — one row per URN per year.
|
||||||
|
|
||||||
|
Separate from FactAdmissions because the source is separate: EES publishes
|
||||||
|
admissions for the whole country, whereas cut-off distances exist only for
|
||||||
|
the local authorities that choose to publish them (57 at the time of
|
||||||
|
writing), and the two refresh on unrelated timetables.
|
||||||
|
"""
|
||||||
|
__tablename__ = "fact_admission_distance"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_admission_distance_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
urn = Column(Integer, primary_key=True)
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
# Straight-line distance in metres from the school to the last home offered
|
||||||
|
# a place that year.
|
||||||
|
distance_m = Column(Float)
|
||||||
|
# How many admission routes (ability bands, separate reception/junior
|
||||||
|
# intakes) were collapsed into distance_m. >1 means the figure is the
|
||||||
|
# furthest of several and the page must say so.
|
||||||
|
route_count = Column(Integer)
|
||||||
|
la_code = Column(Integer)
|
||||||
|
la_name = Column(String(100))
|
||||||
|
# The unit the council published in, so the page can lead with the unit a
|
||||||
|
# parent was given rather than always converting.
|
||||||
|
distance_unit_raw = Column(String(20))
|
||||||
|
source_file = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
class FactPupilCharacteristics(Base):
|
class FactPupilCharacteristics(Base):
|
||||||
"""School pupil composition from EES census — one row per URN per year."""
|
"""School pupil composition from EES census — one row per URN per year."""
|
||||||
__tablename__ = "fact_pupil_characteristics"
|
__tablename__ = "fact_pupil_characteristics"
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ def _adm_row(urn, year):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dist_row(urn, year, distance_m, route_count=1):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
urn=urn, year=year, distance_m=distance_m, route_count=route_count,
|
||||||
|
la_name="Camden", distance_unit_raw="miles", source_file="camden/guide.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_one_query_per_table_and_latest_row_per_urn():
|
def test_one_query_per_table_and_latest_row_per_urn():
|
||||||
rows = {
|
rows = {
|
||||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
||||||
@@ -78,6 +85,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
_ofsted_row(2, "2021-06-01", 1),
|
_ofsted_row(2, "2021-06-01", 1),
|
||||||
],
|
],
|
||||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
||||||
|
# URN 1 has three years of cut-offs; only the most recent is served.
|
||||||
|
# Deliberately not in year order — the ordering is the query's job.
|
||||||
|
"FactAdmissionDistance": [
|
||||||
|
_dist_row(1, 2026, 529.47),
|
||||||
|
_dist_row(1, 2024, 772.49),
|
||||||
|
_dist_row(1, 2025, 1421.05),
|
||||||
|
_dist_row(2, 2023, 2029.38, route_count=4),
|
||||||
|
],
|
||||||
"FactPupilCharacteristics": [],
|
"FactPupilCharacteristics": [],
|
||||||
"FactDeprivation": [],
|
"FactDeprivation": [],
|
||||||
"FactFinance": [],
|
"FactFinance": [],
|
||||||
@@ -85,10 +100,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
session = _FakeSession(rows)
|
session = _FakeSession(rows)
|
||||||
out = get_supplementary_data_batch(session, [1, 2])
|
out = get_supplementary_data_batch(session, [1, 2])
|
||||||
|
|
||||||
# Exactly one query per table — five total, regardless of two URNs.
|
# Exactly one query per table — six total, regardless of two URNs.
|
||||||
assert sorted(session.queries) == [
|
assert sorted(session.queries) == [
|
||||||
"FactAdmissions", "FactDeprivation", "FactFinance",
|
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
||||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Latest Ofsted kept per URN
|
# Latest Ofsted kept per URN
|
||||||
@@ -100,6 +115,14 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
assert out[1]["admissions"]["year"] == 202627
|
assert out[1]["admissions"]["year"] == 202627
|
||||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||||
|
|
||||||
|
# Cut-off distance: latest year only, per URN. Coverage is ragged, so
|
||||||
|
# serving anything but the newest would show a parent a stale figure.
|
||||||
|
assert out[1]["admission_distance"]["year"] == 2026
|
||||||
|
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
||||||
|
# route_count travels with the figure — the page needs it to say the
|
||||||
|
# distance is the furthest of several bands rather than the only one.
|
||||||
|
assert out[2]["admission_distance"]["route_count"] == 4
|
||||||
|
|
||||||
# Empty tables degrade to the null block, not a crash
|
# Empty tables degrade to the null block, not a crash
|
||||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
||||||
|
|
||||||
@@ -109,3 +132,4 @@ def test_single_wrapper_matches_batch(monkeypatch):
|
|||||||
single = data_loader.get_supplementary_data(session, 5)
|
single = data_loader.get_supplementary_data(session, 5)
|
||||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||||
assert single["admissions_history"] == []
|
assert single["admissions_history"] == []
|
||||||
|
assert single["admission_distance"] is None
|
||||||
@@ -1210,3 +1210,100 @@ for (const scheme of ['light', 'dark'] as const) {
|
|||||||
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Last distance offered ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Coverage is partial by nature: only 57 local authorities publish cut-off
|
||||||
|
// distances at all, and staging may not yet hold the extract. These tests
|
||||||
|
// therefore locate a school that HAS a figure and assert the invariants that
|
||||||
|
// make it safe to show, rather than asserting any particular school has one.
|
||||||
|
|
||||||
|
/** URNs known to be in the collected cut-off dataset, spread across LAs so a
|
||||||
|
* single council's data gap cannot skip the whole check. */
|
||||||
|
const CUTOFF_CANDIDATE_URNS = [
|
||||||
|
139837, 100010, 100011, 100028, 100029, 130342, // Camden
|
||||||
|
100458, 135587, // Islington (banded)
|
||||||
|
101099, 100553, 102574, 100769, // mixed
|
||||||
|
];
|
||||||
|
|
||||||
|
async function schoolWithCutoff(page: Page) {
|
||||||
|
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
||||||
|
const res = await page.request.get(`/api/schools/${urn}`);
|
||||||
|
if (!res.ok()) continue;
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.admission_distance?.distance_m != null) {
|
||||||
|
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a published cut-off distance is shown with the year it belongs to', async ({ page }) => {
|
||||||
|
const found = await schoolWithCutoff(page);
|
||||||
|
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
||||||
|
|
||||||
|
await page.goto(`/school/${found!.urn}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const label = page.getByText(/Last distance offered/).first();
|
||||||
|
await expect(label).toBeVisible();
|
||||||
|
|
||||||
|
// The year is the difference between a fact and a floating number: a cut-off
|
||||||
|
// is the outcome of one admissions round and means nothing without it.
|
||||||
|
await expect(label).toContainText(`September ${found!.distance.year}`);
|
||||||
|
|
||||||
|
// And the figure itself, in the unit councils publish in.
|
||||||
|
await expect(page.getByText(/\d+(\.\d+)? (miles|m)\b/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
|
||||||
|
const found = await schoolWithCutoff(page);
|
||||||
|
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
||||||
|
|
||||||
|
await page.goto(`/school/${found!.urn}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(page.getByText(/not a fixed catchment/)).toBeVisible();
|
||||||
|
|
||||||
|
// The old secondary template asserted "distance cut-off data is not
|
||||||
|
// available for this school" on every page, including ones whose council
|
||||||
|
// does publish it. Showing both at once would be a flat contradiction.
|
||||||
|
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
|
||||||
|
|
||||||
|
// A banded school's figure is the widest of several routes, and must say so.
|
||||||
|
if ((found!.distance.route_count ?? 1) > 1) {
|
||||||
|
await expect(page.getByText(/admission routes at this school/)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cut-off distance opens the Admissions section in the sticky nav', async ({ page }) => {
|
||||||
|
// buildNavItems and the section composers gate on the same condition; when
|
||||||
|
// they drift the nav links to an anchor that was never rendered.
|
||||||
|
const found = await schoolWithCutoff(page);
|
||||||
|
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
||||||
|
|
||||||
|
await page.goto(`/school/${found!.urn}`);
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await expect(page.locator('#admissions')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no school page shows an implausible cut-off distance', async ({ page }) => {
|
||||||
|
// The source carries transcription errors up to 533 miles, filtered in
|
||||||
|
// stg_school_distance. This asserts the filter is actually reaching the API,
|
||||||
|
// because a visibly absurd figure on a live page is the failure that would
|
||||||
|
// cost the site its credibility.
|
||||||
|
let checked = 0;
|
||||||
|
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
||||||
|
const res = await page.request.get(`/api/schools/${urn}`);
|
||||||
|
if (!res.ok()) continue;
|
||||||
|
const d = (await res.json())?.admission_distance;
|
||||||
|
if (d?.distance_m == null) continue;
|
||||||
|
checked += 1;
|
||||||
|
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
|
||||||
|
.toBeGreaterThanOrEqual(25);
|
||||||
|
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
|
||||||
|
.toBeLessThanOrEqual(25000);
|
||||||
|
}
|
||||||
|
test.skip(checked === 0, 'no cut-off distances available to check yet');
|
||||||
|
});
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
<title>Last distance offered — detail page mockup</title>
|
||||||
|
<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:#b04a2e;
|
||||||
|
--accent-teal:#296f6f; --accent-teal-light:#3a9e9e;
|
||||||
|
--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);
|
||||||
|
--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,'Iowan Old Style',serif;
|
||||||
|
--sans:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||||
|
}
|
||||||
|
/* Mockup is a fixed light artefact — it mirrors the live app, which is light-only. */
|
||||||
|
*{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:44px 20px 110px;font-variant-numeric:tabular-nums}
|
||||||
|
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:0}
|
||||||
|
|
||||||
|
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:clamp(26px,4vw,34px);letter-spacing:-.015em;text-wrap:balance}
|
||||||
|
.pagehead p{color:var(--text-secondary);margin-top:10px;font-size:15px;max-width:64ch}
|
||||||
|
.pagehead p + p{margin-top:8px}
|
||||||
|
|
||||||
|
.step{margin:60px 0 6px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
|
||||||
|
.step h2{font-family:var(--serif);font-size:20px;font-weight:600;letter-spacing:-.01em}
|
||||||
|
.step .where{font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--accent-teal);
|
||||||
|
background:var(--teal-bg);padding:3px 9px;border-radius:999px}
|
||||||
|
.stepdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:66ch}
|
||||||
|
.stepdesc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--bg-secondary);padding:1px 5px;border-radius:var(--radius-sm)}
|
||||||
|
|
||||||
|
/* ---- card shell mirrors SchoolDetailView .card ---- */
|
||||||
|
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);
|
||||||
|
box-shadow:var(--shadow);padding:26px 26px 24px}
|
||||||
|
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:8px}
|
||||||
|
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em}
|
||||||
|
.sectionSub{color:var(--text-secondary);font-size:14px;margin-bottom:18px}
|
||||||
|
|
||||||
|
.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 13px;border-radius:999px;white-space:nowrap;transition:background .15s,color .15s}
|
||||||
|
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
|
||||||
|
.seg button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
||||||
|
|
||||||
|
/* ---- tiles ---- */
|
||||||
|
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(146px,1fr));gap:10px;margin-top:4px}
|
||||||
|
.tile{background:var(--bg-secondary);border-radius:var(--radius-md);padding:14px 15px 13px}
|
||||||
|
.tile .num{font-family:var(--serif);font-size:27px;font-weight:600;line-height:1.15;letter-spacing:-.01em;display:block}
|
||||||
|
.tile .num .unit{font-family:var(--sans);font-size:15px;font-weight:600;margin-left:2px}
|
||||||
|
.tile .num .sub{display:block;font-family:var(--sans);font-size:12.5px;font-weight:400;color:var(--text-muted);margin-top:1px}
|
||||||
|
.tile .lbl{display:block;font-size:12.5px;color:var(--text-secondary);margin-top:5px;line-height:1.35}
|
||||||
|
.tile.accent{background:var(--coral-bg)}
|
||||||
|
.tile.accent .num{color:var(--accent-coral-dark)}
|
||||||
|
.tile.newtile{background:var(--coral-bg);box-shadow:inset 0 0 0 1.5px var(--accent-coral)}
|
||||||
|
.tile.newtile .num{color:var(--accent-coral-dark)}
|
||||||
|
.newflag{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
||||||
|
color:var(--accent-coral-dark);background:rgba(224,114,86,.2);padding:1px 6px;border-radius:999px;margin-bottom:6px}
|
||||||
|
|
||||||
|
/* ---- verdict banner ---- */
|
||||||
|
.verdict{display:flex;gap:13px;align-items:flex-start;padding:15px 17px;border-radius:var(--radius-md);margin:2px 0 20px}
|
||||||
|
.verdict.hard{background:var(--coral-bg)}
|
||||||
|
.verdict .vic{flex:none;width:22px;height:22px;border-radius:50%;display:grid;place-items:center;margin-top:1px;
|
||||||
|
background:var(--accent-coral-dark);color:#fff;font-size:12px;font-weight:700}
|
||||||
|
.verdict .vhead{font-weight:700;font-size:16.5px;letter-spacing:-.01em;color:var(--accent-coral-dark);text-wrap:balance}
|
||||||
|
.verdict .vsub{color:var(--text-secondary);font-size:13.5px;margin-top:3px}
|
||||||
|
|
||||||
|
/* ---- chart ---- */
|
||||||
|
.chartwrap{overflow-x:auto}
|
||||||
|
.chart{display:block;width:100%;min-width:460px;height:auto}
|
||||||
|
.axtxt{font-family:var(--sans);font-size:11px;fill:var(--text-muted)}
|
||||||
|
.ptlbl{font-family:var(--sans);font-size:12px;font-weight:700;fill:var(--accent-coral-dark)}
|
||||||
|
.keyrow{display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
||||||
|
.keyrow span{display:inline-flex;align-items:center;gap:7px}
|
||||||
|
.kdot{width:11px;height:11px;border-radius:50%;flex:none}
|
||||||
|
.kdot.line{background:var(--accent-coral)}
|
||||||
|
.kdot.open{background:#fff;box-shadow:inset 0 0 0 2px var(--accent-teal)}
|
||||||
|
.kdot.gap{background:repeating-linear-gradient(90deg,var(--text-muted) 0 2px,transparent 2px 4px);border-radius:0;height:2px}
|
||||||
|
|
||||||
|
/* ---- table ---- */
|
||||||
|
.tblwrap{overflow-x:auto;margin-top:4px}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:14px;min-width:420px}
|
||||||
|
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
||||||
|
color:var(--text-muted);padding:0 10px 9px;border-bottom:1px solid var(--border)}
|
||||||
|
thead th:first-child{text-align:left}
|
||||||
|
tbody td{text-align:right;padding:11px 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.now{background:var(--bg-secondary)}
|
||||||
|
td.miss{color:var(--text-muted);font-weight:400}
|
||||||
|
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;white-space:nowrap}
|
||||||
|
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
||||||
|
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
|
||||||
|
.pill.na{background:var(--bg-secondary);color:var(--text-muted)}
|
||||||
|
|
||||||
|
/* ---- map ---- */
|
||||||
|
.mapfig{border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);background:#eef2ec}
|
||||||
|
.mapsvg{display:block;width:100%;height:auto}
|
||||||
|
.maplegend{display:flex;flex-wrap:wrap;gap:14px 20px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
||||||
|
.maplegend span{display:inline-flex;align-items:center;gap:7px}
|
||||||
|
.swatch{width:14px;height:14px;border-radius:50%;flex:none}
|
||||||
|
.swatch.now{background:rgba(224,114,86,.18);box-shadow:inset 0 0 0 2px var(--accent-coral)}
|
||||||
|
.swatch.past{background:transparent;box-shadow:inset 0 0 0 1.5px rgba(176,74,46,.4)}
|
||||||
|
.swatch.you{background:var(--accent-teal);border-radius:2px;transform:rotate(45deg);width:11px;height:11px}
|
||||||
|
|
||||||
|
/* ---- postcode check ---- */
|
||||||
|
.checkbox{margin-top:20px;border-top:1px solid var(--border);padding-top:18px}
|
||||||
|
.checkhead{font-weight:700;font-size:15px;margin-bottom:3px}
|
||||||
|
.checksub{font-size:13.5px;color:var(--text-muted);margin-bottom:12px}
|
||||||
|
.checkform{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.checkform input{font:inherit;font-size:15px;padding:10px 13px;border:1px solid var(--border);border-radius:var(--radius-md);
|
||||||
|
background:var(--bg-card);color:var(--text-primary);min-width:150px;flex:1 1 150px;text-transform:uppercase}
|
||||||
|
.checkform input:focus-visible{outline:2px solid var(--accent-teal);outline-offset:1px;border-color:var(--accent-teal)}
|
||||||
|
.checkform button{font:inherit;font-weight:600;font-size:15px;padding:10px 20px;border:none;border-radius:var(--radius-md);
|
||||||
|
background:var(--accent-coral-dark);color:#fff;cursor:pointer;transition:background .15s}
|
||||||
|
.checkform button:hover{background:#9c3f26}
|
||||||
|
.checkform button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
||||||
|
.result{margin-top:14px;background:var(--teal-bg);border-radius:var(--radius-md);padding:15px 17px}
|
||||||
|
.result .rhead{font-weight:700;font-size:16px;color:var(--accent-teal);letter-spacing:-.01em;text-wrap:balance}
|
||||||
|
.result .rsub{font-size:13.5px;color:var(--text-secondary);margin-top:4px}
|
||||||
|
.yearstrip{display:flex;gap:4px;margin-top:12px;flex-wrap:wrap}
|
||||||
|
.yr{font-size:11px;font-weight:600;padding:3px 7px;border-radius:var(--radius-sm);white-space:nowrap}
|
||||||
|
.yr.in{background:rgba(45,125,125,.22);color:var(--accent-teal)}
|
||||||
|
.yr.out{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
||||||
|
.yr.none{background:var(--bg-secondary);color:var(--text-muted)}
|
||||||
|
|
||||||
|
/* ---- notes / disclosure ---- */
|
||||||
|
.note{display:flex;gap:9px;margin-top:16px;font-size:13px;color:var(--text-muted);line-height:1.55}
|
||||||
|
.note .i{flex:none;width:17px;height:17px;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{margin-top:16px;border-top:1px solid var(--border);padding-top:4px}
|
||||||
|
.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:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px;border-radius:var(--radius-sm)}
|
||||||
|
.disclosure .chev{transition:transform .2s ease}
|
||||||
|
.disclosure[open]>summary .chev{transform:rotate(90deg)}
|
||||||
|
.disclosure .body{padding:2px 0 10px;font-size:13.5px;color:var(--text-secondary);display:flex;flex-direction:column;gap:10px}
|
||||||
|
.disclosure .body b{color:var(--text-primary)}
|
||||||
|
|
||||||
|
/* ---- viewport stack (keeps card height stable across views) ---- */
|
||||||
|
.viewport{display:grid}
|
||||||
|
.viewport>.view{grid-area:1/1}
|
||||||
|
.viewport>.view[hidden]{display:block;visibility:hidden;pointer-events:none}
|
||||||
|
|
||||||
|
/* ---- edge cases ---- */
|
||||||
|
.cases{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
|
||||||
|
.case{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 17px}
|
||||||
|
.case h3{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted);margin-bottom:10px}
|
||||||
|
.case .body{font-size:14px;color:var(--text-secondary);line-height:1.5}
|
||||||
|
.case .body strong{color:var(--text-primary)}
|
||||||
|
.emptybox{background:var(--bg-secondary);border-radius:var(--radius-md);padding:13px 15px;font-size:13.5px;color:var(--text-secondary)}
|
||||||
|
|
||||||
|
/* ---- phone ---- */
|
||||||
|
.phonerow{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
|
||||||
|
.phone{width:330px;max-width:100%;border:9px solid #1a1612;border-radius:34px;overflow:hidden;box-shadow:var(--shadow-md);background:var(--bg-primary)}
|
||||||
|
.phonebody{padding:14px 13px 20px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.phone .card{padding:17px 16px 16px;border-radius:var(--radius-md)}
|
||||||
|
.phone .sectionTitle{font-size:18px}
|
||||||
|
.phone .tiles{grid-template-columns:1fr 1fr;gap:8px}
|
||||||
|
.phone .tile{padding:11px 12px}
|
||||||
|
.phone .tile .num{font-size:22px}
|
||||||
|
.phone .chart{min-width:0}
|
||||||
|
.phone .chartwrap{overflow:visible}
|
||||||
|
.phonenote{font-size:13px;color:var(--text-muted);flex:1 1 240px;min-width:220px}
|
||||||
|
.phonenote h3{font-family:var(--serif);font-size:17px;color:var(--text-primary);margin-bottom:8px;font-weight:600}
|
||||||
|
.phonenote ul{padding-left:18px;display:flex;flex-direction:column;gap:7px}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<div class="pagehead">
|
||||||
|
<h1>Last distance offered — school detail page</h1>
|
||||||
|
<p>Adds the final-offer cut-off distance to the existing <b>Admissions</b> card, plus a catchment
|
||||||
|
view on the map. Data covers one to ten years depending on the school and local authority, so every
|
||||||
|
screen here is built around partial coverage rather than assuming a full run.</p>
|
||||||
|
<p>Sample school: <b>Fairlawn Primary School</b>, Lewisham — 8 years of distance data out of 10 years of admissions data.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ 1. ADMISSIONS CARD ============ -->
|
||||||
|
<div class="step">
|
||||||
|
<h2>1. A distance tile joins the admissions tiles</h2>
|
||||||
|
<span class="where">SchoolDetailView · #admissions</span>
|
||||||
|
</div>
|
||||||
|
<p class="stepdesc">No new section and no new nav entry — the number a parent actually asks for
|
||||||
|
("how close do we need to live?") sits with the rest of the intake story. The segmented control gains a
|
||||||
|
third view, <code>Distance</code>.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2 class="sectionTitle">Admissions</h2>
|
||||||
|
<div class="seg" role="group" aria-label="Admissions view">
|
||||||
|
<button type="button" aria-pressed="true" data-view="year">This year</button>
|
||||||
|
<button type="button" aria-pressed="false" data-view="trend">10-year trend</button>
|
||||||
|
<button type="button" aria-pressed="false" data-view="dist">Distance</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="sectionSub">Reception entry, September 2025.</p>
|
||||||
|
|
||||||
|
<div class="viewport">
|
||||||
|
<!-- view: this year -->
|
||||||
|
<div class="view" id="v-year">
|
||||||
|
<dl class="tiles">
|
||||||
|
<div class="tile">
|
||||||
|
<dd class="num">60</dd><dt class="lbl">Places offered</dt>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<dd class="num">142</dd><dt class="lbl">Wanted it first</dt>
|
||||||
|
</div>
|
||||||
|
<div class="tile accent">
|
||||||
|
<dd class="num">54<span class="sub">of 142 · 38%</span></dd>
|
||||||
|
<dt class="lbl">Got their first choice</dt>
|
||||||
|
</div>
|
||||||
|
<div class="tile newtile">
|
||||||
|
<span class="newflag">New</span>
|
||||||
|
<dd class="num">0.31<span class="unit">mi</span><span class="sub">≈ 500 m · 6 min walk</span></dd>
|
||||||
|
<dt class="lbl">Last distance offered</dt>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="note">
|
||||||
|
<span class="i" aria-hidden="true">i</span>
|
||||||
|
<span>The furthest home offered a place once siblings, faith and EHCP priority were applied.
|
||||||
|
It is not a fixed catchment — it moves every year with the number of applications.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- view: distance -->
|
||||||
|
<div class="view" id="v-dist" hidden>
|
||||||
|
<div class="verdict hard">
|
||||||
|
<span class="vic" aria-hidden="true">↓</span>
|
||||||
|
<div>
|
||||||
|
<div class="vhead">The catchment has halved in nine years</div>
|
||||||
|
<div class="vsub">0.62 mi in 2016 → 0.31 mi in 2025. Four of the last five years tightened.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chartwrap">
|
||||||
|
<svg class="chart" viewBox="0 0 700 250" role="img"
|
||||||
|
aria-label="Last distance offered by year: 0.62 miles in 2016, 0.55 in 2017, not published in 2018, 0.48 in 2019, 0.51 in 2020, 0.44 in 2021, no cut-off needed in 2022, 0.39 in 2023, 0.35 in 2024, 0.31 in 2025.">
|
||||||
|
<!-- grid -->
|
||||||
|
<g stroke="#e5dfd5" stroke-width="1">
|
||||||
|
<line x1="46" y1="30" x2="686" y2="30"/>
|
||||||
|
<line x1="46" y1="80" x2="686" y2="80"/>
|
||||||
|
<line x1="46" y1="130" x2="686" y2="130"/>
|
||||||
|
<line x1="46" y1="180" x2="686" y2="180"/>
|
||||||
|
</g>
|
||||||
|
<line x1="46" y1="206" x2="686" y2="206" stroke="#d8d0c3" stroke-width="1.5"/>
|
||||||
|
<g class="axtxt" text-anchor="end">
|
||||||
|
<text x="38" y="34">0.8</text><text x="38" y="84">0.6</text>
|
||||||
|
<text x="38" y="134">0.4</text><text x="38" y="184">0.2</text>
|
||||||
|
</g>
|
||||||
|
<text class="axtxt" x="46" y="16" text-anchor="start">miles</text>
|
||||||
|
|
||||||
|
<!-- gap segments (dashed = no figure published) -->
|
||||||
|
<g fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="4 4" opacity=".55">
|
||||||
|
<path d="M114 92 L182 110"/>
|
||||||
|
<path d="M318 122 L386 132.5"/>
|
||||||
|
</g>
|
||||||
|
<!-- solid series -->
|
||||||
|
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
|
points="46,75 114,92"/>
|
||||||
|
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
|
points="182,110 250,102 318,122"/>
|
||||||
|
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
||||||
|
points="386,132.5 454,142.5 522,152.5"/>
|
||||||
|
|
||||||
|
<!-- points -->
|
||||||
|
<g fill="#e07256" stroke="#fff" stroke-width="2">
|
||||||
|
<circle cx="46" cy="75" r="5"/><circle cx="114" cy="92" r="5"/>
|
||||||
|
<circle cx="182" cy="110" r="5"/><circle cx="250" cy="102" r="5"/>
|
||||||
|
<circle cx="318" cy="122" r="5"/><circle cx="386" cy="132.5" r="5"/>
|
||||||
|
<circle cx="454" cy="142.5" r="5"/>
|
||||||
|
</g>
|
||||||
|
<!-- latest, emphasised -->
|
||||||
|
<circle cx="522" cy="152.5" r="7.5" fill="#b04a2e" stroke="#fff" stroke-width="2.5"/>
|
||||||
|
|
||||||
|
<!-- "no cut-off needed" marker, 2022 -->
|
||||||
|
<circle cx="352" cy="46" r="6" fill="#fff" stroke="#296f6f" stroke-width="2.5"/>
|
||||||
|
<text class="axtxt" x="352" y="34" text-anchor="middle" fill="#296f6f" font-weight="600">all offered</text>
|
||||||
|
<line x1="352" y1="54" x2="352" y2="196" stroke="#296f6f" stroke-width="1.5" stroke-dasharray="3 4" opacity=".45"/>
|
||||||
|
|
||||||
|
<!-- endpoint labels -->
|
||||||
|
<text class="ptlbl" x="46" y="63" text-anchor="start">0.62</text>
|
||||||
|
<text class="ptlbl" x="530" y="157" text-anchor="start">0.31 mi</text>
|
||||||
|
|
||||||
|
<!-- year axis -->
|
||||||
|
<g class="axtxt" text-anchor="middle">
|
||||||
|
<text x="46" y="226">2016</text><text x="114" y="226">2017</text>
|
||||||
|
<text x="182" y="226">2019</text><text x="250" y="226">2020</text>
|
||||||
|
<text x="318" y="226">2021</text><text x="386" y="226">2023</text>
|
||||||
|
<text x="454" y="226">2024</text><text x="522" y="226">2025</text>
|
||||||
|
</g>
|
||||||
|
<text class="axtxt" x="148" y="243" text-anchor="middle" fill="#6d685f">2018 not published</text>
|
||||||
|
<text class="axtxt" x="352" y="243" text-anchor="middle" fill="#296f6f">2022 undersubscribed</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="keyrow">
|
||||||
|
<span><i class="kdot line" aria-hidden="true"></i>Distance of the last place offered</span>
|
||||||
|
<span><i class="kdot open" aria-hidden="true"></i>No cut-off needed — every applicant offered</span>
|
||||||
|
<span><i class="kdot gap" aria-hidden="true"></i>Not published by the local authority</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tblwrap" style="margin-top:22px">
|
||||||
|
<table>
|
||||||
|
<caption class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Last distance offered by year</caption>
|
||||||
|
<thead>
|
||||||
|
<tr><th scope="col">Year</th><th scope="col">Last distance</th><th scope="col">Places</th><th scope="col">Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="now"><td>2025</td><td>0.31 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
<tr><td>2024</td><td>0.35 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
<tr><td>2023</td><td>0.39 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
<tr><td>2022</td><td class="miss">No cut-off needed</td><td>60</td><td><span class="pill ok">All offered</span></td></tr>
|
||||||
|
<tr><td>2021</td><td>0.44 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
<tr><td>2020</td><td>0.51 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
<tr><td>2019</td><td>0.48 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
<tr><td>2018</td><td class="miss">—</td><td>60</td><td><span class="pill na">Not published</span></td></tr>
|
||||||
|
<tr><td>2017</td><td>0.55 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
<tr><td>2016</td><td>0.62 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="disclosure">
|
||||||
|
<summary><span class="chev" aria-hidden="true">›</span>What this number does and doesn't tell you</summary>
|
||||||
|
<div class="body">
|
||||||
|
<span><b>It is a result, not a rule.</b> It records how far the last successful applicant lived
|
||||||
|
in a given year. Move one large sibling cohort and the figure shifts.</span>
|
||||||
|
<span><b>Distance is the final tiebreak.</b> Children in care, EHCP places, siblings and — at faith
|
||||||
|
schools — the faith criteria are ranked first. A family inside the distance can still miss out.</span>
|
||||||
|
<span><b>Lewisham measures straight-line distance</b> from home to the school's main gate. Other
|
||||||
|
authorities use walking routes, which are always longer for the same home.</span>
|
||||||
|
<span><b>Gaps are normal.</b> An authority may not publish a figure, or the school may not have
|
||||||
|
needed a distance cut-off that year. Both are shown here rather than hidden.</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ 2. MAP ============ -->
|
||||||
|
<div class="step">
|
||||||
|
<h2>2. The cut-off drawn on the map</h2>
|
||||||
|
<span class="where">SchoolHeroMap · catchment layer</span>
|
||||||
|
</div>
|
||||||
|
<p class="stepdesc">"0.31 miles" is abstract until you see it over your own streets. The latest year is a
|
||||||
|
filled ring; earlier years sit behind it as hairlines, so the tightening reads instantly as a set of
|
||||||
|
shrinking circles. Straight-line rings only — they're an illustration of the number, not a boundary.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="cardhead" style="margin-bottom:14px">
|
||||||
|
<h2 class="sectionTitle">Where the last place went</h2>
|
||||||
|
<div class="seg" role="group" aria-label="Map years">
|
||||||
|
<button type="button" aria-pressed="true">2025 only</button>
|
||||||
|
<button type="button" aria-pressed="false">Last 5 years</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure class="mapfig">
|
||||||
|
<svg class="mapsvg" viewBox="0 0 700 400" role="img"
|
||||||
|
aria-label="Map showing concentric catchment rings around Fairlawn Primary School: 0.62 miles in 2016 shrinking to 0.31 miles in 2025, with a marker for a sample home 0.24 miles away, inside the current ring.">
|
||||||
|
<rect width="700" height="400" fill="#eef2ec"/>
|
||||||
|
<!-- park -->
|
||||||
|
<path d="M470 20 h230 v150 h-160 q-70 -20 -70 -80 z" fill="#dfe9dc"/>
|
||||||
|
<!-- water -->
|
||||||
|
<path d="M0 330 q120 -40 250 -10 t260 -20 l190 -30 v130 H0 z" fill="#dbe6ee"/>
|
||||||
|
<!-- street grid -->
|
||||||
|
<g stroke="#fff" stroke-width="7" stroke-linecap="round" opacity=".95">
|
||||||
|
<path d="M-10 90 H710"/><path d="M-10 200 H710"/><path d="M-10 300 H710"/>
|
||||||
|
<path d="M120 -10 V410"/><path d="M300 -10 V410"/><path d="M470 -10 V410"/><path d="M620 -10 V410"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#fff" stroke-width="3.5" opacity=".8">
|
||||||
|
<path d="M-10 145 H710"/><path d="M-10 250 H710"/><path d="M210 -10 V410"/><path d="M385 -10 V410"/><path d="M550 -10 V410"/>
|
||||||
|
</g>
|
||||||
|
<!-- building blocks -->
|
||||||
|
<g fill="#e4e2dc" opacity=".85">
|
||||||
|
<rect x="132" y="102" width="60" height="30" rx="2"/><rect x="222" y="102" width="64" height="30" rx="2"/>
|
||||||
|
<rect x="132" y="212" width="60" height="26" rx="2"/><rect x="222" y="212" width="64" height="26" rx="2"/>
|
||||||
|
<rect x="400" y="102" width="56" height="30" rx="2"/><rect x="400" y="212" width="56" height="26" rx="2"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- historic rings (hairline) -->
|
||||||
|
<g fill="none" stroke="#b04a2e" opacity=".38" stroke-width="1.5">
|
||||||
|
<circle cx="300" cy="200" r="150"/>
|
||||||
|
<circle cx="300" cy="200" r="126"/>
|
||||||
|
<circle cx="300" cy="200" r="106"/>
|
||||||
|
<circle cx="300" cy="200" r="90"/>
|
||||||
|
</g>
|
||||||
|
<text class="axtxt" x="300" y="44" text-anchor="middle" fill="#b04a2e" font-weight="600">2016 · 0.62 mi</text>
|
||||||
|
<!-- current ring -->
|
||||||
|
<circle cx="300" cy="200" r="75" fill="rgba(224,114,86,.18)" stroke="#e07256" stroke-width="3"/>
|
||||||
|
<text class="axtxt" x="300" y="118" text-anchor="middle" fill="#b04a2e" font-weight="700" font-size="12.5">2025 · 0.31 mi</text>
|
||||||
|
|
||||||
|
<!-- school pin -->
|
||||||
|
<circle cx="300" cy="200" r="11" fill="#b04a2e" stroke="#fff" stroke-width="3"/>
|
||||||
|
<text class="axtxt" x="300" y="232" text-anchor="middle" fill="#1a1612" font-weight="700" font-size="12">Fairlawn Primary</text>
|
||||||
|
|
||||||
|
<!-- your home -->
|
||||||
|
<g transform="translate(246,158)">
|
||||||
|
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="#296f6f" stroke="#fff" stroke-width="2.5" transform="rotate(45)"/>
|
||||||
|
</g>
|
||||||
|
<text class="axtxt" x="246" y="140" text-anchor="middle" fill="#296f6f" font-weight="700" font-size="12">Your home · 0.24 mi</text>
|
||||||
|
</svg>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="maplegend">
|
||||||
|
<span><i class="swatch now" aria-hidden="true"></i>2025 cut-off — 0.31 mi</span>
|
||||||
|
<span><i class="swatch past" aria-hidden="true"></i>Earlier years, 2016–2024</span>
|
||||||
|
<span><i class="swatch you" aria-hidden="true"></i>Your home</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox">
|
||||||
|
<div class="checkhead">Would you have got in?</div>
|
||||||
|
<p class="checksub">We measure straight-line distance from your postcode, the same way Lewisham does.</p>
|
||||||
|
<form class="checkform" onsubmit="return false">
|
||||||
|
<label class="sr-only" for="pc" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Your postcode</label>
|
||||||
|
<input id="pc" type="text" value="SE23 3NA" autocomplete="postal-code" spellcheck="false">
|
||||||
|
<button type="submit">Check</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="result">
|
||||||
|
<div class="rhead">0.24 miles away — inside the cut-off in all 8 years on record</div>
|
||||||
|
<div class="rsub">That's 0.07 miles of headroom on 2025, the tightest year so far. 2018 has no published
|
||||||
|
figure, and in 2022 every applicant was offered a place.</div>
|
||||||
|
<div class="yearstrip">
|
||||||
|
<span class="yr in">2016 ✓</span>
|
||||||
|
<span class="yr in">2017 ✓</span>
|
||||||
|
<span class="yr none">2018 –</span>
|
||||||
|
<span class="yr in">2019 ✓</span>
|
||||||
|
<span class="yr in">2020 ✓</span>
|
||||||
|
<span class="yr in">2021 ✓</span>
|
||||||
|
<span class="yr in">2022 ✓</span>
|
||||||
|
<span class="yr in">2023 ✓</span>
|
||||||
|
<span class="yr in">2024 ✓</span>
|
||||||
|
<span class="yr in">2025 ✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<span class="i" aria-hidden="true">!</span>
|
||||||
|
<span>An indication only. Distance is applied after siblings, faith and EHCP priority, and next year's
|
||||||
|
cut-off depends on next year's applicants. Always check the school's own admissions policy.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ 3. COVERAGE STATES ============ -->
|
||||||
|
<div class="step">
|
||||||
|
<h2>3. Coverage states</h2>
|
||||||
|
<span class="where">Partial data is the normal case</span>
|
||||||
|
</div>
|
||||||
|
<p class="stepdesc">Coverage runs from ten years to none. Each state says something true rather than
|
||||||
|
falling back on a generic "no data" — the reason a figure is absent is itself useful to a parent.</p>
|
||||||
|
|
||||||
|
<div class="cases">
|
||||||
|
<div class="case">
|
||||||
|
<h3>4+ years</h3>
|
||||||
|
<div class="body">Full treatment: verdict banner, chart, table, map rings. <strong>The verdict line only
|
||||||
|
appears with 4+ points</strong> — below that a two-year swing isn't a trend.</div>
|
||||||
|
</div>
|
||||||
|
<div class="case">
|
||||||
|
<h3>2–3 years</h3>
|
||||||
|
<div class="body">Tile and table, no verdict banner, single map ring for the latest year.
|
||||||
|
<strong>"Only 3 years available"</strong> sits under the table.</div>
|
||||||
|
</div>
|
||||||
|
<div class="case">
|
||||||
|
<h3>1 year</h3>
|
||||||
|
<div class="body">Tile plus one map ring. No <code>Distance</code> tab — the tile and its footnote are
|
||||||
|
the whole story.</div>
|
||||||
|
</div>
|
||||||
|
<div class="case">
|
||||||
|
<h3>Never oversubscribed</h3>
|
||||||
|
<div class="body" style="margin-bottom:10px">Not missing data — good news, and it should read that way.</div>
|
||||||
|
<div class="emptybox"><b style="color:var(--accent-teal)">Everyone who applied was offered a place</b>
|
||||||
|
in each of the last 6 years, so no distance cut-off was needed.</div>
|
||||||
|
</div>
|
||||||
|
<div class="case">
|
||||||
|
<h3>No data at all</h3>
|
||||||
|
<div class="body" style="margin-bottom:10px">Replaces the current placeholder text in
|
||||||
|
<code>SecondarySchoolDetailView.tsx:780</code>.</div>
|
||||||
|
<div class="emptybox">Lewisham hasn't published cut-off distances for this school.
|
||||||
|
<a href="#" style="color:var(--accent-teal);font-weight:600">The council's admissions page</a> may list them.</div>
|
||||||
|
</div>
|
||||||
|
<div class="case">
|
||||||
|
<h3>Not distance-ranked</h3>
|
||||||
|
<div class="body" style="margin-bottom:10px">Grammar and some faith schools rank on test score or faith
|
||||||
|
practice, so a distance figure would mislead.</div>
|
||||||
|
<div class="emptybox">Places here are ranked by the entrance test, not by distance.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ 4. MOBILE ============ -->
|
||||||
|
<div class="step">
|
||||||
|
<h2>4. Mobile</h2>
|
||||||
|
<span class="where">≤ 640 px</span>
|
||||||
|
</div>
|
||||||
|
<p class="stepdesc">Tiles fall to two columns and the distance tile takes the full width beneath them, so the
|
||||||
|
headline number survives the reflow. The chart drops its table on mobile behind a "See all years" disclosure.</p>
|
||||||
|
|
||||||
|
<div class="phonerow">
|
||||||
|
<div class="phone">
|
||||||
|
<div class="phonebody">
|
||||||
|
<div class="card">
|
||||||
|
<div class="cardhead" style="margin-bottom:6px">
|
||||||
|
<h2 class="sectionTitle">Admissions</h2>
|
||||||
|
</div>
|
||||||
|
<p class="sectionSub" style="font-size:13px;margin-bottom:12px">Reception, Sept 2025</p>
|
||||||
|
<div class="seg" style="margin-bottom:14px">
|
||||||
|
<button type="button" aria-pressed="true">Year</button>
|
||||||
|
<button type="button" aria-pressed="false">Trend</button>
|
||||||
|
<button type="button" aria-pressed="false">Distance</button>
|
||||||
|
</div>
|
||||||
|
<dl class="tiles">
|
||||||
|
<div class="tile"><dd class="num">60</dd><dt class="lbl">Places offered</dt></div>
|
||||||
|
<div class="tile"><dd class="num">142</dd><dt class="lbl">Wanted it first</dt></div>
|
||||||
|
</dl>
|
||||||
|
<div class="tile newtile" style="margin-top:8px">
|
||||||
|
<span class="newflag">New</span>
|
||||||
|
<dd class="num" style="font-size:26px">0.31<span class="unit">mi</span>
|
||||||
|
<span class="sub">≈ 500 m · 6 min walk</span></dd>
|
||||||
|
<dt class="lbl">Last distance offered, 2025</dt>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="sectionTitle" style="margin-bottom:12px">Distance</h2>
|
||||||
|
<div class="verdict hard" style="padding:12px 13px;margin:0 0 14px">
|
||||||
|
<span class="vic" aria-hidden="true">↓</span>
|
||||||
|
<div><div class="vhead" style="font-size:15px">Halved in nine years</div>
|
||||||
|
<div class="vsub" style="font-size:12.5px">0.62 mi → 0.31 mi</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="chartwrap">
|
||||||
|
<svg class="chart" viewBox="0 0 300 150" role="img" aria-label="Last distance offered falling from 0.62 miles in 2016 to 0.31 miles in 2025.">
|
||||||
|
<g stroke="#e5dfd5" stroke-width="1">
|
||||||
|
<line x1="26" y1="20" x2="292" y2="20"/><line x1="26" y1="62" x2="292" y2="62"/><line x1="26" y1="104" x2="292" y2="104"/>
|
||||||
|
</g>
|
||||||
|
<g class="axtxt" text-anchor="end" font-size="9">
|
||||||
|
<text x="21" y="23">0.8</text><text x="21" y="65">0.5</text><text x="21" y="107">0.2</text>
|
||||||
|
</g>
|
||||||
|
<path d="M26 55 L64 69" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M64 69 L102 84" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
||||||
|
<path d="M102 84 L140 78 L178 95" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
<path d="M178 95 L216 109" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
||||||
|
<path d="M216 109 L254 116 L280 120" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
<g fill="#e07256" stroke="#fff" stroke-width="1.8">
|
||||||
|
<circle cx="26" cy="55" r="4"/><circle cx="64" cy="69" r="4"/><circle cx="102" cy="84" r="4"/>
|
||||||
|
<circle cx="140" cy="78" r="4"/><circle cx="178" cy="95" r="4"/><circle cx="216" cy="109" r="4"/><circle cx="254" cy="116" r="4"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="280" cy="120" r="6" fill="#b04a2e" stroke="#fff" stroke-width="2"/>
|
||||||
|
<circle cx="197" cy="30" r="4.5" fill="#fff" stroke="#296f6f" stroke-width="2"/>
|
||||||
|
<g class="axtxt" font-size="9" text-anchor="middle">
|
||||||
|
<text x="26" y="132">'16</text><text x="140" y="132">'20</text><text x="280" y="132">'25</text>
|
||||||
|
</g>
|
||||||
|
<text class="ptlbl" x="280" y="140" text-anchor="middle" font-size="11">0.31 mi</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<details class="disclosure" style="margin-top:10px">
|
||||||
|
<summary style="font-size:13px"><span class="chev" aria-hidden="true">›</span>See all 10 years</summary>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="phonenote">
|
||||||
|
<h3>Mobile decisions</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Distance tile spans both columns — the number a parent came for shouldn't be a half-width cell.</li>
|
||||||
|
<li>Chart keeps every year but labels only first, middle and last; the endpoint stays labelled.</li>
|
||||||
|
<li>Year-by-year table collapses into a disclosure rather than forcing a horizontal scroll.</li>
|
||||||
|
<li>Map rings reuse the existing full-screen hero map sheet, opened from the tile.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Segmented control on the admissions card swaps the stacked views.
|
||||||
|
document.querySelectorAll('.seg button[data-view]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var group = btn.closest('.seg');
|
||||||
|
group.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
|
||||||
|
var target = btn.dataset.view === 'dist' ? 'v-dist' : 'v-year';
|
||||||
|
['v-year', 'v-dist'].forEach(function (id) {
|
||||||
|
document.getElementById(id).hidden = id !== target;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Last distance offered, rendered on both detail templates.
|
||||||
|
*
|
||||||
|
* The figure is the one number on these pages that a parent may act on — it is
|
||||||
|
* easy to read as "we live inside the catchment, we will get a place". These
|
||||||
|
* tests pin the things that stop it being read that way: the year is always
|
||||||
|
* present, the caveat is always present, and the blanket "not available"
|
||||||
|
* sentence appears only when it is actually true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||||
|
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||||
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||||
|
|
||||||
|
const cutoff = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||||
|
year: 2025,
|
||||||
|
distance_m: 500,
|
||||||
|
route_count: 1,
|
||||||
|
la_name: 'Camden',
|
||||||
|
distance_unit_raw: 'miles',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('primary detail page', () => {
|
||||||
|
it('shows the figure with the year it belongs to', () => {
|
||||||
|
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49, year: 2024 }) });
|
||||||
|
|
||||||
|
expect(screen.getByText('0.48 miles')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never shows the figure without saying it is not a catchment', () => {
|
||||||
|
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
|
||||||
|
|
||||||
|
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/moves every year/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags that a banded school\'s figure is the widest of several routes', () => {
|
||||||
|
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ route_count: 4 }) });
|
||||||
|
|
||||||
|
expect(screen.getByText(/4 admission routes/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing distance-related when the LA publishes none', () => {
|
||||||
|
renderSchoolDetail({ ...primaryFixture, admissionDistance: null });
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Last distance offered/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/not a fixed catchment/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the figure even with no EES admissions row', () => {
|
||||||
|
// The two sources are independent; this school has a cut-off and no
|
||||||
|
// admissions figures. Before this feature the section did not render at all.
|
||||||
|
renderSchoolDetail({
|
||||||
|
...primaryFixture,
|
||||||
|
admissions: null,
|
||||||
|
admissionsHistory: [],
|
||||||
|
admissionDistance: cutoff({ distance_m: 1421.05 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('0.88 miles')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secondary detail page', () => {
|
||||||
|
it('shows the figure with the year it belongs to', () => {
|
||||||
|
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff({ distance_m: 3472.96 }) });
|
||||||
|
|
||||||
|
expect(screen.getByText('2.16 miles')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2025');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the blanket "not available" line once a distance exists', () => {
|
||||||
|
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff() });
|
||||||
|
|
||||||
|
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the authority that would hold the data when there is none', () => {
|
||||||
|
// The old copy asserted "Historical distance cut-off data is not available
|
||||||
|
// for this school" on every secondary page, including the ones whose
|
||||||
|
// council does publish it.
|
||||||
|
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: null });
|
||||||
|
|
||||||
|
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Last distance offered — formatting and the caveats attached to the figure.
|
||||||
|
*
|
||||||
|
* The assertions about the route note and the year are not cosmetic. A cut-off
|
||||||
|
* shown without its year, or a banded school's widest cut-off shown as if it
|
||||||
|
* were the only one, tells a parent something false about their chances of a
|
||||||
|
* place — so both are pinned here rather than left to the component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
||||||
|
import { describeCutoff } from '@/components/school/lastDistanceOffered';
|
||||||
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||||
|
|
||||||
|
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||||
|
year: 2025,
|
||||||
|
distance_m: 500,
|
||||||
|
route_count: 1,
|
||||||
|
la_name: 'Camden',
|
||||||
|
distance_unit_raw: 'miles',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatCutoffDistance', () => {
|
||||||
|
it('leads with miles, the unit councils publish in', () => {
|
||||||
|
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
|
||||||
|
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to kilometres for the support figure above a kilometre', () => {
|
||||||
|
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leads with metres below 100 m, where a miles figure carries nothing', () => {
|
||||||
|
expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null rather than a zero cut-off', () => {
|
||||||
|
// 0.0 miles appears in the source where a school filled on a higher
|
||||||
|
// criterion. Rendered as "0.00 miles" it would read as the opposite.
|
||||||
|
expect(formatCutoffDistance(0)).toBeNull();
|
||||||
|
expect(formatCutoffDistance(null)).toBeNull();
|
||||||
|
expect(formatCutoffDistance(undefined)).toBeNull();
|
||||||
|
expect(formatCutoffDistance(Number.NaN)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatEntryYear', () => {
|
||||||
|
it('names the intake, not the academic year', () => {
|
||||||
|
// formatAcademicYear would render 2025 as "2025/26", which reads as a
|
||||||
|
// school year rather than the September a child started.
|
||||||
|
expect(formatEntryYear(2025)).toBe('September 2025');
|
||||||
|
expect(formatEntryYear(null)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeCutoff', () => {
|
||||||
|
it('always carries the entry year alongside the figure', () => {
|
||||||
|
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
|
||||||
|
expect(d).not.toBeNull();
|
||||||
|
expect(d!.primary).toBe('0.48 miles');
|
||||||
|
expect(d!.entryYear).toBe('September 2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about routes for a school with one', () => {
|
||||||
|
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
|
||||||
|
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns that a banded school\'s figure is the widest of several', () => {
|
||||||
|
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
|
||||||
|
expect(note).toContain('4 admission routes');
|
||||||
|
expect(note).toContain('shorter cut-off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when there is nothing publishable', () => {
|
||||||
|
expect(describeCutoff(null)).toBeNull();
|
||||||
|
expect(describeCutoff(undefined)).toBeNull();
|
||||||
|
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,7 +60,7 @@ describe('buildNavItems', () => {
|
|||||||
it('omits sections with no data', () => {
|
it('omits sections with no data', () => {
|
||||||
const flags = computeSchoolFlags(specialFixture);
|
const flags = computeSchoolFlags(specialFixture);
|
||||||
const ids = buildNavItems(flags, {
|
const ids = buildNavItems(flags, {
|
||||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).not.toContain('ofsted');
|
expect(ids).not.toContain('ofsted');
|
||||||
@@ -74,6 +74,7 @@ describe('buildNavItems', () => {
|
|||||||
return buildNavItems(flags, {
|
return buildNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
|
admissionDistance: null,
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
}).find((n) => n.id === 'results')?.label;
|
}).find((n) => n.id === 'results')?.label;
|
||||||
};
|
};
|
||||||
@@ -83,11 +84,27 @@ describe('buildNavItems', () => {
|
|||||||
expect(label(allThroughFixture)).toBe('Results');
|
expect(label(allThroughFixture)).toBe('Results');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the admissions entry for a cut-off distance with no EES admissions', () => {
|
||||||
|
// 3% of the schools that render have one source and not the other. The nav
|
||||||
|
// condition and the section's render condition have to agree, or the sticky
|
||||||
|
// nav links to an anchor that was never rendered.
|
||||||
|
const flags = computeSchoolFlags(specialFixture);
|
||||||
|
const ids = buildNavItems(flags, {
|
||||||
|
ofsted: null,
|
||||||
|
admissions: null,
|
||||||
|
admissionDistance: { year: 2025, distance_m: 500, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles' },
|
||||||
|
yearlyDataLength: 1,
|
||||||
|
}).map((n) => n.id);
|
||||||
|
|
||||||
|
expect(ids).toContain('admissions');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the engagement-led ordering', () => {
|
it('keeps the engagement-led ordering', () => {
|
||||||
const flags = computeSchoolFlags(primaryFixture);
|
const flags = computeSchoolFlags(primaryFixture);
|
||||||
const ids = buildNavItems(flags, {
|
const ids = buildNavItems(flags, {
|
||||||
ofsted: primaryFixture.ofsted,
|
ofsted: primaryFixture.ofsted,
|
||||||
admissions: primaryFixture.admissions,
|
admissions: primaryFixture.admissions,
|
||||||
|
admissionDistance: null,
|
||||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
@@ -127,16 +144,29 @@ describe('buildSecondaryNavItems', () => {
|
|||||||
const ids = buildSecondaryNavItems(flags, {
|
const ids = buildSecondaryNavItems(flags, {
|
||||||
ofsted: secondaryFixture.ofsted,
|
ofsted: secondaryFixture.ofsted,
|
||||||
admissions: secondaryFixture.admissions,
|
admissions: secondaryFixture.admissions,
|
||||||
|
admissionDistance: null,
|
||||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens the admissions entry for a cut-off distance alone', () => {
|
||||||
|
const flags = computeSecondaryFlags(secondaryFixture);
|
||||||
|
const ids = buildSecondaryNavItems(flags, {
|
||||||
|
ofsted: null,
|
||||||
|
admissions: null,
|
||||||
|
admissionDistance: { year: 2025, distance_m: 2400, route_count: 4, la_name: 'Islington', distance_unit_raw: 'miles' },
|
||||||
|
yearlyDataLength: 1,
|
||||||
|
}).map((n) => n.id);
|
||||||
|
|
||||||
|
expect(ids).toContain('admissions');
|
||||||
|
});
|
||||||
|
|
||||||
it('gates History on more than one year, unlike the primary page', () => {
|
it('gates History on more than one year, unlike the primary page', () => {
|
||||||
const flags = computeSecondaryFlags(secondaryFixture);
|
const flags = computeSecondaryFlags(secondaryFixture);
|
||||||
const ids = buildSecondaryNavItems(flags, {
|
const ids = buildSecondaryNavItems(flags, {
|
||||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).not.toContain('history');
|
expect(ids).not.toContain('history');
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function renderSchoolDetail(fixture: any) {
|
|||||||
const navItems = buildNavItems(flags, {
|
const navItems = buildNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
|
admissionDistance: fixture.admissionDistance ?? null,
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
|||||||
const navItems = buildSecondaryNavItems(flags, {
|
const navItems = buildSecondaryNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
|
admissionDistance: fixture.admissionDistance ?? null,
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, 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/', '');
|
||||||
@@ -176,6 +176,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
const navInput = {
|
const navInput = {
|
||||||
ofsted: ofsted ?? null,
|
ofsted: ofsted ?? null,
|
||||||
admissions: admissions ?? null,
|
admissions: admissions ?? null,
|
||||||
|
admissionDistance: admission_distance ?? null,
|
||||||
yearlyDataLength: yearly_data.length,
|
yearlyDataLength: yearly_data.length,
|
||||||
};
|
};
|
||||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||||
@@ -228,6 +229,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
|
admissionDistance={admission_distance ?? null}
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
@@ -249,6 +251,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
|
admissionDistance={admission_distance ?? null}
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
|
|||||||
@@ -12,59 +12,112 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { SchoolAdmissions } from '@/lib/types';
|
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
||||||
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
||||||
import { AdmissionsTrendChart } from './charts';
|
import { AdmissionsTrendChart } from './charts';
|
||||||
|
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
||||||
|
|
||||||
|
|
||||||
export function AdmissionsSection({
|
export function AdmissionsSection({
|
||||||
admissions,
|
admissions,
|
||||||
admissionsHistory,
|
admissionsHistory,
|
||||||
|
admissionDistance,
|
||||||
isAllThrough,
|
isAllThrough,
|
||||||
}: {
|
}: {
|
||||||
admissions: SchoolAdmissions;
|
/* Nullable: the two data sources are independent. A minority of schools have
|
||||||
|
a published cut-off distance but no EES admissions row (3% of the schools
|
||||||
|
that render, sampled against staging), and for those this section exists
|
||||||
|
to carry the distance alone. */
|
||||||
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
isAllThrough: boolean;
|
isAllThrough: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const cutoff = describeCutoff(admissionDistance);
|
||||||
|
|
||||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
|
||||||
// Banded interpretation of the first-choice offer rate ("More than half of
|
// Banded interpretation of the first-choice offer rate ("More than half of
|
||||||
// first choices missed out" etc.) — the same banding the compare screen
|
// first choices missed out" etc.) — the same banding the compare screen
|
||||||
// uses, so a low offer rate reads as how severe it actually is.
|
// uses, so a low offer rate reads as how severe it actually is.
|
||||||
const admissionsSummary = summariseAdmissions(admissions);
|
const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
|
||||||
|
|
||||||
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
|
const title = (
|
||||||
|
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
|
||||||
|
);
|
||||||
|
|
||||||
{/* All-through admissions data covers a single entry point (usually the
|
{/* All-through admissions data covers a single entry point (usually the
|
||||||
Year 7 secondary intake), not reception — say so, or a parent could
|
Year 7 secondary intake), not reception — say so, or a parent could
|
||||||
read these as the whole-school figures. */}
|
read these as the whole-school figures. */}
|
||||||
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
|
const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
These figures are for {admissions.school_phase.toLowerCase()} entry
|
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||||
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||||
</p>
|
</p>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
{/* Spans both columns rather than taking a half-width cell. This is the
|
||||||
|
figure parents come to the page for, and at tile width the two-line
|
||||||
|
"0.31 miles / September 2025" pairing wraps badly. */}
|
||||||
|
const distanceTile = cutoff && (
|
||||||
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
|
||||||
|
<dd className={styles.admissionsTileNum}>
|
||||||
|
{cutoff.primary}
|
||||||
|
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
|
||||||
|
</dd>
|
||||||
|
<dt className={styles.admissionsTileLabel}>
|
||||||
|
Last distance offered · {cutoff.entryYear}
|
||||||
|
</dt>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const distanceNote = cutoff && (
|
||||||
|
<p className={styles.admissionsDistanceNote}>
|
||||||
|
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
||||||
|
{cutoff.routeNote && <> {cutoff.routeNote}</>}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Which row template the tile grid needs.
|
||||||
|
*
|
||||||
|
* The grid reserves two equal rows so the year view matches the height of the
|
||||||
|
* trend chart beside it. With a cut-off and no EES admissions there are no
|
||||||
|
* metric tiles to fill them, and the reserved rows render as a bare block of
|
||||||
|
* the grid's own gap colour under the distance tile.
|
||||||
|
*/
|
||||||
|
const hasMetricTiles = admissions != null && (
|
||||||
|
admissions.places_offered != null
|
||||||
|
|| admissions.first_preference_applications != null
|
||||||
|
|| admissions.first_preference_offer_pct != null
|
||||||
|
|| admissions.total_applications != null
|
||||||
|
);
|
||||||
|
const tilesClass = [
|
||||||
|
styles.admissionsTiles,
|
||||||
|
cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '',
|
||||||
|
cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
const yearView = (
|
const yearView = (
|
||||||
<>
|
<>
|
||||||
<dl className={styles.admissionsTiles}>
|
<dl className={tilesClass}>
|
||||||
{admissions.places_offered != null && (
|
{admissions?.places_offered != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.first_preference_applications != null && (
|
{admissions?.first_preference_applications != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.first_preference_offer_pct != null && (
|
{admissions?.first_preference_offer_pct != null && (
|
||||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
||||||
<dd className={styles.admissionsTileNum}>
|
<dd className={styles.admissionsTileNum}>
|
||||||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||||||
@@ -81,20 +134,25 @@ export function AdmissionsSection({
|
|||||||
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.total_applications != null && (
|
{admissions?.total_applications != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{distanceTile}
|
||||||
</dl>
|
</dl>
|
||||||
{admissionsSummary.chip && (
|
{admissionsSummary?.chip && (
|
||||||
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||||
)}
|
)}
|
||||||
|
{distanceNote}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const trendView = (
|
{/* Guarded rather than asserted: showAdmissionsTrend already requires
|
||||||
|
admissions, and tying the two together in one expression keeps that
|
||||||
|
invariant checked by the compiler instead of assumed. */}
|
||||||
|
const trendView = admissions && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
||||||
<AdmissionsTrendChart history={admissionsHistory} />
|
<AdmissionsTrendChart history={admissionsHistory} />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SchoolFlags } from '@/lib/schoolSections';
|
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||||
@@ -34,6 +34,7 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -42,7 +43,7 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function PrimarySchoolSections({
|
export function PrimarySchoolSections({
|
||||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||||
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
|
admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
|
||||||
}: PrimarySchoolSectionsProps) {
|
}: PrimarySchoolSectionsProps) {
|
||||||
const primaryAvg = nationalAvg?.primary ?? {};
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
@@ -91,10 +92,15 @@ export function PrimarySchoolSections({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{admissions && (
|
{/* Either source is enough to justify the section. The cut-off distance
|
||||||
|
and the EES admissions figures come from different places and a
|
||||||
|
minority of schools have one without the other — gating on admissions
|
||||||
|
alone would hide a published distance on those pages. */}
|
||||||
|
{(admissions || admissionDistance) && (
|
||||||
<AdmissionsSection
|
<AdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionsHistory={admissionsHistory}
|
admissionsHistory={admissionsHistory}
|
||||||
|
admissionDistance={admissionDistance}
|
||||||
isAllThrough={flags.isAllThrough}
|
isAllThrough={flags.isAllThrough}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,17 +6,22 @@
|
|||||||
* JavaScript at all. Server component.
|
* JavaScript at all. Server component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School, SchoolAdmissions } from '@/lib/types';
|
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
||||||
import { formatPercentage } from '@/lib/utils';
|
import { formatPercentage } from '@/lib/utils';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
|
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
||||||
|
|
||||||
export function SecondaryAdmissionsSection({
|
export function SecondaryAdmissionsSection({
|
||||||
admissions, schoolInfo, hasSixthForm,
|
admissions, admissionDistance, schoolInfo, hasSixthForm,
|
||||||
}: {
|
}: {
|
||||||
admissions: SchoolAdmissions;
|
/* Nullable for the same reason as the primary section: a school can have a
|
||||||
|
published cut-off and no EES admissions row. */
|
||||||
|
admissions: SchoolAdmissions | null;
|
||||||
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
schoolInfo: School;
|
schoolInfo: School;
|
||||||
hasSixthForm: boolean;
|
hasSixthForm: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const cutoff = describeCutoff(admissionDistance);
|
||||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||||
const admissionsTag = (() => {
|
const admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
@@ -40,32 +45,43 @@ export function SecondaryAdmissionsSection({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{admissions.places_offered != null && (
|
{admissions?.places_offered != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.total_applications != null && (
|
{admissions?.total_applications != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Total applications</div>
|
<div className={styles.metricLabel}>Total applications</div>
|
||||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.first_preference_applications != null && (
|
{admissions?.first_preference_applications != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>1st preference applications</div>
|
<div className={styles.metricLabel}>1st preference applications</div>
|
||||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.first_preference_offer_pct != null && (
|
{admissions?.first_preference_offer_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{cutoff && (
|
||||||
|
<div className={`${styles.metricCard} ${styles.metricCardDistance}`}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
Last distance offered · {cutoff.entryYear}
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>
|
||||||
|
{cutoff.primary}
|
||||||
|
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{admissions.oversubscribed != null && (
|
{admissions?.oversubscribed != null && (
|
||||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||||
{admissions.oversubscribed
|
{admissions.oversubscribed
|
||||||
? '⚠ Applications exceeded places last year'
|
? '⚠ Applications exceeded places last year'
|
||||||
@@ -73,9 +89,23 @@ export function SecondaryAdmissionsSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
{/* Replaces a blanket "distance cut-off data is not available for this
|
||||||
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
school", which was hardcoded onto every secondary page and was untrue
|
||||||
</p>
|
wherever the local authority does publish. The absence is now stated
|
||||||
|
only when it is real, and names the authority that would hold it. */}
|
||||||
|
{cutoff ? (
|
||||||
|
<p className={styles.admissionsDistanceNote}>
|
||||||
|
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
||||||
|
{cutoff.routeNote && <> {cutoff.routeNote}</>}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||||
|
{schoolInfo.local_authority
|
||||||
|
? `${schoolInfo.local_authority} has not published a cut-off distance for this school.`
|
||||||
|
: 'No cut-off distance has been published for this school.'}{' '}
|
||||||
|
Contact the admissions authority for its oversubscription criteria.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasSixthForm && (
|
{hasSixthForm && (
|
||||||
<div className={styles.sixthFormNote}>
|
<div className={styles.sixthFormNote}>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||||
@@ -32,6 +32,7 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -40,7 +41,7 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function SecondarySchoolSections({
|
export function SecondarySchoolSections({
|
||||||
schoolInfo, yearlyData, ofsted, census,
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
admissions, deprivation, finance, nationalAvg, flags,
|
admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
|
||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
@@ -79,9 +80,12 @@ export function SecondarySchoolSections({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{admissions && (
|
{/* See PrimarySchoolSections: distance and EES admissions are independent
|
||||||
|
sources, so either one warrants the section. */}
|
||||||
|
{(admissions || admissionDistance) && (
|
||||||
<SecondaryAdmissionsSection
|
<SecondaryAdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
|
admissionDistance={admissionDistance}
|
||||||
schoolInfo={schoolInfo}
|
schoolInfo={schoolInfo}
|
||||||
hasSixthForm={flags.hasSixthForm}
|
hasSixthForm={flags.hasSixthForm}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Shared logic and copy for the "last distance offered" figure.
|
||||||
|
*
|
||||||
|
* The primary and secondary admissions sections lay their metrics out
|
||||||
|
* differently (a seamless tile grid vs. metric cards), so the markup is not
|
||||||
|
* shared — but the words are. Every caveat below is doing a specific job, and
|
||||||
|
* a figure that appeared on one template without them would be misleading in a
|
||||||
|
* way the other template was not:
|
||||||
|
*
|
||||||
|
* * The year, because a cut-off is the outcome of one particular admissions
|
||||||
|
* round and means nothing detached from it.
|
||||||
|
* * "not a fixed catchment", because that is the inference a parent will
|
||||||
|
* otherwise draw, and it is wrong — the distance moves every year.
|
||||||
|
* * The route count, because on a banded school the headline is the widest
|
||||||
|
* of several cut-offs and does not apply to every child.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||||
|
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface CutoffDisplay {
|
||||||
|
/** Headline figure, e.g. "0.31 miles". */
|
||||||
|
primary: string;
|
||||||
|
/** Supporting conversion, e.g. "500 m". */
|
||||||
|
secondary: string;
|
||||||
|
/** Entry point the figure belongs to, e.g. "September 2025". */
|
||||||
|
entryYear: string;
|
||||||
|
/** Present only where several admission routes were collapsed into one figure. */
|
||||||
|
routeNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the cut-off measures, and what it does not. Identical on both templates. */
|
||||||
|
export const CUTOFF_NOTE =
|
||||||
|
'The furthest home offered a place, after higher priorities such as siblings, ' +
|
||||||
|
'faith and EHCP were applied. It is not a fixed catchment — it moves every year ' +
|
||||||
|
'with the number of applications.';
|
||||||
|
|
||||||
|
/** Straight-line, because that is how councils measure it. */
|
||||||
|
export const CUTOFF_MEASUREMENT_NOTE = 'Measured in a straight line from the school.';
|
||||||
|
|
||||||
|
export function describeCutoff(
|
||||||
|
distance: SchoolAdmissionDistance | null | undefined
|
||||||
|
): CutoffDisplay | null {
|
||||||
|
if (!distance) return null;
|
||||||
|
|
||||||
|
const figure = formatCutoffDistance(distance.distance_m);
|
||||||
|
if (!figure) return null;
|
||||||
|
|
||||||
|
const routes = distance.route_count ?? 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: figure.primary,
|
||||||
|
secondary: figure.secondary,
|
||||||
|
entryYear: formatEntryYear(distance.year),
|
||||||
|
routeNote:
|
||||||
|
routes > 1
|
||||||
|
? `Furthest of ${routes} admission routes at this school — the one that ` +
|
||||||
|
'applies to your child may have had a shorter cut-off.'
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -778,6 +778,38 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Last distance offered, secondary template. These cards are opaque
|
||||||
|
(--bg-secondary), so the tint can be set directly here — unlike the primary
|
||||||
|
tile grid, nothing shows through between them. */
|
||||||
|
.metricCardDistance {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
|
||||||
|
border-color: var(--brand);
|
||||||
|
/* Two columns' worth of width. Its label carries the entry year as well as
|
||||||
|
the measure, which at the 140px track width of the other cards wraps to
|
||||||
|
three lines and pushes the figure out of alignment with them. */
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricCardDistance .metricValue {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
color: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValueSub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same measured reason as .admissionsTileDistance above: --text-muted falls to
|
||||||
|
4.09:1 on the tinted card in dark theme. */
|
||||||
|
.metricCardDistance .metricLabel,
|
||||||
|
.metricCardDistance .metricValueSub {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.metricHint {
|
.metricHint {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1453,6 +1485,62 @@
|
|||||||
color: var(--brand-strong);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Admissions: last distance offered ──
|
||||||
|
*
|
||||||
|
* Spans the full grid width instead of taking a fifth half-cell. Two reasons:
|
||||||
|
* the tiles are a 2-column grid, so a fifth tile would sit alone next to a gap;
|
||||||
|
* and this is the figure the section is most often opened for, which a
|
||||||
|
* half-width cell would rank below "Applied in total".
|
||||||
|
*/
|
||||||
|
.admissionsTileDistance {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
/*
|
||||||
|
* --brand-bg is translucent, and the tile grid paints --border between
|
||||||
|
* cells, so a plain `background` would let the hairline colour show through
|
||||||
|
* and read as muddy rather than tinted. The opaque card colour is laid down
|
||||||
|
* first and the tint composited over it as an image.
|
||||||
|
*/
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsTileDistance .admissionsTileNum {
|
||||||
|
color: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The tint costs the muted tokens their AA margin, so this tile does not use
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* Measured on the composited backdrop, not the computed one: the tint is a
|
||||||
|
* background-image over --bg-card, so getComputedStyle reports the untinted
|
||||||
|
* card and flatters the result. Sampling the rendered pixel in dark theme gives
|
||||||
|
* a backdrop of rgb(34,59,64), on which --text-muted is 4.09:1 — under the 4.5
|
||||||
|
* floor — while --text-secondary is 6.50:1.
|
||||||
|
*
|
||||||
|
* The label is where the year lives, so it is the last thing on the tile that
|
||||||
|
* can afford to be hard to read: a distance without its year is not a fact
|
||||||
|
* about anything.
|
||||||
|
*/
|
||||||
|
.admissionsTileDistance .admissionsTileLabel,
|
||||||
|
.admissionsTileDistance .admissionsTileSub {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The caveats, not a footnote in the visual-noise sense: without them the
|
||||||
|
* figure reads as a catchment boundary. Kept at body contrast rather than
|
||||||
|
* --text-muted for that reason — it qualifies the number above it, so it has
|
||||||
|
* to be as readable as the number.
|
||||||
|
*/
|
||||||
|
.admissionsDistanceNote {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
.admissionsVerdict {
|
.admissionsVerdict {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
@@ -1568,6 +1656,20 @@
|
|||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The distance tile occupies a third row. Left implicit it would inherit the
|
||||||
|
1fr sizing of the row template and stretch to match the metric tiles above,
|
||||||
|
which are twice its content height. */
|
||||||
|
.admissionsViewYear .admissionsTilesWithDistance {
|
||||||
|
grid-template-rows: 1fr 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cut-off distance but no EES admissions figures: there is nothing to put in
|
||||||
|
the two metric rows, and leaving them in the template paints an empty block
|
||||||
|
of the grid's gap colour beneath the tile. */
|
||||||
|
.admissionsViewYear .admissionsTilesDistanceOnly {
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.admissionsChartCap {
|
.admissionsChartCap {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, SchoolCensus,
|
School, SchoolResult, AbsenceData, SchoolCensus,
|
||||||
OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance,
|
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { isSpecialSchool } from './utils';
|
import { isSpecialSchool } from './utils';
|
||||||
|
|
||||||
@@ -119,6 +119,9 @@ export function computeSchoolFlags({
|
|||||||
export interface NavItemsInput {
|
export interface NavItemsInput {
|
||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
|
/** A published cut-off distance renders the Admissions section on its own,
|
||||||
|
* so it has to open the nav entry too. */
|
||||||
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
yearlyDataLength: number;
|
yearlyDataLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +136,7 @@ export interface NavItemsInput {
|
|||||||
*/
|
*/
|
||||||
export function buildNavItems(
|
export function buildNavItems(
|
||||||
flags: SchoolFlags,
|
flags: SchoolFlags,
|
||||||
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
@@ -143,7 +146,7 @@ export function buildNavItems(
|
|||||||
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
|
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||||
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||||
@@ -213,12 +216,12 @@ export function computeSecondaryFlags({
|
|||||||
*/
|
*/
|
||||||
export function buildSecondaryNavItems(
|
export function buildSecondaryNavItems(
|
||||||
flags: SecondaryFlags,
|
flags: SecondaryFlags,
|
||||||
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||||
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
|
|||||||
@@ -163,6 +163,30 @@ export interface SchoolAdmissions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last distance offered — how far from the school the last child to be given a
|
||||||
|
* place that year lived.
|
||||||
|
*
|
||||||
|
* Not a catchment: it is the outcome of one year's applications and moves every
|
||||||
|
* year. Only published by the local authorities that choose to, so most schools
|
||||||
|
* have none of this at all.
|
||||||
|
*/
|
||||||
|
export interface SchoolAdmissionDistance {
|
||||||
|
/** Entry year the cut-off belongs to — always shown alongside the figure. */
|
||||||
|
year: number;
|
||||||
|
/** Straight-line distance in metres. */
|
||||||
|
distance_m: number | null;
|
||||||
|
/**
|
||||||
|
* Admission routes collapsed into this figure (ability bands, or separate
|
||||||
|
* reception and junior intakes). Above 1 the distance is the furthest of
|
||||||
|
* several, which the page has to say out loud.
|
||||||
|
*/
|
||||||
|
route_count: number | null;
|
||||||
|
la_name: string | null;
|
||||||
|
/** The unit the council published in ("miles", "meters", "km"). */
|
||||||
|
distance_unit_raw: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SchoolDeprivation {
|
export interface SchoolDeprivation {
|
||||||
lsoa_code: string | null;
|
lsoa_code: string | null;
|
||||||
idaci_score: number | null;
|
idaci_score: number | null;
|
||||||
@@ -330,6 +354,8 @@ export interface SchoolDetailsResponse {
|
|||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||||
admissions_history: SchoolAdmissions[];
|
admissions_history: SchoolAdmissions[];
|
||||||
|
/** Latest published cut-off distance, or null where the LA publishes none. */
|
||||||
|
admission_distance: SchoolAdmissionDistance | null;
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,6 +502,54 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const METRES_PER_MILE = 1609.344;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an admission cut-off distance.
|
||||||
|
*
|
||||||
|
* Miles lead because that is the unit councils publish in — 90% of the source
|
||||||
|
* rows are miles — and because it is the unit a parent has already been quoted
|
||||||
|
* if they have read their council's booklet. The metric figure follows as
|
||||||
|
* support rather than replacing it.
|
||||||
|
*
|
||||||
|
* Below 100 m the miles figure stops carrying information ("0.03 miles"), so
|
||||||
|
* the two swap round and metres lead instead.
|
||||||
|
*/
|
||||||
|
export function formatCutoffDistance(
|
||||||
|
metres: number | null | undefined
|
||||||
|
): { primary: string; secondary: string } | null {
|
||||||
|
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
|
||||||
|
|
||||||
|
const miles = metres / METRES_PER_MILE;
|
||||||
|
|
||||||
|
if (metres < 100) {
|
||||||
|
return {
|
||||||
|
primary: `${Math.round(metres)} m`,
|
||||||
|
secondary: `${miles.toFixed(2)} miles`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: `${miles.toFixed(2)} miles`,
|
||||||
|
secondary: metres < 1000
|
||||||
|
? `${Math.round(metres / 10) * 10} m`
|
||||||
|
: `${(metres / 1000).toFixed(1)} km`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry point a cut-off belongs to, phrased the way councils phrase it.
|
||||||
|
*
|
||||||
|
* The distance year is a plain entry year (2025 = the September 2025 intake),
|
||||||
|
* not the six-digit academic year EES uses, so formatAcademicYear would render
|
||||||
|
* it as "2025/26" and invite the reader to think of it as a school year rather
|
||||||
|
* than an application round.
|
||||||
|
*/
|
||||||
|
export function formatEntryYear(year: number | null | undefined): string {
|
||||||
|
if (year == null) return '';
|
||||||
|
return `September ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAcademicYear(year: number | null | undefined): string {
|
export function formatAcademicYear(year: number | null | undefined): string {
|
||||||
if (year == null) return '';
|
if (year == null) return '';
|
||||||
const s = year.toString();
|
const s = year.toString();
|
||||||
|
|||||||
@@ -224,3 +224,68 @@ with DAG(
|
|||||||
)
|
)
|
||||||
|
|
||||||
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
|
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
|
||||||
|
|
||||||
|
|
||||||
|
# ── Annual DAG (Last distance offered) ────────────────────────────────
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="school_data_annual_distance",
|
||||||
|
default_args=default_args,
|
||||||
|
description="Last distance offered (LA admission cut-offs) extraction and transform",
|
||||||
|
# Councils publish on allocation day (March for secondary, April for
|
||||||
|
# primary) and each one on its own timetable, so there is no date worth
|
||||||
|
# scheduling against. Triggered manually after a collection run refreshes
|
||||||
|
# the CSV.
|
||||||
|
schedule=None,
|
||||||
|
start_date=datetime(2025, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
tags=["school-compare", "annual"],
|
||||||
|
) as annual_distance_dag:
|
||||||
|
|
||||||
|
extract_distance = BashOperator(
|
||||||
|
task_id="extract_distance",
|
||||||
|
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-school-distance target-postgres",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Coverage is the thing that silently rots here: the CSV is assembled by
|
||||||
|
# hand from council publications, so a collection run that half-failed
|
||||||
|
# produces a valid file with a fraction of the schools in it. A row count
|
||||||
|
# alone would not catch that — losing an entire local authority leaves the
|
||||||
|
# total looking healthy — so the floor is checked on distinct LAs too.
|
||||||
|
validate_distance = BashOperator(
|
||||||
|
task_id="validate_distance",
|
||||||
|
bash_command=f"""
|
||||||
|
cd {PIPELINE_DIR} && python -c "
|
||||||
|
import psycopg2, os, sys
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=os.environ.get('PG_HOST', 'localhost'),
|
||||||
|
port=os.environ.get('PG_PORT', '5432'),
|
||||||
|
user=os.environ.get('PG_USER', 'postgres'),
|
||||||
|
password=os.environ.get('PG_PASSWORD', 'postgres'),
|
||||||
|
dbname=os.environ.get('PG_DATABASE', 'school_compare'),
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('SELECT count(*), count(distinct urn), count(distinct la_code) FROM raw.school_distance_offered')
|
||||||
|
rows, urns, las = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
print(f'Loaded {{rows}} rows, {{urns}} schools, {{las}} local authorities')
|
||||||
|
if rows < 7000 or urns < 3000 or las < 45:
|
||||||
|
print('ERROR: distance extract is short of expected coverage '
|
||||||
|
'(baseline 2026-08: 9128 rows / 3726 schools / 57 LAs)', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print('Validation passed')
|
||||||
|
"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
dbt_build_distance = BashOperator(
|
||||||
|
task_id="dbt_build",
|
||||||
|
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_school_distance+ fact_admission_distance+",
|
||||||
|
)
|
||||||
|
|
||||||
|
invalidate_cache_distance = BashOperator(
|
||||||
|
task_id="invalidate_cache",
|
||||||
|
bash_command=INVALIDATE_CACHE_CMD,
|
||||||
|
)
|
||||||
|
|
||||||
|
extract_distance >> validate_distance >> dbt_build_distance >> invalidate_cache_distance
|
||||||
@@ -67,6 +67,21 @@ plugins:
|
|||||||
pip_url: ./plugins/extractors/tap-uk-idaci
|
pip_url: ./plugins/extractors/tap-uk-idaci
|
||||||
executable: tap-uk-idaci
|
executable: tap-uk-idaci
|
||||||
|
|
||||||
|
# Last distance offered — admission cut-offs collected from LA
|
||||||
|
# publications. There is no national source for this, so the URL points at
|
||||||
|
# the collected CSV rather than at a government endpoint (same pattern as
|
||||||
|
# the legacy KS2/KS4 URLs above).
|
||||||
|
- name: tap-uk-school-distance
|
||||||
|
namespace: uk_school_distance
|
||||||
|
pip_url: ./plugins/extractors/tap-uk-school-distance
|
||||||
|
executable: tap-uk-school-distance
|
||||||
|
settings:
|
||||||
|
- name: download_url
|
||||||
|
kind: string
|
||||||
|
description: URL of the collected last-distance-offered CSV
|
||||||
|
config:
|
||||||
|
download_url: http://10.0.1.224:8081/filebrowser/api/public/dl/UU_oEqVr?inline=true
|
||||||
|
|
||||||
loaders:
|
loaders:
|
||||||
- name: target-postgres
|
- name: target-postgres
|
||||||
variant: meltanolabs
|
variant: meltanolabs
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tap-uk-school-distance"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Singer tap for last-distance-offered admission cut-offs collected from LA admissions publications"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"singer-sdk~=0.53",
|
||||||
|
"requests>=2.31",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
tap-uk-school-distance = "tap_uk_school_distance.tap:TapUKSchoolDistance.cli"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""tap-uk-school-distance: Singer tap for LA "last distance offered" admission cut-offs."""
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Singer tap for "last distance offered" school admission cut-offs.
|
||||||
|
|
||||||
|
Unlike every other tap in this project, the upstream here is not a single
|
||||||
|
government endpoint. There is no national dataset of admission cut-off
|
||||||
|
distances: each local authority publishes its own, in its own format
|
||||||
|
(PDF booklets, XLSX allocation tables, HTML pages), and only some publish
|
||||||
|
at all. The CSV this tap reads is the output of that collection work —
|
||||||
|
one row per school per year per admission route, already normalised to
|
||||||
|
metres in `distance_m`.
|
||||||
|
|
||||||
|
The tap deliberately does no cleaning. Implausible values (0.0 miles,
|
||||||
|
1-metre cut-offs, a 533-mile outlier) are loaded verbatim into `raw` and
|
||||||
|
filtered in `stg_school_distance`, so the raw table stays a faithful
|
||||||
|
record of what the councils published and the plausibility rules live in
|
||||||
|
one reviewable place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from singer_sdk import Stream, Tap
|
||||||
|
from singer_sdk import typing as th
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT = 300
|
||||||
|
|
||||||
|
|
||||||
|
class SchoolDistanceStream(Stream):
|
||||||
|
"""Stream: last distance offered, one row per school × year × admission route."""
|
||||||
|
|
||||||
|
name = "school_distance_offered"
|
||||||
|
# school_name is part of the key, not decoration: where a school admits
|
||||||
|
# through several routes the route is encoded in the name ("… Band 1",
|
||||||
|
# "… Band 2"), and (urn, year) alone is not unique. Verified against the
|
||||||
|
# source: (urn, year) collides on 118 keys, (urn, year, school_name) on
|
||||||
|
# none. Without school_name in the key a reload would silently drop the
|
||||||
|
# other bands.
|
||||||
|
primary_keys = ["urn", "year", "school_name"]
|
||||||
|
replication_key = None
|
||||||
|
|
||||||
|
schema = th.PropertiesList(
|
||||||
|
th.Property("urn", th.IntegerType, required=True),
|
||||||
|
th.Property("year", th.IntegerType, required=True),
|
||||||
|
th.Property("school_name", th.StringType, required=True),
|
||||||
|
th.Property("la_code", th.IntegerType),
|
||||||
|
th.Property("la_name", th.StringType),
|
||||||
|
# The figure as the council published it, before unit conversion —
|
||||||
|
# kept so a surprising metre value can be traced back to "0.31 miles"
|
||||||
|
# without reopening the source PDF.
|
||||||
|
th.Property("distance_value_raw", th.NumberType),
|
||||||
|
th.Property("distance_unit_raw", th.StringType),
|
||||||
|
th.Property("distance_m", th.NumberType),
|
||||||
|
# Path of the council publication the row was read from. This is the
|
||||||
|
# provenance trail for a figure we cannot re-derive from an API.
|
||||||
|
th.Property("source_file", th.StringType),
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _num(value: str) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int(value: str) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_records(self, context):
|
||||||
|
url = self.config["download_url"]
|
||||||
|
self.logger.info("Downloading school distance data from %s", url)
|
||||||
|
|
||||||
|
resp = requests.get(url, timeout=REQUEST_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(resp.text))
|
||||||
|
|
||||||
|
yielded = 0
|
||||||
|
skipped = 0
|
||||||
|
for row in reader:
|
||||||
|
urn = self._int(row.get("urn"))
|
||||||
|
year = self._int(row.get("year"))
|
||||||
|
name = (row.get("school_name") or "").strip()
|
||||||
|
|
||||||
|
# Every part of the primary key must be present, or the load is
|
||||||
|
# not idempotent. One row in the source has a blank year.
|
||||||
|
if urn is None or year is None or not name:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"urn": urn,
|
||||||
|
"year": year,
|
||||||
|
"school_name": name,
|
||||||
|
"la_code": self._int(row.get("la_code")),
|
||||||
|
"la_name": (row.get("la_name") or "").strip() or None,
|
||||||
|
"distance_value_raw": self._num(row.get("distance_value_raw")),
|
||||||
|
"distance_unit_raw": (row.get("distance_unit_raw") or "").strip() or None,
|
||||||
|
"distance_m": self._num(row.get("distance_m")),
|
||||||
|
"source_file": (row.get("source_file") or "").strip() or None,
|
||||||
|
}
|
||||||
|
yielded += 1
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Yielded %d distance records (%d skipped: missing urn, year or school name)",
|
||||||
|
yielded,
|
||||||
|
skipped,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TapUKSchoolDistance(Tap):
|
||||||
|
"""Singer tap for LA-published admission cut-off distances."""
|
||||||
|
|
||||||
|
name = "tap-uk-school-distance"
|
||||||
|
|
||||||
|
config_jsonschema = th.PropertiesList(
|
||||||
|
th.Property(
|
||||||
|
"download_url",
|
||||||
|
th.StringType,
|
||||||
|
required=True,
|
||||||
|
description="URL of the collected last-distance-offered CSV",
|
||||||
|
),
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def discover_streams(self):
|
||||||
|
return [SchoolDistanceStream(self)]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
TapUKSchoolDistance.cli()
|
||||||
@@ -156,6 +156,22 @@ models:
|
|||||||
- name: cross_la_offers
|
- name: cross_la_offers
|
||||||
- name: total_offers
|
- name: total_offers
|
||||||
|
|
||||||
|
- name: fact_admission_distance
|
||||||
|
description: >
|
||||||
|
Last distance offered — one row per URN per year. Where a school admits
|
||||||
|
through several routes (ability bands, separate reception/junior
|
||||||
|
intakes) the figure is the furthest of them and route_count says how
|
||||||
|
many were collapsed.
|
||||||
|
columns:
|
||||||
|
- name: urn
|
||||||
|
tests: [not_null]
|
||||||
|
- name: year
|
||||||
|
tests: [not_null]
|
||||||
|
- name: distance_m
|
||||||
|
tests: [not_null]
|
||||||
|
- name: route_count
|
||||||
|
tests: [not_null]
|
||||||
|
|
||||||
- name: fact_finance
|
- name: fact_finance
|
||||||
description: School financial data — one row per URN per year
|
description: School financial data — one row per URN per year
|
||||||
columns:
|
columns:
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- Mart: last distance offered — one row per URN per year.
|
||||||
|
--
|
||||||
|
-- Collapses the per-route grain of stg_school_distance to the grain the site
|
||||||
|
-- serves. Where a school admits through several routes the figure shown is the
|
||||||
|
-- FURTHEST of them, which is what "last distance offered" literally means: the
|
||||||
|
-- distance of the last place the school gave out that year.
|
||||||
|
--
|
||||||
|
-- route_count travels with it so the page can say so. A parent looking at a
|
||||||
|
-- banded secondary needs to know that 2.4 miles is the widest of four bands
|
||||||
|
-- and not the number that applies to their own child — the count is what makes
|
||||||
|
-- that sentence possible, and dropping it would make the figure quietly
|
||||||
|
-- optimistic.
|
||||||
|
--
|
||||||
|
-- Kept separate from fact_admissions rather than joined into it. That mart is
|
||||||
|
-- EES-derived and near-complete for England; this one comes from 57 local
|
||||||
|
-- authorities that publish cut-offs at all, so folding it in would add mostly
|
||||||
|
-- null columns to every school in the country and tie two very different
|
||||||
|
-- refresh cadences together.
|
||||||
|
|
||||||
|
with routes as (
|
||||||
|
select * from {{ ref('stg_school_distance') }}
|
||||||
|
),
|
||||||
|
|
||||||
|
per_year as (
|
||||||
|
select
|
||||||
|
urn,
|
||||||
|
year,
|
||||||
|
max(distance_m) as distance_m,
|
||||||
|
count(*) as route_count,
|
||||||
|
min(la_code) as la_code,
|
||||||
|
min(la_name) as la_name,
|
||||||
|
-- The unit the council actually published in. Where routes disagree
|
||||||
|
-- (they do not in the current data) the alphabetically first is taken
|
||||||
|
-- rather than an arbitrary row, so the mart is deterministic.
|
||||||
|
min(distance_unit_raw) as distance_unit_raw,
|
||||||
|
min(source_file) as source_file
|
||||||
|
from routes
|
||||||
|
group by urn, year
|
||||||
|
)
|
||||||
|
|
||||||
|
select
|
||||||
|
urn,
|
||||||
|
year,
|
||||||
|
distance_m,
|
||||||
|
route_count,
|
||||||
|
la_code,
|
||||||
|
la_name,
|
||||||
|
distance_unit_raw,
|
||||||
|
source_file
|
||||||
|
from per_year
|
||||||
@@ -61,3 +61,11 @@ sources:
|
|||||||
|
|
||||||
- name: idaci
|
- name: idaci
|
||||||
description: Income Deprivation Affecting Children Index lookups
|
description: Income Deprivation Affecting Children Index lookups
|
||||||
|
|
||||||
|
- name: school_distance_offered
|
||||||
|
description: >
|
||||||
|
Last distance offered (admission cut-offs) transcribed from local
|
||||||
|
authority admissions publications. One row per school × year ×
|
||||||
|
admission route; no national source exists, so coverage is limited to
|
||||||
|
the LAs that publish. Loaded verbatim — implausible values are
|
||||||
|
filtered in stg_school_distance, not here.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- Staging model: "last distance offered" admission cut-offs.
|
||||||
|
--
|
||||||
|
-- One row per school × year × admission route. A school admits through
|
||||||
|
-- several routes when it bands by ability (Islington publishes four bands per
|
||||||
|
-- secondary) or takes more than one intake (Gloucestershire publishes
|
||||||
|
-- reception, junior and secondary allocations separately). The route is
|
||||||
|
-- carried in school_name, which is why it forms part of the grain here and is
|
||||||
|
-- collapsed only in fact_admission_distance.
|
||||||
|
--
|
||||||
|
-- PLAUSIBILITY BAND (25 m – 25 km)
|
||||||
|
--
|
||||||
|
-- The source is transcribed from council PDFs, spreadsheets and web pages, and
|
||||||
|
-- a minority of rows are transcription or unit errors rather than real
|
||||||
|
-- cut-offs. Three failure modes, all present:
|
||||||
|
--
|
||||||
|
-- * 0.0 miles (34 rows, mostly Staffordshire). Published where a school
|
||||||
|
-- filled on a higher criterion so distance was never reached — a real
|
||||||
|
-- fact, but "0 metres" read as a cut-off would tell a parent the opposite
|
||||||
|
-- of the truth.
|
||||||
|
-- * 1–10 metre values (72 rows, e.g. Hillingdon). A cut-off inside the
|
||||||
|
-- school gate is not physically possible; these are unit slips.
|
||||||
|
-- * Values above 25 km (~240 rows), topping out at 533 miles. Genuine rural
|
||||||
|
-- secondary cut-offs reach roughly 12–15 miles, so the ceiling keeps those
|
||||||
|
-- while cutting figures that are visibly wrong.
|
||||||
|
--
|
||||||
|
-- Together these are ~4% of rows. They are loaded verbatim into raw and
|
||||||
|
-- excluded here, so the rule is reviewable in one place and the raw table
|
||||||
|
-- stays a faithful record of what each council published.
|
||||||
|
|
||||||
|
{% set min_plausible_m = 25 %}
|
||||||
|
{% set max_plausible_m = 25000 %}
|
||||||
|
|
||||||
|
with source as (
|
||||||
|
select * from {{ source('raw', 'school_distance_offered') }}
|
||||||
|
),
|
||||||
|
|
||||||
|
renamed as (
|
||||||
|
select
|
||||||
|
cast(urn as integer) as urn,
|
||||||
|
cast(year as integer) as year,
|
||||||
|
trim(school_name) as admission_route,
|
||||||
|
cast(la_code as integer) as la_code,
|
||||||
|
la_name,
|
||||||
|
cast(distance_value_raw as numeric) as distance_value_raw,
|
||||||
|
distance_unit_raw,
|
||||||
|
cast(distance_m as numeric) as distance_m,
|
||||||
|
source_file
|
||||||
|
from source
|
||||||
|
where urn is not null
|
||||||
|
and year is not null
|
||||||
|
and distance_m is not null
|
||||||
|
and cast(distance_m as numeric) between {{ min_plausible_m }} and {{ max_plausible_m }}
|
||||||
|
)
|
||||||
|
|
||||||
|
select * from renamed
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Custom test: every published cut-off distance is inside the plausibility
|
||||||
|
-- band, and there is exactly one row per school per year.
|
||||||
|
--
|
||||||
|
-- The band is enforced in stg_school_distance; this asserts it survived the
|
||||||
|
-- max() collapse in the mart. A failure means either the staging filter was
|
||||||
|
-- edited without updating this test, or the grain broke — both of which put a
|
||||||
|
-- visibly wrong number ("533 miles") on a live school page, which is the one
|
||||||
|
-- outcome this feature cannot afford.
|
||||||
|
|
||||||
|
with out_of_band as (
|
||||||
|
select urn, year, distance_m, 'out of plausibility band' as problem
|
||||||
|
from {{ ref('fact_admission_distance') }}
|
||||||
|
where distance_m is null
|
||||||
|
or distance_m < 25
|
||||||
|
or distance_m > 25000
|
||||||
|
),
|
||||||
|
|
||||||
|
duplicated as (
|
||||||
|
select urn, year, max(distance_m) as distance_m, 'duplicate urn/year' as problem
|
||||||
|
from {{ ref('fact_admission_distance') }}
|
||||||
|
group by urn, year
|
||||||
|
having count(*) > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
select * from out_of_band
|
||||||
|
union all
|
||||||
|
select * from duplicated
|
||||||
Reference in new issue
Block a user