feat(admissions): add the cut-off history, map and postcode check #102
No files matched your search
@@ -8,3 +8,6 @@ pipeline/transform/.user.yml
|
|||||||
|
|
||||||
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
||||||
.playwright-mcp/
|
.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": supplementary.get("admissions"),
|
||||||
"admissions_history": supplementary.get("admissions_history") or [],
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
"admission_distance": supplementary.get("admission_distance"),
|
"admission_distance": supplementary.get("admission_distance"),
|
||||||
|
"admission_distance_history": supplementary.get("admission_distance_history") or [],
|
||||||
"sen_detail": supplementary.get("sen_detail"),
|
"sen_detail": supplementary.get("sen_detail"),
|
||||||
"phonics": supplementary.get("phonics"),
|
"phonics": supplementary.get("phonics"),
|
||||||
"deprivation": supplementary.get("deprivation"),
|
"deprivation": supplementary.get("deprivation"),
|
||||||
|
|||||||
+16
-11
@@ -771,6 +771,7 @@ def _empty_supplementary() -> dict:
|
|||||||
"admissions": None,
|
"admissions": None,
|
||||||
"admissions_history": [],
|
"admissions_history": [],
|
||||||
"admission_distance": None,
|
"admission_distance": None,
|
||||||
|
"admission_distance_history": [],
|
||||||
"sen_detail": None,
|
"sen_detail": None,
|
||||||
"phonics": None,
|
"phonics": None,
|
||||||
"deprivation": 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
|
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||||
_safe(_admissions)
|
_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
|
# The history was deliberately withheld at first: coverage is ragged (a
|
||||||
# have 2021 and 2026 but nothing between), so a history array would invite
|
# school may have 2021 and 2026 and nothing between), and a plain series
|
||||||
# a trend line drawn through gaps that are absences of publication rather
|
# would draw a trend line straight through gaps that are absences of
|
||||||
# than absences of a cut-off.
|
# 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():
|
def _admission_distance():
|
||||||
rows = (
|
rows = (
|
||||||
db.query(FactAdmissionDistance)
|
db.query(FactAdmissionDistance)
|
||||||
.filter(FactAdmissionDistance.urn.in_(urns))
|
.filter(FactAdmissionDistance.urn.in_(urns))
|
||||||
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
|
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
seen = set()
|
history: dict = {urn: [] for urn in urns}
|
||||||
for d in rows:
|
for d in rows:
|
||||||
if d.urn in seen:
|
history[d.urn].append(_admission_distance_dict(d))
|
||||||
continue
|
for urn, rows_for_urn in history.items():
|
||||||
seen.add(d.urn)
|
result[urn]["admission_distance_history"] = rows_for_urn
|
||||||
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
|
result[urn]["admission_distance"] = rows_for_urn[-1] if rows_for_urn else None
|
||||||
_safe(_admission_distance)
|
_safe(_admission_distance)
|
||||||
|
|
||||||
# Deprivation — one row per URN.
|
# Deprivation — one row per URN.
|
||||||
|
|||||||
@@ -8,8 +8,31 @@ from backend import data_loader
|
|||||||
from backend.data_loader import get_supplementary_data_batch
|
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:
|
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):
|
def __init__(self, recorder, model_name, rows):
|
||||||
self._rec = recorder
|
self._rec = recorder
|
||||||
@@ -19,7 +42,20 @@ class _FakeQuery:
|
|||||||
def filter(self, *args, **kwargs):
|
def filter(self, *args, **kwargs):
|
||||||
return self
|
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
|
return self
|
||||||
|
|
||||||
def all(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[1]["admissions"]["year"] == 202627
|
||||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||||
|
|
||||||
# Cut-off distance: latest year only, per URN. Coverage is ragged, so
|
# Cut-off distance: full history oldest-first, with the newest year also
|
||||||
# serving anything but the newest would show a parent a stale figure.
|
# 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"]["year"] == 2026
|
||||||
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
||||||
# route_count travels with the figure — the page needs it to say the
|
# 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["ofsted"]["overall_effectiveness"] == 2
|
||||||
assert single["admissions_history"] == []
|
assert single["admissions_history"] == []
|
||||||
assert single["admission_distance"] is None
|
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
|
101099, 100553, 102574, 100769, // mixed
|
||||||
];
|
];
|
||||||
|
|
||||||
async function schoolWithCutoff(page: Page) {
|
async function schoolWithCutoff(page: Page, minPublishedYears = 1) {
|
||||||
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
||||||
const res = await page.request.get(`/api/schools/${urn}`);
|
const res = await page.request.get(`/api/schools/${urn}`);
|
||||||
if (!res.ok()) continue;
|
if (!res.ok()) continue;
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body?.admission_distance?.distance_m != null) {
|
if (body?.admission_distance?.distance_m == null) continue;
|
||||||
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
|
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;
|
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');
|
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.
|
* 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 { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
||||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
||||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
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();
|
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 { 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';
|
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||||
|
|
||||||
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
||||||
@@ -78,3 +81,151 @@ describe('describeCutoff', () => {
|
|||||||
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
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
|
<PrimarySchoolSections
|
||||||
{...fixture}
|
{...fixture}
|
||||||
|
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||||
nationalAvg={nationalAveragesFixture}
|
nationalAvg={nationalAveragesFixture}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
/>
|
/>
|
||||||
@@ -72,6 +73,8 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
|||||||
>
|
>
|
||||||
<SecondarySchoolSections
|
<SecondarySchoolSections
|
||||||
{...fixture}
|
{...fixture}
|
||||||
|
admissionsHistory={fixture.admissionsHistory ?? []}
|
||||||
|
admissionDistanceHistory={fixture.admissionDistanceHistory ?? []}
|
||||||
nationalAvg={nationalAveragesFixture}
|
nationalAvg={nationalAveragesFixture}
|
||||||
flags={flags}
|
flags={flags}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, 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
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -229,7 +229,9 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
|
admissionsHistory={admissions_history ?? []}
|
||||||
admissionDistance={admission_distance ?? null}
|
admissionDistance={admission_distance ?? null}
|
||||||
|
admissionDistanceHistory={admission_distance_history ?? []}
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
@@ -252,6 +254,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
admissionDistance={admission_distance ?? null}
|
admissionDistance={admission_distance ?? null}
|
||||||
|
admissionDistanceHistory={admission_distance_history ?? []}
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
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 { 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 { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
|
||||||
import { AdmissionsTrendChart } from './charts';
|
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({
|
export function AdmissionsSection({
|
||||||
admissions,
|
admissions,
|
||||||
admissionsHistory,
|
admissionsHistory,
|
||||||
admissionDistance,
|
admissionDistance,
|
||||||
|
admissionDistanceHistory,
|
||||||
|
schoolInfo,
|
||||||
isAllThrough,
|
isAllThrough,
|
||||||
}: {
|
}: {
|
||||||
/* Nullable: the two data sources are independent. A minority of schools have
|
/* Nullable: the two data sources are independent. A minority of schools have
|
||||||
@@ -34,9 +40,18 @@ export function AdmissionsSection({
|
|||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
|
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||||
|
schoolInfo: School;
|
||||||
isAllThrough: boolean;
|
isAllThrough: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cutoff = describeCutoff(admissionDistance);
|
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.
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||||
@@ -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 (
|
return (
|
||||||
<Section id="admissions">
|
<Section id="admissions">
|
||||||
{showAdmissionsTrend ? (
|
{views.length > 1 ? (
|
||||||
<AdmissionsViewToggle
|
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
trendLabel={`${admissionsHistory.length}-year trend`}
|
|
||||||
yearView={yearView}
|
|
||||||
trendView={trendView}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
/* 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}>
|
<div className={styles.admissionsHeader}>
|
||||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||||
|
|||||||
@@ -3,14 +3,25 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import styles from './schoolSections.module.css';
|
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
|
* The only interactive part of the primary admissions section, and the only
|
||||||
* client component in components/school/.
|
* client component in components/school/.
|
||||||
*
|
*
|
||||||
* Both views are always present in the DOM and visibility is toggled with the
|
* Every view is always present in the DOM and visibility is toggled with the
|
||||||
* `hidden` attribute — matching the previous behaviour exactly — so the
|
* `hidden` attribute, so the server-rendered markup passed in as content never
|
||||||
* server-rendered markup passed in as yearView/trendView never ships as client
|
* ships as client JavaScript — this component carries the state and nothing
|
||||||
* JavaScript.
|
* else.
|
||||||
*
|
*
|
||||||
* It spans the header and the viewport because the segmented control sits
|
* It spans the header and the viewport because the segmented control sits
|
||||||
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
|
* inside .admissionsHeader beside the <h2> while the viewport is a sibling
|
||||||
@@ -19,39 +30,38 @@ import styles from './schoolSections.module.css';
|
|||||||
export function AdmissionsViewToggle({
|
export function AdmissionsViewToggle({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
trendLabel,
|
views,
|
||||||
yearView,
|
|
||||||
trendView,
|
|
||||||
}: {
|
}: {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
subtitle: ReactNode;
|
subtitle: ReactNode;
|
||||||
trendLabel: string;
|
views: AdmissionsView[];
|
||||||
yearView: ReactNode;
|
|
||||||
trendView: ReactNode;
|
|
||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState<'year' | 'trend'>('year');
|
const [active, setActive] = useState(views[0]?.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.admissionsHeader}>
|
<div className={styles.admissionsHeader}>
|
||||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||||
<button type="button" aria-pressed={view === 'year'} onClick={() => setView('year')}>
|
{views.map((v) => (
|
||||||
This year
|
<button
|
||||||
</button>
|
key={v.id}
|
||||||
<button type="button" aria-pressed={view === 'trend'} onClick={() => setView('trend')}>
|
type="button"
|
||||||
{trendLabel}
|
aria-pressed={active === v.id}
|
||||||
</button>
|
onClick={() => setActive(v.id)}
|
||||||
|
>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
<div className={styles.admissionsViewport}>
|
<div className={styles.admissionsViewport}>
|
||||||
<div className={styles.admissionsViewYear} hidden={view !== 'year'}>
|
{views.map((v) => (
|
||||||
{yearView}
|
<div key={v.id} className={v.className} hidden={active !== v.id}>
|
||||||
</div>
|
{v.content}
|
||||||
<div className={styles.admissionsViewTrend} hidden={view !== 'trend'}>
|
</div>
|
||||||
{trendView}
|
))}
|
||||||
</div>
|
|
||||||
</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;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
|
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -43,7 +44,8 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function PrimarySchoolSections({
|
export function PrimarySchoolSections({
|
||||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
|
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
|
||||||
|
deprivation, finance, nationalAvg, flags,
|
||||||
}: PrimarySchoolSectionsProps) {
|
}: PrimarySchoolSectionsProps) {
|
||||||
const primaryAvg = nationalAvg?.primary ?? {};
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
@@ -101,6 +103,8 @@ export function PrimarySchoolSections({
|
|||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionsHistory={admissionsHistory}
|
admissionsHistory={admissionsHistory}
|
||||||
admissionDistance={admissionDistance}
|
admissionDistance={admissionDistance}
|
||||||
|
admissionDistanceHistory={admissionDistanceHistory}
|
||||||
|
schoolInfo={schoolInfo}
|
||||||
isAllThrough={flags.isAllThrough}
|
isAllThrough={flags.isAllThrough}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,19 +9,27 @@
|
|||||||
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
||||||
import { formatPercentage } from '@/lib/utils';
|
import { formatPercentage } from '@/lib/utils';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
|
||||||
|
import {
|
||||||
|
describeCutoff, cutoffYearRows, describeCutoffAbsence,
|
||||||
|
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
|
||||||
|
} from './lastDistanceOffered';
|
||||||
|
|
||||||
export function SecondaryAdmissionsSection({
|
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
|
/* Nullable for the same reason as the primary section: a school can have a
|
||||||
published cut-off and no EES admissions row. */
|
published cut-off and no EES admissions row. */
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
|
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||||
schoolInfo: School;
|
schoolInfo: School;
|
||||||
hasSixthForm: boolean;
|
hasSixthForm: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cutoff = describeCutoff(admissionDistance);
|
const cutoff = describeCutoff(admissionDistance);
|
||||||
|
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
|
||||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||||
const admissionsTag = (() => {
|
const admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
@@ -100,13 +108,19 @@ export function SecondaryAdmissionsSection({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||||
{schoolInfo.local_authority
|
{describeCutoffAbsence({
|
||||||
? `${schoolInfo.local_authority} has not published a cut-off distance for this school.`
|
localAuthority: schoolInfo.local_authority,
|
||||||
: 'No cut-off distance has been published for this school.'}{' '}
|
admissionsPolicy: schoolInfo.admissions_policy,
|
||||||
Contact the admissions authority for its oversubscription criteria.
|
admissionsHistory,
|
||||||
|
})}
|
||||||
</p>
|
</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 && (
|
{hasSixthForm && (
|
||||||
<div className={styles.sixthFormNote}>
|
<div className={styles.sixthFormNote}>
|
||||||
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
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;
|
ofsted: OfstedInspection | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | 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;
|
admissionDistance: SchoolAdmissionDistance | null;
|
||||||
|
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -41,7 +45,8 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function SecondarySchoolSections({
|
export function SecondarySchoolSections({
|
||||||
schoolInfo, yearlyData, ofsted, census,
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
|
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
|
||||||
|
deprivation, finance, nationalAvg, flags,
|
||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
@@ -86,6 +91,8 @@ export function SecondarySchoolSections({
|
|||||||
<SecondaryAdmissionsSection
|
<SecondaryAdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionDistance={admissionDistance}
|
admissionDistance={admissionDistance}
|
||||||
|
admissionDistanceHistory={admissionDistanceHistory}
|
||||||
|
admissionsHistory={admissionsHistory}
|
||||||
schoolInfo={schoolInfo}
|
schoolInfo={schoolInfo}
|
||||||
hasSixthForm={flags.hasSixthForm}
|
hasSixthForm={flags.hasSixthForm}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ export const AdmissionsTrendChart = dynamic(
|
|||||||
() => import('../AdmissionsTrendChart'),
|
() => import('../AdmissionsTrendChart'),
|
||||||
{ ssr: false },
|
{ ssr: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const CutoffTrendChart = dynamic(
|
||||||
|
() => import('../CutoffTrendChart'),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
@@ -59,3 +59,319 @@ export function describeCutoff(
|
|||||||
: null,
|
: 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 .ofstedReportLink { margin-left: 0; display: block; margin-top: 0.25rem; }
|
||||||
.secondaryScope .admissionsTypeBadge { font-size: 0.75rem; }
|
.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[];
|
admissions_history: SchoolAdmissions[];
|
||||||
/** Latest published cut-off distance, or null where the LA publishes none. */
|
/** Latest published cut-off distance, or null where the LA publishes none. */
|
||||||
admission_distance: SchoolAdmissionDistance | null;
|
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;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user