feat(admissions): add the cut-off history, map and postcode check
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
Completes the last-distance-offered feature against the mockup: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them. Serving the history The first cut deliberately served only the latest year, because a plain series would draw a trend line straight through gaps that are absences of publication, not of a cut-off. That reasoning is answered rather than abandoned: cutoffYearRows classifies every year in the span, and the chart breaks the line rather than interpolating across it. A missing year is not one fact but three. It may be unpublished; it may be a year the school was not oversubscribed; or there may be no record at all. Collapsing them into "no data" throws away the reassuring case and hides the important caveat, so each is stated in words in the table. The claim is held to what the data supports. fact_admissions.oversubscribed compares FIRST PREFERENCES against places, which does not establish that every applicant was offered one — so the copy says "places available on first preferences" and a test asserts the stronger claim never appears. The trend summary is not a verdict It names both endpoints and their years and lets the reader conclude. It is withheld below four published points, and a swing under a tenth of the earlier figure is reported as "broadly the same" rather than dressed up as a direction. The postcode check This is the only place on the site that answers a question about a family rather than a school, so most of the care went into what it refuses to say. postcodes.io returns a centroid covering roughly fifteen addresses, which against a 500 m cut-off is a fifth of the whole distance — so a margin inside 100 m returns "too close to call" rather than a place a family does not have. Unpublished years count as unknown, never as a pass. The limits are stated before the check is used, not revealed with the answer. The postcode is geocoded in the browser and never stored. Both templates Banded and selective secondaries are exactly where this matters most, so the detail is shared. The primary page gives it a third tab; the secondary page is one flat panel by design and renders it inline. Absence is explained rather than reported. A selective school's missing figure is explained by how it admits; a consistently undersubscribed school reads as good news. Also makes the batch loader's test double honour ORDER BY. It was a no-op, so "latest row per URN" was really "first row in the fixture" and the test would have passed with the sort reversed or removed. Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging (the 8 cut-off journeys skip until the DAG runs). Rendered offline against the real compiled CSS in both themes and at 390px; every new surface clears WCAG AA, measured on composited pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
This commit is contained in:
1 parent
5a71f54d94
commit
a72323874f
24 files changed
+2067
-65
No files matched your search
@@ -8,3 +8,6 @@ pipeline/transform/.user.yml
|
||||
|
||||
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
||||
.playwright-mcp/
|
||||
|
||||
# Playwright run artefacts written when the suite is run from the repo root
|
||||
test-results/
|
||||
@@ -633,6 +633,7 @@ async def get_school_details(request: Request, urn: int):
|
||||
"admissions": supplementary.get("admissions"),
|
||||
"admissions_history": supplementary.get("admissions_history") or [],
|
||||
"admission_distance": supplementary.get("admission_distance"),
|
||||
"admission_distance_history": supplementary.get("admission_distance_history") or [],
|
||||
"sen_detail": supplementary.get("sen_detail"),
|
||||
"phonics": supplementary.get("phonics"),
|
||||
"deprivation": supplementary.get("deprivation"),
|
||||
|
||||
+16
-11
@@ -771,6 +771,7 @@ def _empty_supplementary() -> dict:
|
||||
"admissions": None,
|
||||
"admissions_history": [],
|
||||
"admission_distance": None,
|
||||
"admission_distance_history": [],
|
||||
"sen_detail": None,
|
||||
"phonics": None,
|
||||
"deprivation": None,
|
||||
@@ -849,25 +850,29 @@ 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.
|
||||
# Last distance offered — all years per URN, oldest first, with the latest
|
||||
# also exposed on its own.
|
||||
#
|
||||
# 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.
|
||||
# The history was deliberately withheld at first: coverage is ragged (a
|
||||
# school may have 2021 and 2026 and nothing between), and a plain series
|
||||
# would draw a trend line straight through gaps that are absences of
|
||||
# publication rather than absences of a cut-off. It is served now because
|
||||
# the client distinguishes those gaps explicitly — see cutoffYearRows in
|
||||
# lastDistanceOffered.ts, which classifies every year in the range and
|
||||
# breaks the line rather than interpolating across it.
|
||||
def _admission_distance():
|
||||
rows = (
|
||||
db.query(FactAdmissionDistance)
|
||||
.filter(FactAdmissionDistance.urn.in_(urns))
|
||||
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
|
||||
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.asc())
|
||||
.all()
|
||||
)
|
||||
seen = set()
|
||||
history: dict = {urn: [] for urn in urns}
|
||||
for d in rows:
|
||||
if d.urn in seen:
|
||||
continue
|
||||
seen.add(d.urn)
|
||||
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
|
||||
history[d.urn].append(_admission_distance_dict(d))
|
||||
for urn, rows_for_urn in history.items():
|
||||
result[urn]["admission_distance_history"] = rows_for_urn
|
||||
result[urn]["admission_distance"] = rows_for_urn[-1] if rows_for_urn else None
|
||||
_safe(_admission_distance)
|
||||
|
||||
# Deprivation — one row per URN.
|
||||
|
||||
@@ -8,8 +8,31 @@ from backend import data_loader
|
||||
from backend.data_loader import get_supplementary_data_batch
|
||||
|
||||
|
||||
def _sort_key(criterion):
|
||||
"""(column name, descending) for a SQLAlchemy order_by argument.
|
||||
|
||||
A bare column (Model.year) arrives as an InstrumentedAttribute carrying
|
||||
.key; Model.year.desc() wraps it in a UnaryExpression whose column sits on
|
||||
.element.
|
||||
"""
|
||||
name = getattr(criterion, "key", None)
|
||||
if name is not None:
|
||||
return name, False
|
||||
element = getattr(criterion, "element", None)
|
||||
name = getattr(element, "key", None)
|
||||
return name, "DESC" in str(criterion).upper()
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
"""Records that a query ran and serves canned rows filtered by an in-list."""
|
||||
"""Records that a query ran and serves canned rows filtered by an in-list.
|
||||
|
||||
order_by is honoured rather than ignored. The batch loader picks a row per
|
||||
URN by position — first for "latest Ofsted", last for "latest cut-off
|
||||
distance" — which is only correct because the database returned them
|
||||
sorted. A double that drops the ORDER BY makes those picks depend on
|
||||
fixture insertion order instead, so the test would pass with the sort
|
||||
reversed or removed and prove nothing about the query.
|
||||
"""
|
||||
|
||||
def __init__(self, recorder, model_name, rows):
|
||||
self._rec = recorder
|
||||
@@ -19,7 +42,20 @@ class _FakeQuery:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
def order_by(self, *criteria):
|
||||
for crit in reversed(criteria): # reversed = stable multi-key sort
|
||||
name, descending = _sort_key(crit)
|
||||
if not name:
|
||||
continue
|
||||
values = [getattr(r, name, None) for r in self._rows]
|
||||
# Only sort on plainly comparable values. Some fixtures stand dates
|
||||
# up as namespace objects, which raise on <; leaving those in their
|
||||
# given order matches what the real query would produce for them.
|
||||
if not all(isinstance(v, (int, float, str)) for v in values):
|
||||
continue
|
||||
self._rows = sorted(
|
||||
self._rows, key=lambda r: getattr(r, name), reverse=descending
|
||||
)
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
@@ -115,8 +151,10 @@ 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.
|
||||
# Cut-off distance: full history oldest-first, with the newest year also
|
||||
# exposed on its own. The fixture rows are deliberately out of order, so
|
||||
# this only passes if the query's ORDER BY is doing the work.
|
||||
assert [r["year"] for r in out[1]["admission_distance_history"]] == [2024, 2025, 2026]
|
||||
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
|
||||
@@ -133,3 +171,4 @@ def test_single_wrapper_matches_batch(monkeypatch):
|
||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||
assert single["admissions_history"] == []
|
||||
assert single["admission_distance"] is None
|
||||
assert single["admission_distance_history"] == []
|
||||
+114
-4
@@ -1226,14 +1226,21 @@ const CUTOFF_CANDIDATE_URNS = [
|
||||
101099, 100553, 102574, 100769, // mixed
|
||||
];
|
||||
|
||||
async function schoolWithCutoff(page: Page) {
|
||||
async function schoolWithCutoff(page: Page, minPublishedYears = 1) {
|
||||
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 };
|
||||
}
|
||||
if (body?.admission_distance?.distance_m == null) continue;
|
||||
const published = (body.admission_distance_history ?? [])
|
||||
.filter((h: { distance_m?: number | null }) => h.distance_m != null);
|
||||
if (published.length < minPublishedYears) continue;
|
||||
return {
|
||||
urn,
|
||||
distance: body.admission_distance,
|
||||
history: published,
|
||||
phase: body.school_info?.phase,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1307,3 +1314,106 @@ test('no school page shows an implausible cut-off distance', async ({ page }) =>
|
||||
}
|
||||
test.skip(checked === 0, 'no cut-off distances available to check yet');
|
||||
});
|
||||
|
||||
// ── The Distance view, map and postcode check ────────────────────────────
|
||||
|
||||
test('a school with several published years gets the Distance view', async ({ page }) => {
|
||||
const found = await schoolWithCutoff(page, 2);
|
||||
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const isSecondary = /secondary/i.test(found!.phase ?? '');
|
||||
if (!isSecondary) {
|
||||
// Primary pages carry a segmented control; the detail sits behind it.
|
||||
const distanceTab = page.getByRole('button', { name: 'Distance' });
|
||||
await expect(distanceTab).toBeVisible();
|
||||
await distanceTab.click();
|
||||
await expect(distanceTab).toHaveAttribute('aria-pressed', 'true');
|
||||
}
|
||||
|
||||
// Every published year must appear as a row, whichever template rendered it.
|
||||
for (const h of found!.history as { year: number }[]) {
|
||||
await expect(page.getByRole('rowheader', { name: String(h.year) })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('the year table never leaves a gap unexplained', async ({ page }) => {
|
||||
// A blank row would tell a parent nothing; each gap must name its reason.
|
||||
const found = await schoolWithCutoff(page, 2);
|
||||
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
||||
if (!/secondary/i.test(found!.phase ?? '')) {
|
||||
await page.getByRole('button', { name: 'Distance' }).click();
|
||||
}
|
||||
|
||||
const statuses = await page.locator('[class*="cutoffPill"]').allTextContents();
|
||||
expect(statuses.length).toBeGreaterThan(0);
|
||||
for (const s of statuses) {
|
||||
expect(s.trim(), 'every row carries a status').not.toBe('');
|
||||
}
|
||||
// The stronger claim the data cannot support must never appear.
|
||||
await expect(page.getByText(/every applicant was offered/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the postcode check answers with a distance and per-year verdicts', async ({ page }) => {
|
||||
const found = await schoolWithCutoff(page, 2);
|
||||
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
||||
if (!/secondary/i.test(found!.phase ?? '')) {
|
||||
await page.getByRole('button', { name: 'Distance' }).click();
|
||||
}
|
||||
|
||||
const input = page.getByLabel('Your postcode');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill('SW1A 1AA');
|
||||
await page.getByRole('button', { name: 'Check' }).click();
|
||||
|
||||
// Either a verdict or a plain error — never a silent no-op.
|
||||
const result = page.getByRole('status');
|
||||
const error = page.getByRole('alert');
|
||||
await expect(result.or(error)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
if (await result.isVisible()) {
|
||||
await expect(result).toContainText(/mile|m\b/);
|
||||
}
|
||||
});
|
||||
|
||||
test('the postcode check states its limits before it is used', async ({ page }) => {
|
||||
// The caveat is not revealed with the answer — it is there while the parent
|
||||
// is deciding whether to trust the answer at all.
|
||||
const found = await schoolWithCutoff(page, 2);
|
||||
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
||||
if (!/secondary/i.test(found!.phase ?? '')) {
|
||||
await page.getByRole('button', { name: 'Distance' }).click();
|
||||
}
|
||||
|
||||
await expect(page.getByText(/An indication only/)).toBeVisible();
|
||||
await expect(page.getByText(/not a catchment boundary/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('the distance section never scrolls the page sideways', async ({ page }) => {
|
||||
const found = await schoolWithCutoff(page, 2);
|
||||
test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
|
||||
if (!/secondary/i.test(found!.phase ?? '')) {
|
||||
await page.getByRole('button', { name: 'Distance' }).click();
|
||||
}
|
||||
|
||||
// The year table is deliberately wider than a phone; its own wrapper has to
|
||||
// absorb that, or the whole page slides under the reader's thumb.
|
||||
const overflow = await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The postcode check.
|
||||
*
|
||||
* This is the one place on the site that answers a question about a specific
|
||||
* family rather than about a school, so the tests here are mostly about what it
|
||||
* refuses to say: no verdict without a published figure, and no verdict at all
|
||||
* when the margin is inside the error of a postcode centroid.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { CutoffMapPanel } from '@/components/school/CutoffMapPanel';
|
||||
import { cutoffYearRows, CUTOFF_UNCERTAINTY_M } from '@/components/school/lastDistanceOffered';
|
||||
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
// Leaflet needs a real layout box and network tiles; neither exists in jsdom.
|
||||
// The panel's logic is independent of it, so the map is stubbed out.
|
||||
jest.mock('@/components/LeafletCutoffMapInner', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="cutoff-map" />,
|
||||
}));
|
||||
|
||||
const mockGeocode = jest.fn();
|
||||
jest.mock('@/lib/api', () => ({
|
||||
...jest.requireActual('@/lib/api'),
|
||||
geocodePostcode: (pc: string) => mockGeocode(pc),
|
||||
}));
|
||||
|
||||
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
|
||||
|
||||
const d = (year: number, distance_m: number): SchoolAdmissionDistance => ({
|
||||
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
|
||||
});
|
||||
|
||||
/** A point due north of the school, `metres` away. 1° latitude ≈ 111,320 m. */
|
||||
function northOf(metres: number) {
|
||||
return { latitude: SCHOOL.latitude! + metres / 111_320, longitude: SCHOOL.longitude! };
|
||||
}
|
||||
|
||||
function renderPanel(history: SchoolAdmissionDistance[], admissions: { year: number; oversubscribed?: boolean }[] = []) {
|
||||
return render(
|
||||
<CutoffMapPanel schoolInfo={SCHOOL} rows={cutoffYearRows(history, admissions)} />,
|
||||
);
|
||||
}
|
||||
|
||||
async function check(postcode: string) {
|
||||
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check' }));
|
||||
}
|
||||
|
||||
beforeEach(() => mockGeocode.mockReset());
|
||||
|
||||
describe('CutoffMapPanel', () => {
|
||||
it('renders nothing without a published figure to draw', () => {
|
||||
const { container } = renderPanel([]);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when the school has no coordinates', () => {
|
||||
const { container } = render(
|
||||
<CutoffMapPanel
|
||||
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
|
||||
rows={cutoffYearRows([d(2024, 800)])}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('rejects a malformed postcode without calling the geocoder', async () => {
|
||||
renderPanel([d(2024, 800)]);
|
||||
await check('not a postcode');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/does not look like a UK postcode/);
|
||||
expect(mockGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a home clearly inside every published year', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel([d(2022, 1000), d(2024, 800)]);
|
||||
await check('SE23 3NA');
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/inside the cut-off in all 2 years/);
|
||||
});
|
||||
|
||||
it('reports a home clearly outside every published year', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(5000));
|
||||
renderPanel([d(2022, 1000), d(2024, 800)]);
|
||||
await check('SE23 3NA');
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/outside the cut-off in every year/);
|
||||
});
|
||||
|
||||
it('declines to call a result that sits inside the measurement error', async () => {
|
||||
// The home is nominally inside 2024's 800 m cut-off, but only by half the
|
||||
// uncertainty band — which a postcode centroid cannot resolve.
|
||||
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
|
||||
renderPanel([d(2024, 800)]);
|
||||
await check('SE23 3NA');
|
||||
|
||||
const result = await screen.findByRole('status');
|
||||
expect(result).toHaveTextContent(/too close to call/);
|
||||
expect(result).not.toHaveTextContent(/inside the cut-off in all/);
|
||||
});
|
||||
|
||||
it('counts an unpublished year as unknown rather than as a pass', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel([d(2022, 1000), d(2024, 800)], [{ year: 2023, oversubscribed: false }]);
|
||||
await check('SE23 3NA');
|
||||
|
||||
const result = await screen.findByRole('status');
|
||||
expect(result).toHaveTextContent(/inside the cut-off in all 2 years/);
|
||||
expect(result).toHaveTextContent(/no published figure/);
|
||||
});
|
||||
|
||||
it('surfaces a postcode the geocoder cannot find', async () => {
|
||||
mockGeocode.mockResolvedValue(null);
|
||||
renderPanel([d(2024, 800)]);
|
||||
await check('ZZ99 9ZZ');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
|
||||
});
|
||||
|
||||
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel([d(2024, 800)]);
|
||||
await check('SE23 3NA');
|
||||
await screen.findByRole('status');
|
||||
|
||||
mockGeocode.mockRejectedValue(new Error('network'));
|
||||
await check('SE23 3NB');
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
|
||||
});
|
||||
|
||||
it('always states the limits of the check', async () => {
|
||||
renderPanel([d(2024, 800)]);
|
||||
// Present before any postcode is entered, not revealed with the answer.
|
||||
expect(screen.getByText(/An indication only/)).toBeInTheDocument();
|
||||
// The two caveats divide the work: the map says the rings are not a
|
||||
// boundary, the check says the measurement may not be a straight line.
|
||||
expect(screen.getByText(/not a catchment boundary/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/walking route rather than a straight line/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
* sentence appears only when it is actually true.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
@@ -88,3 +88,122 @@ describe('secondary detail page', () => {
|
||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The Distance view ──────────────────────────────────────────────────
|
||||
|
||||
const history = (pts: [number, number][]): SchoolAdmissionDistance[] =>
|
||||
pts.map(([year, distance_m]) => ({
|
||||
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
|
||||
}));
|
||||
|
||||
describe('primary Distance view', () => {
|
||||
it('appears once there are two or more published years', () => {
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2021, 1000], [2022, 900], [2023, 800], [2024, 700]]),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Distance' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays hidden on a single published year', () => {
|
||||
// One point is a fact, not a history; a chart of it invites a trend reading
|
||||
// that is not there.
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2024, 700]]),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Distance' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every year in the span, including the ones with no figure', () => {
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
|
||||
});
|
||||
|
||||
// The view is server-rendered but starts hidden behind its tab, and role
|
||||
// queries skip hidden subtrees — so open it the way a reader would.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Distance' }));
|
||||
|
||||
// 2022 and 2023 were never published but must still appear as rows, or the
|
||||
// gap in the chart has nothing explaining it.
|
||||
for (const year of ['2021', '2022', '2023', '2024']) {
|
||||
expect(screen.getByRole('rowheader', { name: year })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getAllByText('Not published').length).toBe(2);
|
||||
});
|
||||
|
||||
it('never claims everyone was offered a place', () => {
|
||||
// fact_admissions.oversubscribed is about first preferences only, so the
|
||||
// stronger claim is not supported by the data behind it.
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
|
||||
admissionsHistory: [
|
||||
...primaryFixture.admissionsHistory,
|
||||
{ year: 2022, oversubscribed: false, places_offered: 60 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Places available on first preferences/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/every applicant/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/all offered/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('withholds a trend reading while the record is thin', () => {
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2023, 800], [2024, 700]]),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/too few to read as a trend/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Across \d+ published years/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary Distance detail', () => {
|
||||
it('renders inline, with no extra tab, once there are two published years', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 3472.96 }),
|
||||
admissionDistanceHistory: history([[2023, 3800], [2024, 3472.96]]),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Last distance offered, by year/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('rowheader', { name: '2023' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains a selective school by how it admits rather than as missing data', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' },
|
||||
admissionDistance: null,
|
||||
admissionDistanceHistory: [],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads a consistently undersubscribed school as good news', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
admissionDistance: null,
|
||||
admissionDistanceHistory: [],
|
||||
admissionsHistory: [
|
||||
{ year: 2022, oversubscribed: false },
|
||||
{ year: 2023, oversubscribed: false },
|
||||
{ year: 2024, oversubscribed: false },
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/has not needed a distance cut-off/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,10 @@
|
||||
*/
|
||||
|
||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
||||
import { describeCutoff } from '@/components/school/lastDistanceOffered';
|
||||
import {
|
||||
describeCutoff, cutoffYearRows, cutoffTrendSummary, cutoffCoverageNote,
|
||||
describeCutoffAbsence, compareToCutoffs, entryYearOf, CUTOFF_UNCERTAINTY_M,
|
||||
} from '@/components/school/lastDistanceOffered';
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||
@@ -78,3 +81,151 @@ describe('describeCutoff', () => {
|
||||
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Year-by-year history ───────────────────────────────────────────────
|
||||
|
||||
const d = (year: number, distance_m: number | null, route_count = 1): SchoolAdmissionDistance => ({
|
||||
year, distance_m, route_count, la_name: 'Camden', distance_unit_raw: 'miles',
|
||||
});
|
||||
|
||||
describe('entryYearOf', () => {
|
||||
it('reduces EES academic codes and plain entry years to one key', () => {
|
||||
// Without this the two histories never join and every year looks unpublished.
|
||||
expect(entryYearOf(202425)).toBe(2024);
|
||||
expect(entryYearOf(2024)).toBe(2024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cutoffYearRows', () => {
|
||||
it('emits a row for every year in the span, including the empty ones', () => {
|
||||
const rows = cutoffYearRows([d(2021, 900), d(2024, 700)]);
|
||||
expect(rows.map((r) => r.year)).toEqual([2024, 2023, 2022, 2021]);
|
||||
expect(rows.map((r) => r.status)).toEqual([
|
||||
'published', 'not-published', 'not-published', 'published',
|
||||
]);
|
||||
});
|
||||
|
||||
it('separates "nothing published" from "was not oversubscribed"', () => {
|
||||
// The two look identical in the distance data and mean opposite things.
|
||||
const rows = cutoffYearRows(
|
||||
[d(2021, 900), d(2023, 700)],
|
||||
[{ year: 2022, oversubscribed: false }],
|
||||
);
|
||||
expect(rows.find((r) => r.year === 2022)!.status).toBe('not-oversubscribed');
|
||||
});
|
||||
|
||||
it('matches EES six-digit years against plain cut-off years', () => {
|
||||
const rows = cutoffYearRows(
|
||||
[d(2023, 700)],
|
||||
[{ year: 202324, places_offered: 60, oversubscribed: true }],
|
||||
);
|
||||
expect(rows.find((r) => r.year === 2023)!.placesOffered).toBe(60);
|
||||
});
|
||||
|
||||
it('does not stretch the span back over admissions years with no cut-offs', () => {
|
||||
// EES reaches back further than councils publish; padding the chart with a
|
||||
// decade of blanks would bury the years that carry a figure.
|
||||
const rows = cutoffYearRows(
|
||||
[d(2024, 700)],
|
||||
[{ year: 2015, oversubscribed: true }, { year: 2024, oversubscribed: true }],
|
||||
);
|
||||
expect(rows.map((r) => r.year)).toEqual([2024]);
|
||||
});
|
||||
|
||||
it('is safe when the backend does not send the field at all', () => {
|
||||
expect(cutoffYearRows(undefined as never)).toEqual([]);
|
||||
expect(cutoffYearRows([], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cutoffTrendSummary', () => {
|
||||
const rowsFor = (pts: [number, number][]) => cutoffYearRows(pts.map(([y, m]) => d(y, m)));
|
||||
|
||||
it('says nothing below four published points', () => {
|
||||
expect(cutoffTrendSummary(rowsFor([[2022, 900], [2023, 800], [2024, 700]]))).toBeNull();
|
||||
});
|
||||
|
||||
it('names both endpoints and their years rather than passing a verdict', () => {
|
||||
const s = cutoffTrendSummary(rowsFor([
|
||||
[2021, 1000], [2022, 900], [2023, 800], [2024, 500],
|
||||
]));
|
||||
expect(s).toContain('2021');
|
||||
expect(s).toContain('2024');
|
||||
expect(s).toContain('tightened');
|
||||
});
|
||||
|
||||
it('does not call a small wobble a direction', () => {
|
||||
const s = cutoffTrendSummary(rowsFor([
|
||||
[2021, 1000], [2022, 1010], [2023, 990], [2024, 1030],
|
||||
]));
|
||||
expect(s).toContain('stayed broadly the same');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cutoffCoverageNote', () => {
|
||||
it('warns while the record is too thin to read as a trend', () => {
|
||||
expect(cutoffCoverageNote(cutoffYearRows([d(2024, 700)]))).toContain('Only 1 year');
|
||||
expect(cutoffCoverageNote(cutoffYearRows([
|
||||
d(2021, 1000), d(2022, 900), d(2023, 800), d(2024, 700),
|
||||
]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCutoffAbsence', () => {
|
||||
it('explains a selective school by how it admits, not by missing data', () => {
|
||||
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
|
||||
expect(s).toContain('entrance test');
|
||||
expect(s).not.toContain('has not published');
|
||||
});
|
||||
|
||||
it('reads a consistently undersubscribed school as good news', () => {
|
||||
const s = describeCutoffAbsence({
|
||||
localAuthority: 'Camden',
|
||||
admissionsHistory: [
|
||||
{ year: 2022, oversubscribed: false },
|
||||
{ year: 2023, oversubscribed: false },
|
||||
{ year: 2024, oversubscribed: false },
|
||||
],
|
||||
});
|
||||
expect(s).toContain('has not needed a distance cut-off');
|
||||
});
|
||||
|
||||
it('otherwise names the authority that would hold the figure', () => {
|
||||
expect(describeCutoffAbsence({ localAuthority: 'Camden' }))
|
||||
.toContain('Camden has not published');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareToCutoffs', () => {
|
||||
const rows = cutoffYearRows(
|
||||
[d(2022, 1000), d(2024, 800)],
|
||||
[{ year: 2023, oversubscribed: false }],
|
||||
);
|
||||
|
||||
it('marks a clearly nearer home as inside every comparable year', () => {
|
||||
const r = compareToCutoffs(300, rows);
|
||||
expect(r.insideCount).toBe(2);
|
||||
expect(r.comparableCount).toBe(2);
|
||||
expect(r.headline).toContain('inside the cut-off in all 2 years');
|
||||
});
|
||||
|
||||
it('marks a clearly further home as outside', () => {
|
||||
const r = compareToCutoffs(4000, rows);
|
||||
expect(r.insideCount).toBe(0);
|
||||
expect(r.headline).toContain('outside the cut-off in every year');
|
||||
});
|
||||
|
||||
it('refuses to call a result inside the measurement error', () => {
|
||||
// A postcode centroid covers several addresses; claiming a place on a 20 m
|
||||
// margin would be inventing precision the inputs do not have.
|
||||
const r = compareToCutoffs(800 - CUTOFF_UNCERTAINTY_M / 2, rows);
|
||||
expect(r.years.find((y) => y.year === 2024)!.verdict).toBe('too-close');
|
||||
expect(r.detail).toContain('too close to call');
|
||||
});
|
||||
|
||||
it('never counts an unpublished year as a pass or a fail', () => {
|
||||
const r = compareToCutoffs(300, rows);
|
||||
expect(r.years.find((y) => y.year === 2023)!.verdict).toBe('unknown');
|
||||
expect(r.detail).toContain('no published figure');
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export function renderSchoolDetail(fixture: any) {
|
||||
>
|
||||
<PrimarySchoolSections
|
||||
{...fixture}
|
||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
@@ -72,6 +73,8 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
||||
>
|
||||
<SecondarySchoolSections
|
||||
{...fixture}
|
||||
admissionsHistory={fixture.admissionsHistory ?? []}
|
||||
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||
nationalAvg={nationalAveragesFixture}
|
||||
flags={flags}
|
||||
/>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, admission_distance_history, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -229,7 +229,9 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
ofsted={ofsted ?? null}
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
admissionDistanceHistory={admission_distance_history ?? []}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
@@ -252,6 +254,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
admissionDistanceHistory={admission_distance_history ?? []}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* CutoffTrendChart
|
||||
* The last distance offered, year by year.
|
||||
*
|
||||
* Two things this chart deliberately does not do.
|
||||
*
|
||||
* It does not join across gaps (`spanGaps: false`). A missing year is usually a
|
||||
* year the authority published nothing, and a line drawn through it would
|
||||
* assert a cut-off that was never measured.
|
||||
*
|
||||
* It does not plot the non-distance outcomes. A year in which the school was
|
||||
* not oversubscribed has no distance — that is the point of it — and giving it
|
||||
* a y-position would put a number on the axis that does not exist. The mockup
|
||||
* floated such years at the top of the plot; here they are a gap in the line
|
||||
* and a labelled row in the table underneath, which is where a reason can be
|
||||
* stated in words rather than implied by a coordinate.
|
||||
*/
|
||||
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||||
import type { CutoffYearRow } from './school/lastDistanceOffered';
|
||||
import styles from './AdmissionsTrendChart.module.css';
|
||||
|
||||
const METRES_PER_MILE = 1609.344;
|
||||
|
||||
export default function CutoffTrendChart({ rows }: { rows: CutoffYearRow[] }) {
|
||||
// rows arrive newest-first; a time axis reads oldest-first.
|
||||
const axis = [...rows].reverse();
|
||||
const published = axis.filter((r) => r.distanceM != null);
|
||||
if (published.length < 2) return null;
|
||||
|
||||
const labels = axis.map((r) => String(r.year));
|
||||
const values: (number | null)[] = axis.map((r) =>
|
||||
r.distanceM != null ? r.distanceM / METRES_PER_MILE : null,
|
||||
);
|
||||
|
||||
const present = values.map((v, i) => (v != null ? i : -1)).filter((i) => i >= 0);
|
||||
const lastIdx = present[present.length - 1];
|
||||
|
||||
const numeric = values.filter((v): v is number => v != null);
|
||||
const lo = Math.min(...numeric);
|
||||
const hi = Math.max(...numeric);
|
||||
// Headroom proportional to the spread, with a floor so a flat series does not
|
||||
// collapse onto a single gridline and read as more precise than it is.
|
||||
const pad = Math.max(0.05, (hi - lo) * 0.25);
|
||||
const yMin = Math.max(0, lo - pad);
|
||||
const yMax = hi + pad;
|
||||
|
||||
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
|
||||
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card',
|
||||
);
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
layout: { padding: { top: 8 } },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: cInverse,
|
||||
titleColor: cInverseText,
|
||||
bodyColor: cInverseText,
|
||||
padding: 10,
|
||||
titleFont: { size: 12 },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
ctx.parsed.y == null ? '' : `Last distance offered: ${ctx.parsed.y.toFixed(2)} miles`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: { color: cGrid },
|
||||
ticks: {
|
||||
font: { size: 11 },
|
||||
maxTicksLimit: 5,
|
||||
callback: (v) => `${Number(v).toFixed(2)} mi`,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 11 }, autoSkip: true, maxRotation: 0, autoSkipPadding: 16 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Last distance offered',
|
||||
data: values,
|
||||
clip: false as const,
|
||||
spanGaps: false,
|
||||
borderColor: cBrand,
|
||||
backgroundColor: alpha('--brand', 0.10),
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: values.map((v, i) => (v == null ? 0 : i === lastIdx ? 5 : 3)),
|
||||
pointBackgroundColor: cBrand,
|
||||
pointBorderColor: cCard,
|
||||
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Line data={data} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* LeafletCutoffMapInner
|
||||
* The cut-off distances drawn around the school.
|
||||
*
|
||||
* L.circle takes a radius in metres and projects it properly, which is exactly
|
||||
* what these rings are: a straight-line distance from the school. (L.circleMarker
|
||||
* would take pixels and would silently stop meaning anything as the user zoomed.)
|
||||
*
|
||||
* The latest year is filled and labelled; earlier years are hairlines behind it,
|
||||
* so a tightening cut-off reads as a set of shrinking circles without needing a
|
||||
* legend to decode it. The map auto-fits the widest ring, so the whole history
|
||||
* is in frame whatever the distances are.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export interface CutoffRing {
|
||||
year: number;
|
||||
radiusM: number;
|
||||
latest: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lat: number;
|
||||
lng: number;
|
||||
rings: CutoffRing[];
|
||||
/** The parent's geocoded postcode, once they have entered one. */
|
||||
home: { lat: number; lng: number } | null;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
const SCHOOL_PIN = `
|
||||
<div style="position:relative;width:22px;height:22px">
|
||||
<span style="position:absolute;left:50%;top:50%;width:26px;height:26px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||||
<span style="position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand);border:3px solid var(--bg-card)"></span>
|
||||
</div>`;
|
||||
|
||||
// A square rotated 45° — a different SHAPE from the school's circle, not just a
|
||||
// different colour, so the two are still distinguishable to anyone who cannot
|
||||
// separate the hues.
|
||||
const HOME_PIN = `
|
||||
<div style="width:18px;height:18px;display:grid;place-items:center">
|
||||
<span style="width:12px;height:12px;background:var(--status-above);border:2.5px solid var(--bg-card);transform:rotate(45deg);border-radius:2px"></span>
|
||||
</div>`;
|
||||
|
||||
function cssVar(name: string, fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layersRef = useRef<L.Layer[]>([]);
|
||||
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elRef.current || mapRef.current) return;
|
||||
const map = L.map(elRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: true,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
touchZoom: false,
|
||||
}).setView([lat, lng], 14);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
setTimeout(() => map.invalidateSize(), 60);
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
layersRef.current = [];
|
||||
zoomCtrlRef.current = null;
|
||||
};
|
||||
}, [lat, lng]);
|
||||
|
||||
// Redraw rings, pins and the viewport whenever the data changes.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
layersRef.current.forEach((l) => l.remove());
|
||||
layersRef.current = [];
|
||||
|
||||
const brand = cssVar('--brand', '#0F766E');
|
||||
const above = cssVar('--status-above', '#2F855A');
|
||||
|
||||
// Widest first, so the small recent ring is not buried under the old ones.
|
||||
const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM);
|
||||
for (const r of ordered) {
|
||||
const circle = L.circle([lat, lng], {
|
||||
radius: r.radiusM,
|
||||
color: brand,
|
||||
weight: r.latest ? 3 : 1.5,
|
||||
opacity: r.latest ? 1 : 0.45,
|
||||
fill: r.latest,
|
||||
fillColor: brand,
|
||||
fillOpacity: r.latest ? 0.12 : 0,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(circle);
|
||||
}
|
||||
|
||||
const school = L.marker([lat, lng], {
|
||||
icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }),
|
||||
keyboard: false,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(school);
|
||||
|
||||
if (home) {
|
||||
const pin = L.marker([home.lat, home.lng], {
|
||||
icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }),
|
||||
keyboard: false,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(pin);
|
||||
|
||||
const line = L.polyline([[lat, lng], [home.lat, home.lng]], {
|
||||
color: above,
|
||||
weight: 2,
|
||||
dashArray: '5 5',
|
||||
opacity: 0.85,
|
||||
interactive: false,
|
||||
}).addTo(map);
|
||||
layersRef.current.push(line);
|
||||
}
|
||||
|
||||
// Frame the widest ring — and the home pin when it falls outside it, so a
|
||||
// family beyond every cut-off can still see where they sit.
|
||||
const widest = ordered[0]?.radiusM ?? 500;
|
||||
let bounds = L.latLng(lat, lng).toBounds(widest * 2.2);
|
||||
if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng));
|
||||
map.fitBounds(bounds, { padding: [16, 16] });
|
||||
}, [lat, lng, rings, home]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const handlers = [
|
||||
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
|
||||
];
|
||||
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
|
||||
|
||||
if (interactive && !zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
|
||||
zoomCtrlRef.current.addTo(map);
|
||||
} else if (!interactive && zoomCtrlRef.current) {
|
||||
zoomCtrlRef.current.remove();
|
||||
zoomCtrlRef.current = null;
|
||||
}
|
||||
setTimeout(() => map.invalidateSize(), 80);
|
||||
}, [interactive]);
|
||||
|
||||
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
@@ -12,19 +12,25 @@
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
||||
import type { School, 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 { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
|
||||
import { AdmissionsTrendChart } from './charts';
|
||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
||||
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
|
||||
import {
|
||||
describeCutoff, cutoffYearRows,
|
||||
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
|
||||
} from './lastDistanceOffered';
|
||||
|
||||
|
||||
export function AdmissionsSection({
|
||||
admissions,
|
||||
admissionsHistory,
|
||||
admissionDistance,
|
||||
admissionDistanceHistory,
|
||||
schoolInfo,
|
||||
isAllThrough,
|
||||
}: {
|
||||
/* Nullable: the two data sources are independent. A minority of schools have
|
||||
@@ -34,9 +40,18 @@ export function AdmissionsSection({
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||
schoolInfo: School;
|
||||
isAllThrough: boolean;
|
||||
}) {
|
||||
const cutoff = describeCutoff(admissionDistance);
|
||||
// Every year in the span, including the ones with nothing in them — the
|
||||
// classification of a missing year is what the Distance view is mostly for.
|
||||
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
|
||||
const publishedYears = cutoffRows.filter((r) => r.status === 'published').length;
|
||||
// One published year is a fact, not a history: the tile already says it, and
|
||||
// a chart of a single point invites a trend reading that is not there.
|
||||
const showDistanceView = publishedYears >= 2;
|
||||
|
||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||
@@ -167,18 +182,36 @@ export function AdmissionsSection({
|
||||
</>
|
||||
);
|
||||
|
||||
const distanceView = showDistanceView && (
|
||||
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} />
|
||||
);
|
||||
|
||||
const views: AdmissionsView[] = [
|
||||
{ id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear },
|
||||
];
|
||||
if (showAdmissionsTrend) {
|
||||
views.push({
|
||||
id: 'trend',
|
||||
label: `${admissionsHistory.length}-year trend`,
|
||||
content: trendView,
|
||||
className: styles.admissionsViewTrend,
|
||||
});
|
||||
}
|
||||
if (showDistanceView) {
|
||||
views.push({
|
||||
id: 'distance',
|
||||
label: 'Distance',
|
||||
content: distanceView,
|
||||
className: styles.admissionsViewTrend,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Section id="admissions">
|
||||
{showAdmissionsTrend ? (
|
||||
<AdmissionsViewToggle
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
trendLabel={`${admissionsHistory.length}-year trend`}
|
||||
yearView={yearView}
|
||||
trendView={trendView}
|
||||
/>
|
||||
{views.length > 1 ? (
|
||||
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
|
||||
) : (
|
||||
/* No trend data — render statically, with no client component at all. */
|
||||
/* One view — render statically, with no client component at all. */
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
|
||||
@@ -3,14 +3,25 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
export interface AdmissionsView {
|
||||
id: string;
|
||||
/** Button text. "This year" and the "N-year trend" label are asserted by the
|
||||
* e2e journey, so they are not free to drift. */
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
/** The year view sizes its tile grid to match the chart beside it, so each
|
||||
* view keeps its own class rather than sharing one wrapper style. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only interactive part of the primary admissions section, and the only
|
||||
* client component in components/school/.
|
||||
*
|
||||
* Both views are always present in the DOM and visibility is toggled with the
|
||||
* `hidden` attribute — matching the previous behaviour exactly — so the
|
||||
* server-rendered markup passed in as yearView/trendView never ships as client
|
||||
* JavaScript.
|
||||
* Every view is always present in the DOM and visibility is toggled with the
|
||||
* `hidden` attribute, so the server-rendered markup passed in as content never
|
||||
* ships as client JavaScript — this component carries the state and nothing
|
||||
* else.
|
||||
*
|
||||
* It spans the header and the viewport because the segmented control sits
|
||||
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
|
||||
@@ -19,39 +30,38 @@ import styles from './schoolSections.module.css';
|
||||
export function AdmissionsViewToggle({
|
||||
title,
|
||||
subtitle,
|
||||
trendLabel,
|
||||
yearView,
|
||||
trendView,
|
||||
views,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle: ReactNode;
|
||||
trendLabel: string;
|
||||
yearView: ReactNode;
|
||||
trendView: ReactNode;
|
||||
views: AdmissionsView[];
|
||||
}) {
|
||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
||||
const [active, setActive] = useState(views[0]?.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
||||
This year
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
||||
{trendLabel}
|
||||
</button>
|
||||
{views.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
aria-pressed={active === v.id}
|
||||
onClick={() => setActive(v.id)}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{subtitle}
|
||||
<div className={styles.admissionsViewport}>
|
||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
|
||||
{yearView}
|
||||
</div>
|
||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
|
||||
{trendView}
|
||||
</div>
|
||||
{views.map((v) => (
|
||||
<div key={v.id} className={v.className} hidden={active !== v.id}>
|
||||
{v.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* CutoffDistanceDetail — the full year-by-year cut-off story.
|
||||
*
|
||||
* Chart, table, map and postcode check, in that order: the shape of the trend,
|
||||
* then what happened in each year, then the same numbers over real streets,
|
||||
* then the reader's own address measured against them.
|
||||
*
|
||||
* Shared by both templates because the content is identical, but placed
|
||||
* differently by each. The primary page has a segmented control and gives this
|
||||
* its own "Distance" tab; the secondary page is one flat panel by design, so it
|
||||
* renders inline beneath the metric cards. Extracting it keeps the caveats and
|
||||
* the ordering from drifting apart between the two.
|
||||
*
|
||||
* Server component — the map and postcode form are the only client parts, and
|
||||
* they carry their own boundary.
|
||||
*/
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { sectionStyles as styles } from './sectionShared';
|
||||
import { CutoffTrendChart } from './charts';
|
||||
import { CutoffYearTable } from './CutoffYearTable';
|
||||
import { CutoffMapPanel } from './CutoffMapPanel';
|
||||
import {
|
||||
cutoffTrendSummary, cutoffCoverageNote,
|
||||
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
|
||||
type CutoffYearRow,
|
||||
} from './lastDistanceOffered';
|
||||
|
||||
export function CutoffDistanceDetail({
|
||||
rows,
|
||||
schoolInfo,
|
||||
/** Repeat the "not a catchment" caveat here. On the primary page this view is
|
||||
* behind a tab, so a reader can reach it without ever seeing the tile that
|
||||
* carries the caveat; on the secondary page the caveat is already directly
|
||||
* above, and repeating it verbatim reads as a stutter. */
|
||||
showCaveat = true,
|
||||
}: {
|
||||
rows: CutoffYearRow[];
|
||||
schoolInfo: School;
|
||||
showCaveat?: boolean;
|
||||
}) {
|
||||
const publishedYears = rows.filter((r) => r.status === 'published').length;
|
||||
if (publishedYears < 2) return null;
|
||||
|
||||
const trendSummary = cutoffTrendSummary(rows);
|
||||
const coverageNote = cutoffCoverageNote(rows);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.admissionsChartCap}>Last distance offered, by year</div>
|
||||
<CutoffTrendChart rows={rows} />
|
||||
<p className={styles.cutoffChartKey}>
|
||||
The line breaks where no figure was published — it is never drawn across
|
||||
a gap. The table below says what happened in each of those years.
|
||||
</p>
|
||||
{trendSummary && <p className={styles.admissionsTrendSummary}>{trendSummary}</p>}
|
||||
|
||||
<CutoffYearTable rows={rows} />
|
||||
{coverageNote && <p className={styles.cutoffChartKey}>{coverageNote}</p>}
|
||||
|
||||
<CutoffMapPanel schoolInfo={schoolInfo} rows={rows} />
|
||||
|
||||
{showCaveat && (
|
||||
<p className={styles.admissionsDistanceNote}>
|
||||
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* CutoffMapPanel — "Where the last place went".
|
||||
*
|
||||
* The rings and the postcode check live in one component because they are one
|
||||
* question asked twice: the map answers "how far is that?" and the form answers
|
||||
* "how far are we?". Entering a postcode drops a pin on the same rings rather
|
||||
* than producing a separate verdict somewhere else on the page.
|
||||
*
|
||||
* The postcode never leaves the browser except to postcodes.io for a lat/long,
|
||||
* and nothing is stored — this is a client-side measurement, not a lookup
|
||||
* against the family.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, type FormEvent } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { School } from '@/lib/types';
|
||||
import { geocodePostcode, calculateDistance } from '@/lib/api';
|
||||
import { isValidPostcode } from '@/lib/utils';
|
||||
import {
|
||||
compareToCutoffs, CUTOFF_CHECK_CAVEAT, CUTOFF_MAP_CAVEAT,
|
||||
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
|
||||
} from './lastDistanceOffered';
|
||||
import type { CutoffRing } from '../LeafletCutoffMapInner';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
|
||||
ssr: false,
|
||||
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
|
||||
});
|
||||
|
||||
const VERDICT_MARK: Record<CutoffVerdict, string> = {
|
||||
inside: '✓',
|
||||
outside: '✕',
|
||||
'too-close': '≈',
|
||||
unknown: '–',
|
||||
};
|
||||
|
||||
const VERDICT_TITLE: Record<CutoffVerdict, string> = {
|
||||
inside: 'Inside that year’s cut-off',
|
||||
outside: 'Outside that year’s cut-off',
|
||||
'too-close': 'Too close to the cut-off to say',
|
||||
unknown: 'No published figure for this year',
|
||||
};
|
||||
|
||||
// Written out rather than composed from the verdict string. A computed
|
||||
// `styles[...]` key silently yields undefined when a class is renamed, and an
|
||||
// unstyled "outside" chip would look exactly like an "inside" one.
|
||||
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
|
||||
inside: styles.cutoffYearInside,
|
||||
outside: styles.cutoffYearOutside,
|
||||
'too-close': styles.cutoffYearTooClose,
|
||||
unknown: styles.cutoffYearUnknown,
|
||||
};
|
||||
|
||||
export function CutoffMapPanel({
|
||||
schoolInfo,
|
||||
rows,
|
||||
}: {
|
||||
schoolInfo: School;
|
||||
rows: CutoffYearRow[];
|
||||
}) {
|
||||
const [postcode, setPostcode] = useState('');
|
||||
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [result, setResult] = useState<CutoffCheckResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const lat = schoolInfo.latitude;
|
||||
const lng = schoolInfo.longitude;
|
||||
|
||||
const rings: CutoffRing[] = useMemo(() => {
|
||||
const published = rows.filter((r) => r.distanceM != null);
|
||||
return published.map((r, i) => ({
|
||||
year: r.year,
|
||||
radiusM: r.distanceM as number,
|
||||
// rows are newest-first, so the first published row is the current year.
|
||||
latest: i === 0,
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
// Without coordinates there is nothing to draw rings around and nothing to
|
||||
// measure a postcode against.
|
||||
if (lat == null || lng == null || rings.length === 0) return null;
|
||||
|
||||
const onCheck = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = postcode.trim();
|
||||
if (!value) return;
|
||||
|
||||
if (!isValidPostcode(value)) {
|
||||
setError('That does not look like a UK postcode. Try one like SE23 3NA.');
|
||||
setResult(null);
|
||||
setHome(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const point = await geocodePostcode(value);
|
||||
if (!point) {
|
||||
setError('We could not find that postcode. Check it and try again.');
|
||||
setResult(null);
|
||||
setHome(null);
|
||||
return;
|
||||
}
|
||||
setHome({ lat: point.latitude, lng: point.longitude });
|
||||
// calculateDistance returns kilometres; everything here is metres.
|
||||
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
|
||||
setResult(compareToCutoffs(metres, rows));
|
||||
} catch {
|
||||
setError('Something went wrong looking up that postcode. Try again in a moment.');
|
||||
setResult(null);
|
||||
setHome(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const latestRing = rings[0];
|
||||
|
||||
return (
|
||||
<div className={styles.cutoffMapPanel}>
|
||||
<h3 className={styles.cutoffMapTitle}>Where the last place went</h3>
|
||||
|
||||
<div className={styles.cutoffMapFigure}>
|
||||
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
|
||||
</div>
|
||||
|
||||
<ul className={styles.cutoffMapLegend}>
|
||||
<li>
|
||||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
|
||||
{latestRing.year} cut-off
|
||||
</li>
|
||||
{rings.length > 1 && (
|
||||
<li>
|
||||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
|
||||
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
|
||||
</li>
|
||||
)}
|
||||
{home && (
|
||||
<li>
|
||||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
|
||||
Your postcode
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<p className={styles.cutoffMapCaveat}>{CUTOFF_MAP_CAVEAT}</p>
|
||||
|
||||
<div className={styles.cutoffCheck}>
|
||||
<h4 className={styles.cutoffCheckTitle}>How far away are you?</h4>
|
||||
<p className={styles.cutoffCheckSub}>
|
||||
We measure a straight line from your postcode to the school and compare
|
||||
it with each year’s cut-off. Your postcode is not stored.
|
||||
</p>
|
||||
|
||||
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
|
||||
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
|
||||
<input
|
||||
id="cutoff-postcode"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="postal-code"
|
||||
spellCheck={false}
|
||||
placeholder="e.g. SE23 3NA"
|
||||
value={postcode}
|
||||
onChange={(e) => setPostcode(e.target.value)}
|
||||
className={styles.cutoffCheckInput}
|
||||
/>
|
||||
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
|
||||
{busy ? 'Checking…' : 'Check'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div className={styles.cutoffCheckResult} role="status">
|
||||
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
|
||||
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
|
||||
|
||||
<ul className={styles.cutoffYearStrip}>
|
||||
{[...result.years].reverse().map((y) => (
|
||||
<li
|
||||
key={y.year}
|
||||
className={`${styles.cutoffYearChip} ${VERDICT_CLASS[y.verdict]}`}
|
||||
title={VERDICT_TITLE[y.verdict]}
|
||||
>
|
||||
<span className={styles.cutoffYearChipYear}>{y.year}</span>
|
||||
<span aria-hidden="true">{VERDICT_MARK[y.verdict]}</span>
|
||||
<span className={styles.srOnly}>{VERDICT_TITLE[y.verdict]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CutoffYearTable — the last distance offered, year by year.
|
||||
*
|
||||
* The table is where the chart's gaps get explained. Each year says which of
|
||||
* the three things happened, in words, because "no figure" alone is the least
|
||||
* useful thing we could tell a parent about a year.
|
||||
*
|
||||
* Server component: it is static markup over data the page already holds.
|
||||
*/
|
||||
|
||||
import type { CutoffYearRow, CutoffYearStatus } from './lastDistanceOffered';
|
||||
import { sectionStyles as styles } from './sectionShared';
|
||||
|
||||
const STATUS_LABEL: Record<CutoffYearStatus, string> = {
|
||||
published: 'Oversubscribed',
|
||||
// Precisely what the EES field supports, and no more: first preferences did
|
||||
// not exceed places. It does not establish that every applicant was offered
|
||||
// a place, so it must not be worded as though it did.
|
||||
'not-oversubscribed': 'Places available on first preferences',
|
||||
'not-published': 'Not published',
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<CutoffYearStatus, string> = {
|
||||
published: styles.cutoffPillOver,
|
||||
'not-oversubscribed': styles.cutoffPillOk,
|
||||
'not-published': styles.cutoffPillNa,
|
||||
};
|
||||
|
||||
const DISTANCE_TEXT: Record<CutoffYearStatus, string> = {
|
||||
published: '',
|
||||
'not-oversubscribed': 'No cut-off needed',
|
||||
'not-published': '—',
|
||||
};
|
||||
|
||||
export function CutoffYearTable({ rows }: { rows: CutoffYearRow[] }) {
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const showPlaces = rows.some((r) => r.placesOffered != null);
|
||||
|
||||
return (
|
||||
<div className={styles.cutoffTableWrap}>
|
||||
<table className={styles.cutoffTable}>
|
||||
<caption className={styles.srOnly}>
|
||||
Last distance offered by year, with the reason where no figure was published
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Year</th>
|
||||
<th scope="col">Last distance</th>
|
||||
{showPlaces && <th scope="col">Places</th>}
|
||||
<th scope="col">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={r.year} className={i === 0 ? styles.cutoffRowLatest : undefined}>
|
||||
<th scope="row">{r.year}</th>
|
||||
<td className={r.status === 'published' ? undefined : styles.cutoffCellMissing}>
|
||||
{r.status === 'published' ? r.figure : DISTANCE_TEXT[r.status]}
|
||||
{r.status === 'published' && (r.routeCount ?? 1) > 1 && (
|
||||
<span className={styles.cutoffRouteFlag}>
|
||||
{' '}furthest of {r.routeCount}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{showPlaces && (
|
||||
<td className={r.placesOffered == null ? styles.cutoffCellMissing : undefined}>
|
||||
{r.placesOffered ?? '—'}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<span className={`${styles.cutoffPill} ${STATUS_CLASS[r.status]}`}>
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export interface PrimarySchoolSectionsProps {
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
@@ -43,7 +44,8 @@ export interface PrimarySchoolSectionsProps {
|
||||
|
||||
export function PrimarySchoolSections({
|
||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||
admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
|
||||
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
|
||||
deprivation, finance, nationalAvg, flags,
|
||||
}: PrimarySchoolSectionsProps) {
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
@@ -101,6 +103,8 @@ export function PrimarySchoolSections({
|
||||
admissions={admissions}
|
||||
admissionsHistory={admissionsHistory}
|
||||
admissionDistance={admissionDistance}
|
||||
admissionDistanceHistory={admissionDistanceHistory}
|
||||
schoolInfo={schoolInfo}
|
||||
isAllThrough={flags.isAllThrough}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,19 +9,27 @@
|
||||
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';
|
||||
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
|
||||
import {
|
||||
describeCutoff, cutoffYearRows, describeCutoffAbsence,
|
||||
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
|
||||
} from './lastDistanceOffered';
|
||||
|
||||
export function SecondaryAdmissionsSection({
|
||||
admissions, admissionDistance, schoolInfo, hasSixthForm,
|
||||
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
|
||||
schoolInfo, hasSixthForm,
|
||||
}: {
|
||||
/* 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;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||
schoolInfo: School;
|
||||
hasSixthForm: boolean;
|
||||
}) {
|
||||
const cutoff = describeCutoff(admissionDistance);
|
||||
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
|
||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
@@ -100,13 +108,19 @@ export function SecondaryAdmissionsSection({
|
||||
</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.
|
||||
{describeCutoffAbsence({
|
||||
localAuthority: schoolInfo.local_authority,
|
||||
admissionsPolicy: schoolInfo.admissions_policy,
|
||||
admissionsHistory,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Inline rather than behind a tab: this template is one flat panel by
|
||||
design and has no segmented control to hang a third view on. The
|
||||
caveat is suppressed because it is already directly above. */}
|
||||
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} showCaveat={false} />
|
||||
|
||||
{hasSixthForm && (
|
||||
<div className={styles.sixthFormNote}>
|
||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
||||
|
||||
@@ -32,7 +32,11 @@ export interface SecondarySchoolSectionsProps {
|
||||
ofsted: OfstedInspection | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
/** Needed to tell a year with no published cut-off apart from a year the
|
||||
* school simply was not oversubscribed. */
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
@@ -41,7 +45,8 @@ export interface SecondarySchoolSectionsProps {
|
||||
|
||||
export function SecondarySchoolSections({
|
||||
schoolInfo, yearlyData, ofsted, census,
|
||||
admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
|
||||
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
|
||||
deprivation, finance, nationalAvg, flags,
|
||||
}: SecondarySchoolSectionsProps) {
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
|
||||
@@ -86,6 +91,8 @@ export function SecondarySchoolSections({
|
||||
<SecondaryAdmissionsSection
|
||||
admissions={admissions}
|
||||
admissionDistance={admissionDistance}
|
||||
admissionDistanceHistory={admissionDistanceHistory}
|
||||
admissionsHistory={admissionsHistory}
|
||||
schoolInfo={schoolInfo}
|
||||
hasSixthForm={flags.hasSixthForm}
|
||||
/>
|
||||
|
||||
@@ -24,3 +24,8 @@ export const AdmissionsTrendChart = dynamic(
|
||||
() => import('../AdmissionsTrendChart'),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const CutoffTrendChart = dynamic(
|
||||
() => import('../CutoffTrendChart'),
|
||||
{ ssr: false },
|
||||
);
|
||||
@@ -59,3 +59,319 @@ export function describeCutoff(
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Year-by-year history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Why a year has no cut-off figure.
|
||||
*
|
||||
* The distinction matters more than the figure does. A missing year is not one
|
||||
* fact but three, and collapsing them into "no data" throws away the most
|
||||
* reassuring case (the school had places for everyone who put it first) and
|
||||
* the most important caveat (nobody published anything, so we cannot say).
|
||||
*/
|
||||
export type CutoffYearStatus =
|
||||
/** The authority published a cut-off for this year. */
|
||||
| 'published'
|
||||
/**
|
||||
* No cut-off published, and EES shows fewer first-preference applications
|
||||
* than places offered. Note the precise claim: this says the school was not
|
||||
* oversubscribed ON FIRST PREFERENCES, which is what the data supports. It
|
||||
* does NOT establish that every applicant was offered a place — total
|
||||
* applications can still exceed places — so no copy here may say so.
|
||||
*/
|
||||
| 'not-oversubscribed'
|
||||
/** Nothing published, and nothing in the admissions data to explain why. */
|
||||
| 'not-published';
|
||||
|
||||
export interface CutoffYearRow {
|
||||
year: number;
|
||||
status: CutoffYearStatus;
|
||||
/** Straight-line metres, present only when status is 'published'. */
|
||||
distanceM: number | null;
|
||||
/** Formatted figure, present only when status is 'published'. */
|
||||
figure: string | null;
|
||||
placesOffered: number | null;
|
||||
routeCount: number | null;
|
||||
}
|
||||
|
||||
interface AdmissionsYearLike {
|
||||
year: number;
|
||||
places_offered?: number | null;
|
||||
oversubscribed?: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* EES years are six-digit academic codes (202425); cut-off years are plain
|
||||
* entry years (2024). Both denote the same September intake, so they have to be
|
||||
* reduced to a common key before they can be matched.
|
||||
*/
|
||||
export function entryYearOf(year: number): number {
|
||||
const s = String(year);
|
||||
return s.length === 6 ? Number(s.slice(0, 4)) : year;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per year across the full span the school has any record for, newest
|
||||
* first. Years with no record at all still appear — an unbroken axis is what
|
||||
* makes a gap legible as a gap.
|
||||
*/
|
||||
export function cutoffYearRows(
|
||||
/* Defaulted, not merely optional: admission_distance_history is an additive
|
||||
API field, so a frontend deployed ahead of its backend receives undefined
|
||||
here. Defaulting keeps that a section that renders nothing rather than a
|
||||
page that throws. */
|
||||
history: SchoolAdmissionDistance[] = [],
|
||||
admissionsHistory: AdmissionsYearLike[] = [],
|
||||
): CutoffYearRow[] {
|
||||
const published = new Map<number, SchoolAdmissionDistance>();
|
||||
for (const d of history ?? []) {
|
||||
if (d.distance_m != null) published.set(entryYearOf(d.year), d);
|
||||
}
|
||||
|
||||
const admissions = new Map<number, AdmissionsYearLike>();
|
||||
for (const a of admissionsHistory ?? []) admissions.set(entryYearOf(a.year), a);
|
||||
|
||||
const years = [...published.keys(), ...admissions.keys()];
|
||||
if (years.length === 0) return [];
|
||||
|
||||
// The span is bounded by the cut-off data, not by admissions: EES reaches
|
||||
// back further than councils publish, and padding the chart with a decade of
|
||||
// "not published" would bury the years that carry a figure.
|
||||
const publishedYears = [...published.keys()];
|
||||
const from = publishedYears.length ? Math.min(...publishedYears) : Math.min(...years);
|
||||
const to = Math.max(...years);
|
||||
|
||||
const rows: CutoffYearRow[] = [];
|
||||
for (let y = to; y >= from; y -= 1) {
|
||||
const d = published.get(y);
|
||||
const a = admissions.get(y);
|
||||
|
||||
let status: CutoffYearStatus = 'not-published';
|
||||
if (d) status = 'published';
|
||||
else if (a?.oversubscribed === false) status = 'not-oversubscribed';
|
||||
|
||||
rows.push({
|
||||
year: y,
|
||||
status,
|
||||
distanceM: d?.distance_m ?? null,
|
||||
figure: d ? formatCutoffDistance(d.distance_m)?.primary ?? null : null,
|
||||
placesOffered: a?.places_offered ?? null,
|
||||
routeCount: d?.route_count ?? null,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* A factual summary of how the cut-off has moved.
|
||||
*
|
||||
* Deliberately not a verdict. It names both endpoints and their years and lets
|
||||
* the reader draw the conclusion, because the series is short, gappy, and
|
||||
* driven by things outside the school's control — one large sibling cohort
|
||||
* moves it. Withheld below four published points, where a swing between two
|
||||
* years is noise wearing the clothes of a trend.
|
||||
*/
|
||||
export function cutoffTrendSummary(rows: CutoffYearRow[]): string | null {
|
||||
const pts = rows.filter((r) => r.status === 'published' && r.distanceM != null);
|
||||
if (pts.length < 4) return null;
|
||||
|
||||
// rows are newest-first
|
||||
const latest = pts[0];
|
||||
const earliest = pts[pts.length - 1];
|
||||
const a = formatCutoffDistance(earliest.distanceM)?.primary;
|
||||
const b = formatCutoffDistance(latest.distanceM)?.primary;
|
||||
if (!a || !b) return null;
|
||||
|
||||
const change = latest.distanceM! - earliest.distanceM!;
|
||||
// A tenth of the earlier figure — below that the endpoints are effectively
|
||||
// the same and calling it a direction would be reading noise.
|
||||
const meaningful = Math.abs(change) > earliest.distanceM! * 0.1;
|
||||
|
||||
const direction = !meaningful
|
||||
? 'has stayed broadly the same'
|
||||
: change < 0
|
||||
? 'has tightened'
|
||||
: 'has widened';
|
||||
|
||||
return `Across ${pts.length} published years the cut-off ${direction}: `
|
||||
+ `${a} in ${earliest.year}, ${b} in ${latest.year}.`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Would we have got in?"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How close a home has to be to a year's cut-off before the comparison stops
|
||||
* meaning anything, in metres.
|
||||
*
|
||||
* A UK postcode unit covers roughly fifteen addresses and postcodes.io returns
|
||||
* its centroid, so the home point carries error of this order before anything
|
||||
* else is considered. Against a cut-off that is often only 500 m, that is a
|
||||
* fifth of the whole distance. Inside this band the honest answer is that we
|
||||
* cannot tell, and saying "you would have been offered a place" would be
|
||||
* inventing precision the inputs do not have.
|
||||
*/
|
||||
export const CUTOFF_UNCERTAINTY_M = 100;
|
||||
|
||||
export type CutoffVerdict = 'inside' | 'outside' | 'too-close' | 'unknown';
|
||||
|
||||
export interface CutoffYearComparison {
|
||||
year: number;
|
||||
status: CutoffYearStatus;
|
||||
verdict: CutoffVerdict;
|
||||
}
|
||||
|
||||
export interface CutoffCheckResult {
|
||||
/** Straight-line metres from the given postcode to the school. */
|
||||
distanceM: number;
|
||||
distanceLabel: string;
|
||||
years: CutoffYearComparison[];
|
||||
/** Years the home is clearly inside, out of those with a published figure. */
|
||||
insideCount: number;
|
||||
comparableCount: number;
|
||||
headline: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export function compareToCutoffs(
|
||||
distanceM: number,
|
||||
rows: CutoffYearRow[],
|
||||
): CutoffCheckResult {
|
||||
const years: CutoffYearComparison[] = rows.map((r) => {
|
||||
if (r.status !== 'published' || r.distanceM == null) {
|
||||
return { year: r.year, status: r.status, verdict: 'unknown' as const };
|
||||
}
|
||||
const margin = r.distanceM - distanceM;
|
||||
const verdict: CutoffVerdict =
|
||||
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
|
||||
return { year: r.year, status: r.status, verdict };
|
||||
});
|
||||
|
||||
const comparable = years.filter((y) => y.verdict !== 'unknown');
|
||||
const inside = comparable.filter((y) => y.verdict === 'inside');
|
||||
const tooClose = comparable.filter((y) => y.verdict === 'too-close');
|
||||
const label = formatCutoffDistance(distanceM)?.primary ?? `${Math.round(distanceM)} m`;
|
||||
|
||||
let headline: string;
|
||||
if (comparable.length === 0) {
|
||||
headline = `${label} from the school`;
|
||||
} else if (inside.length === comparable.length) {
|
||||
headline = `${label} away — inside the cut-off in all ${comparable.length} `
|
||||
+ `${comparable.length === 1 ? 'year' : 'years'} with a published figure`;
|
||||
} else if (inside.length === 0 && tooClose.length === 0) {
|
||||
headline = `${label} away — outside the cut-off in every year with a published figure`;
|
||||
} else {
|
||||
headline = `${label} away — inside the cut-off in ${inside.length} of `
|
||||
+ `${comparable.length} years with a published figure`;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (tooClose.length > 0) {
|
||||
parts.push(
|
||||
`${tooClose.length} ${tooClose.length === 1 ? 'year is' : 'years are'} too close to call: `
|
||||
+ 'your postcode is a centroid covering several addresses, so a margin under '
|
||||
+ `${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.`,
|
||||
);
|
||||
}
|
||||
const unknown = years.length - comparable.length;
|
||||
if (unknown > 0) {
|
||||
parts.push(`${unknown} ${unknown === 1 ? 'year has' : 'years have'} no published figure to compare against.`);
|
||||
}
|
||||
|
||||
return {
|
||||
distanceM,
|
||||
distanceLabel: label,
|
||||
years,
|
||||
insideCount: inside.length,
|
||||
comparableCount: comparable.length,
|
||||
headline,
|
||||
detail: parts.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The limits of the check, stated where a parent will act on it.
|
||||
*
|
||||
* Every clause is load-bearing. Distance is the last criterion applied, not the
|
||||
* first; the authority's own measurement may be a walking route rather than a
|
||||
* straight line, which is always longer for the same home; and a past cut-off
|
||||
* constrains next year's not at all.
|
||||
*/
|
||||
export const CUTOFF_CHECK_CAVEAT =
|
||||
'An indication only. Places go first to children in care, EHCP places, '
|
||||
+ 'siblings and — at faith schools — on faith criteria, so a home inside the '
|
||||
+ 'distance can still miss out. Some authorities measure a walking route '
|
||||
+ 'rather than a straight line, which is always longer for the same home, and '
|
||||
+ "next year's cut-off depends on next year's applicants. Always check the "
|
||||
+ "school's own admissions policy.";
|
||||
|
||||
/**
|
||||
* The rings are a drawing of a number, not a boundary anyone administers.
|
||||
*
|
||||
* The walking-route caveat belongs to CUTOFF_CHECK_CAVEAT immediately below
|
||||
* this on the page, which states it more usefully ("always longer for the same
|
||||
* home"). Saying it in both places read as a stutter.
|
||||
*/
|
||||
export const CUTOFF_MAP_CAVEAT =
|
||||
'Each ring is the straight-line cut-off for that year, drawn around the '
|
||||
+ 'school. It illustrates the distance — it is not a catchment boundary.';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// When there is no figure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AbsenceInput {
|
||||
localAuthority?: string | null;
|
||||
admissionsPolicy?: string | null;
|
||||
admissionsHistory?: AdmissionsYearLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this school has no cut-off distance, in the most useful terms available.
|
||||
*
|
||||
* "No data" is the least informative thing we could say, and for two of these
|
||||
* cases it is also the most pessimistic reading of a fact that is either
|
||||
* neutral or good news. The order matters: a selective school's absence is
|
||||
* explained by how it admits, which outranks anything the publication record
|
||||
* says.
|
||||
*/
|
||||
export function describeCutoffAbsence({
|
||||
localAuthority,
|
||||
admissionsPolicy,
|
||||
admissionsHistory = [],
|
||||
}: AbsenceInput): string {
|
||||
const policy = (admissionsPolicy ?? '').toLowerCase();
|
||||
if (policy.includes('selective')) {
|
||||
return 'Places at this school are ranked by the entrance test rather than by '
|
||||
+ 'distance, so no cut-off distance applies.';
|
||||
}
|
||||
|
||||
const known = admissionsHistory.filter((a) => a.oversubscribed != null);
|
||||
if (known.length >= 3 && known.every((a) => a.oversubscribed === false)) {
|
||||
return `First preferences have not exceeded places in any of the last ${known.length} `
|
||||
+ 'years, so this school has not needed a distance cut-off.';
|
||||
}
|
||||
|
||||
return (localAuthority
|
||||
? `${localAuthority} 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.';
|
||||
}
|
||||
|
||||
/**
|
||||
* A short note on how thin the record is.
|
||||
*
|
||||
* Four points is the same threshold the trend summary uses: below it the series
|
||||
* is too short to carry a direction, and saying so is more useful than leaving
|
||||
* the reader to count the rows.
|
||||
*/
|
||||
export function cutoffCoverageNote(rows: CutoffYearRow[]): string | null {
|
||||
const published = rows.filter((r) => r.status === 'published').length;
|
||||
if (published === 0 || published >= 4) return null;
|
||||
return `Only ${published} ${published === 1 ? 'year has' : 'years have'} a published figure, `
|
||||
+ 'which is too few to read as a trend.';
|
||||
}
|
||||
@@ -2064,3 +2064,383 @@
|
||||
.secondaryScope .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
|
||||
.secondaryScope .admissionsTypeBadge { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
Last distance offered — the Distance view, map and postcode check
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Visually hidden but still read aloud. The year strip encodes its verdict as
|
||||
a glyph, so every chip carries a spelled-out equivalent here — a screen
|
||||
reader announcing "2025 ✓" tells you nothing. */
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cutoffChartKey {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
/* ── Year-by-year table ── */
|
||||
|
||||
.cutoffTableWrap {
|
||||
margin-top: 1.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cutoffTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
/* Below this the four columns crush; the wrapper scrolls instead of the
|
||||
page, so the section itself never scrolls sideways. */
|
||||
min-width: 26rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cutoffTable thead th {
|
||||
text-align: right;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 0.625rem 0.55rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cutoffTable thead th:first-child,
|
||||
.cutoffTable tbody th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cutoffTable tbody td,
|
||||
.cutoffTable tbody th {
|
||||
text-align: right;
|
||||
padding: 0.65rem 0.625rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cutoffTable tbody th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cutoffTable tbody tr:last-child td,
|
||||
.cutoffTable tbody tr:last-child th {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cutoffRowLatest td,
|
||||
.cutoffRowLatest th {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.cutoffCellMissing {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cutoffRouteFlag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cutoffPill {
|
||||
display: inline-block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cutoffPillOver {
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.cutoffPillOk {
|
||||
background: var(--status-above-bg);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.cutoffPillNa {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Map panel ── */
|
||||
|
||||
.cutoffMapPanel {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cutoffMapTitle {
|
||||
margin: 0 0 0.85rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cutoffMapFigure {
|
||||
height: 320px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.cutoffMapSkeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.cutoffMapLegend {
|
||||
list-style: none;
|
||||
margin: 0.85rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cutoffMapLegend li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.cutoffSwatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.cutoffSwatchNow {
|
||||
background: var(--brand-bg);
|
||||
box-shadow: inset 0 0 0 2px var(--brand);
|
||||
}
|
||||
|
||||
.cutoffSwatchPast {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1.5px var(--brand);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cutoffSwatchHome {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--status-above);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.cutoffMapCaveat {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
/* ── Postcode check ── */
|
||||
|
||||
.cutoffCheck {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cutoffCheckTitle {
|
||||
margin: 0 0 0.25rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cutoffCheckSub {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.cutoffCheckForm {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cutoffCheckInput {
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
/* Grows into the row but never past it — at 320px a fixed width would push
|
||||
the button out of the viewport. */
|
||||
flex: 1 1 9rem;
|
||||
/* Stops well short of full width on desktop, where a postcode field
|
||||
spanning the whole card reads as a search box. */
|
||||
max-width: 16rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cutoffCheckInput::placeholder {
|
||||
text-transform: none;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cutoffCheckInput:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.cutoffCheckButton {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.cutoffCheckButton:hover:not(:disabled) {
|
||||
background: var(--brand-strong);
|
||||
}
|
||||
|
||||
.cutoffCheckButton:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.cutoffCheckButton:focus-visible {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cutoffCheckError {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.cutoffCheckResult {
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--bg-card);
|
||||
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
|
||||
}
|
||||
|
||||
.cutoffCheckHeadline {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: var(--text-primary);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.cutoffCheckDetail {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cutoffYearStrip {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.cutoffYearChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cutoffYearChipYear {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cutoffYearInside {
|
||||
background: var(--status-above-bg);
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.cutoffYearOutside {
|
||||
background: var(--status-below-bg);
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
/* Deliberately neutral rather than a warning colour: "we cannot tell" is not a
|
||||
bad outcome, and tinting it amber would read as one. */
|
||||
.cutoffYearTooClose {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
}
|
||||
|
||||
.cutoffYearUnknown {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cutoffCheckCaveat {
|
||||
margin: 0.9rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cutoffMapFigure {
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,12 @@ export interface SchoolDetailsResponse {
|
||||
admissions_history: SchoolAdmissions[];
|
||||
/** Latest published cut-off distance, or null where the LA publishes none. */
|
||||
admission_distance: SchoolAdmissionDistance | null;
|
||||
/**
|
||||
* Every published cut-off year, oldest first. Sparse by nature — a school may
|
||||
* have 2021 and 2026 and nothing between, so consumers must classify the
|
||||
* missing years rather than joining the points they do have.
|
||||
*/
|
||||
admission_distance_history: SchoolAdmissionDistance[];
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user