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/logs/
|
||||
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"),
|
||||
"admissions": supplementary.get("admissions"),
|
||||
"admissions_history": supplementary.get("admissions_history") or [],
|
||||
"admission_distance": supplementary.get("admission_distance"),
|
||||
"sen_detail": supplementary.get("sen_detail"),
|
||||
"phonics": supplementary.get("phonics"),
|
||||
"deprivation": supplementary.get("deprivation"),
|
||||
|
||||
+34
-1
@@ -18,7 +18,7 @@ from .config import settings
|
||||
from .database import SessionLocal, engine
|
||||
from .models import (
|
||||
DimSchool, DimLocation, KS2Performance,
|
||||
FactOfstedInspection, FactAdmissions,
|
||||
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||
)
|
||||
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:
|
||||
return {
|
||||
"year": pc.year,
|
||||
@@ -759,6 +770,7 @@ def _empty_supplementary() -> dict:
|
||||
"census": None,
|
||||
"admissions": None,
|
||||
"admissions_history": [],
|
||||
"admission_distance": None,
|
||||
"sen_detail": None,
|
||||
"phonics": 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
|
||||
_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.
|
||||
def _deprivation():
|
||||
rows = (
|
||||
|
||||
@@ -188,6 +188,37 @@ class FactAdmissions(Base):
|
||||
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):
|
||||
"""School pupil composition from EES census — one row per URN per year."""
|
||||
__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():
|
||||
rows = {
|
||||
# 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),
|
||||
],
|
||||
"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": [],
|
||||
"FactDeprivation": [],
|
||||
"FactFinance": [],
|
||||
@@ -85,10 +100,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
||||
session = _FakeSession(rows)
|
||||
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) == [
|
||||
"FactAdmissions", "FactDeprivation", "FactFinance",
|
||||
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
||||
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
||||
]
|
||||
|
||||
# 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[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
|
||||
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)
|
||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||
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([]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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', () => {
|
||||
const flags = computeSchoolFlags(specialFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('ofsted');
|
||||
@@ -74,6 +74,7 @@ describe('buildNavItems', () => {
|
||||
return buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
}).find((n) => n.id === 'results')?.label;
|
||||
};
|
||||
@@ -83,11 +84,27 @@ describe('buildNavItems', () => {
|
||||
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', () => {
|
||||
const flags = computeSchoolFlags(primaryFixture);
|
||||
const ids = buildNavItems(flags, {
|
||||
ofsted: primaryFixture.ofsted,
|
||||
admissions: primaryFixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
@@ -127,16 +144,29 @@ describe('buildSecondaryNavItems', () => {
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: secondaryFixture.ofsted,
|
||||
admissions: secondaryFixture.admissions,
|
||||
admissionDistance: null,
|
||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||
}).map((n) => n.id);
|
||||
|
||||
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', () => {
|
||||
const flags = computeSecondaryFlags(secondaryFixture);
|
||||
const ids = buildSecondaryNavItems(flags, {
|
||||
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
||||
}).map((n) => n.id);
|
||||
|
||||
expect(ids).not.toContain('history');
|
||||
|
||||
@@ -31,6 +31,7 @@ export function renderSchoolDetail(fixture: any) {
|
||||
const navItems = buildNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: fixture.admissionDistance ?? null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
@@ -57,6 +58,7 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
||||
const navItems = buildSecondaryNavItems(flags, {
|
||||
ofsted: fixture.ofsted,
|
||||
admissions: fixture.admissions,
|
||||
admissionDistance: fixture.admissionDistance ?? null,
|
||||
yearlyDataLength: fixture.yearlyData.length,
|
||||
});
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
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
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -176,6 +176,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const navInput = {
|
||||
ofsted: ofsted ?? null,
|
||||
admissions: admissions ?? null,
|
||||
admissionDistance: admission_distance ?? null,
|
||||
yearlyDataLength: yearly_data.length,
|
||||
};
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
@@ -228,6 +229,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
@@ -249,6 +251,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
|
||||
@@ -12,59 +12,112 @@
|
||||
*/
|
||||
|
||||
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 { summariseAdmissions } from '@/lib/compareLogic';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
||||
import { AdmissionsTrendChart } from './charts';
|
||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
||||
|
||||
|
||||
export function AdmissionsSection({
|
||||
admissions,
|
||||
admissionsHistory,
|
||||
admissionDistance,
|
||||
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[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
isAllThrough: boolean;
|
||||
}) {
|
||||
const cutoff = describeCutoff(admissionDistance);
|
||||
|
||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||||
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
|
||||
// Banded interpretation of the first-choice offer rate ("More than half of
|
||||
// first choices missed out" etc.) — the same banding the compare screen
|
||||
// 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
|
||||
Year 7 secondary intake), not reception — say so, or a parent could
|
||||
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}>
|
||||
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||
</p>
|
||||
) : 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 = (
|
||||
<>
|
||||
<dl className={styles.admissionsTiles}>
|
||||
{admissions.places_offered != null && (
|
||||
<dl className={tilesClass}>
|
||||
{admissions?.places_offered != null && (
|
||||
<div className={styles.admissionsTile}>
|
||||
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
||||
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
{admissions?.first_preference_applications != null && (
|
||||
<div className={styles.admissionsTile}>
|
||||
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
||||
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
{admissions?.first_preference_offer_pct != null && (
|
||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
||||
<dd className={styles.admissionsTileNum}>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
{admissions?.total_applications != null && (
|
||||
<div className={styles.admissionsTile}>
|
||||
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
||||
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
||||
</div>
|
||||
)}
|
||||
{distanceTile}
|
||||
</dl>
|
||||
{admissionsSummary.chip && (
|
||||
{admissionsSummary?.chip && (
|
||||
<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>
|
||||
<AdmissionsTrendChart history={admissionsHistory} />
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||
@@ -34,6 +34,7 @@ export interface PrimarySchoolSectionsProps {
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
@@ -42,7 +43,7 @@ export interface PrimarySchoolSectionsProps {
|
||||
|
||||
export function PrimarySchoolSections({
|
||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
|
||||
admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
|
||||
}: PrimarySchoolSectionsProps) {
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
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
|
||||
admissions={admissions}
|
||||
admissionsHistory={admissionsHistory}
|
||||
admissionDistance={admissionDistance}
|
||||
isAllThrough={flags.isAllThrough}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,17 +6,22 @@
|
||||
* 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 { Section, sectionStyles as styles } from './sectionShared';
|
||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
||||
|
||||
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;
|
||||
hasSixthForm: boolean;
|
||||
}) {
|
||||
const cutoff = describeCutoff(admissionDistance);
|
||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
@@ -40,32 +45,43 @@ export function SecondaryAdmissionsSection({
|
||||
)}
|
||||
|
||||
<div className={styles.metricsGrid}>
|
||||
{admissions.places_offered != null && (
|
||||
{admissions?.places_offered != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
{admissions?.total_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Total applications</div>
|
||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
{admissions?.first_preference_applications != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>1st preference applications</div>
|
||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
{admissions?.first_preference_offer_pct != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</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>
|
||||
{admissions.oversubscribed != null && (
|
||||
{admissions?.oversubscribed != null && (
|
||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||
{admissions.oversubscribed
|
||||
? '⚠ Applications exceeded places last year'
|
||||
@@ -73,9 +89,23 @@ export function SecondaryAdmissionsSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||
</p>
|
||||
{/* Replaces a blanket "distance cut-off data is not available for this
|
||||
school", which was hardcoded onto every secondary page and was untrue
|
||||
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 && (
|
||||
<div className={styles.sixthFormNote}>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||
@@ -32,6 +32,7 @@ export interface SecondarySchoolSectionsProps {
|
||||
ofsted: OfstedInspection | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
@@ -40,7 +41,7 @@ export interface SecondarySchoolSectionsProps {
|
||||
|
||||
export function SecondarySchoolSections({
|
||||
schoolInfo, yearlyData, ofsted, census,
|
||||
admissions, deprivation, finance, nationalAvg, flags,
|
||||
admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
|
||||
}: SecondarySchoolSectionsProps) {
|
||||
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
|
||||
admissions={admissions}
|
||||
admissionDistance={admissionDistance}
|
||||
schoolInfo={schoolInfo}
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
@@ -1453,6 +1485,62 @@
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
@@ -1568,6 +1656,20 @@
|
||||
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 {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import type {
|
||||
School, SchoolResult, AbsenceData, SchoolCensus,
|
||||
OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance,
|
||||
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
|
||||
} from './types';
|
||||
import { isSpecialSchool } from './utils';
|
||||
|
||||
@@ -119,6 +119,9 @@ export function computeSchoolFlags({
|
||||
export interface NavItemsInput {
|
||||
ofsted: OfstedInspection | 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;
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ export interface NavItemsInput {
|
||||
*/
|
||||
export function buildNavItems(
|
||||
flags: SchoolFlags,
|
||||
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
||||
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
||||
): NavItem[] {
|
||||
const navItems: NavItem[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
@@ -143,7 +146,7 @@ export function buildNavItems(
|
||||
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 (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
|
||||
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||
@@ -213,12 +216,12 @@ export function computeSecondaryFlags({
|
||||
*/
|
||||
export function buildSecondaryNavItems(
|
||||
flags: SecondaryFlags,
|
||||
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
||||
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
||||
): NavItem[] {
|
||||
const navItems: NavItem[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
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 (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||
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 {
|
||||
lsoa_code: string | null;
|
||||
idaci_score: number | null;
|
||||
@@ -330,6 +354,8 @@ export interface SchoolDetailsResponse {
|
||||
admissions: SchoolAdmissions | null;
|
||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||
admissions_history: SchoolAdmissions[];
|
||||
/** Latest published cut-off distance, or null where the LA publishes none. */
|
||||
admission_distance: SchoolAdmissionDistance | null;
|
||||
deprivation: SchoolDeprivation | 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 {
|
||||
if (year == null) return '';
|
||||
const s = year.toString();
|
||||
|
||||
@@ -224,3 +224,68 @@ with DAG(
|
||||
)
|
||||
|
||||
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
|
||||
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:
|
||||
- name: target-postgres
|
||||
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: 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
|
||||
description: School financial data — one row per URN per year
|
||||
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
|
||||
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