feat(admissions): publish the latest cut-off only, holding history back
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Earlier years are to become a paid feature, so they stop being published.
The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.
Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.
What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.
Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.
One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.
The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
This commit is contained in:
1 parent
50b599a09b
commit
c9a1892bfb
20 files changed
+382
-1215
No files matched your search
@@ -3,17 +3,17 @@
|
||||
*
|
||||
* 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.
|
||||
* refuses to say — and that matters more now than it did, because there is only
|
||||
* one year to answer with. A run of years used to soften a single close call;
|
||||
* nothing does now, so the "too close to call" band is the whole safety margin.
|
||||
*/
|
||||
|
||||
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 { 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" />,
|
||||
@@ -27,7 +27,7 @@ jest.mock('@/lib/api', () => ({
|
||||
|
||||
const SCHOOL = { urn: 100010, school_name: 'Test Primary', latitude: 51.5, longitude: -0.12 } as School;
|
||||
|
||||
const d = (year: number, distance_m: number): SchoolAdmissionDistance => ({
|
||||
const cutoff = (distance_m: number | null, year = 2026): SchoolAdmissionDistance => ({
|
||||
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
|
||||
});
|
||||
|
||||
@@ -36,11 +36,8 @@ 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)} />,
|
||||
);
|
||||
}
|
||||
const renderPanel = (c = cutoff(800)) =>
|
||||
render(<CutoffMapPanel schoolInfo={SCHOOL} cutoff={c} />);
|
||||
|
||||
async function check(postcode: string) {
|
||||
fireEvent.change(screen.getByLabelText('Your postcode'), { target: { value: postcode } });
|
||||
@@ -50,8 +47,8 @@ async function check(postcode: string) {
|
||||
beforeEach(() => mockGeocode.mockReset());
|
||||
|
||||
describe('CutoffMapPanel', () => {
|
||||
it('renders nothing without a published figure to draw', () => {
|
||||
const { container } = renderPanel([]);
|
||||
it('renders nothing without a figure to compare against', () => {
|
||||
const { container } = renderPanel(cutoff(null));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
@@ -59,61 +56,58 @@ describe('CutoffMapPanel', () => {
|
||||
const { container } = render(
|
||||
<CutoffMapPanel
|
||||
schoolInfo={{ ...SCHOOL, latitude: null, longitude: null } as School}
|
||||
rows={cutoffYearRows([d(2024, 800)])}
|
||||
cutoff={cutoff(800)}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('rejects a malformed postcode without calling the geocoder', async () => {
|
||||
renderPanel([d(2024, 800)]);
|
||||
renderPanel();
|
||||
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 () => {
|
||||
it('names the year in the verdict, so the figure is never free-floating', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel([d(2022, 1000), d(2024, 800)]);
|
||||
renderPanel(cutoff(800, 2026));
|
||||
await check('SE23 3NA');
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/inside the cut-off in all 2 years/);
|
||||
const result = await screen.findByRole('status');
|
||||
expect(result).toHaveTextContent(/inside the/);
|
||||
expect(result).toHaveTextContent(/September 2026/);
|
||||
});
|
||||
|
||||
it('reports a home clearly outside every published year', async () => {
|
||||
it('reports a home clearly beyond the cut-off', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(5000));
|
||||
renderPanel([d(2022, 1000), d(2024, 800)]);
|
||||
renderPanel(cutoff(800));
|
||||
await check('SE23 3NA');
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/outside the cut-off in every year/);
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/beyond the/);
|
||||
});
|
||||
|
||||
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.
|
||||
// Nominally inside the 800 m cut-off, but by half the uncertainty band —
|
||||
// which a postcode centroid cannot resolve. With only one year published
|
||||
// there is nothing else to fall back on, so this must not read as a pass.
|
||||
mockGeocode.mockResolvedValue(northOf(800 - CUTOFF_UNCERTAINTY_M / 2));
|
||||
renderPanel([d(2024, 800)]);
|
||||
renderPanel(cutoff(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/);
|
||||
expect(result).toHaveTextContent(/too close/);
|
||||
expect(result).toHaveTextContent(/measurement error/);
|
||||
// Explanation is supporting text, not part of the bold verdict line.
|
||||
expect(result.querySelector('[class*="cutoffCheckHeadline"]')!.textContent)
|
||||
.not.toMatch(/measurement error/);
|
||||
expect(result).not.toHaveTextContent(/^\S+ away — inside/);
|
||||
});
|
||||
|
||||
it('surfaces a postcode the geocoder cannot find', async () => {
|
||||
mockGeocode.mockResolvedValue(null);
|
||||
renderPanel([d(2024, 800)]);
|
||||
renderPanel();
|
||||
await check('ZZ99 9ZZ');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/could not find that postcode/);
|
||||
@@ -121,7 +115,7 @@ describe('CutoffMapPanel', () => {
|
||||
|
||||
it('recovers from a geocoder failure instead of leaving a stale verdict', async () => {
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
renderPanel([d(2024, 800)]);
|
||||
renderPanel();
|
||||
await check('SE23 3NA');
|
||||
await screen.findByRole('status');
|
||||
|
||||
@@ -132,21 +126,10 @@ describe('CutoffMapPanel', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
|
||||
});
|
||||
|
||||
it('does not carry the caveat itself', () => {
|
||||
// It moved to CutoffDistanceDetail so it renders once per section, and so
|
||||
// it still appears for a school with no coordinates (no map, no check).
|
||||
// Asserting its absence here is what stops the old three-paragraph stutter
|
||||
// creeping back.
|
||||
renderPanel([d(2024, 800)]);
|
||||
expect(screen.queryByText(/Distance is the last criterion/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the map behind a request until there is a reason to show it', async () => {
|
||||
renderPanel([d(2024, 800)]);
|
||||
renderPanel();
|
||||
expect(screen.queryByTestId('cutoff-map')).not.toBeInTheDocument();
|
||||
|
||||
// A successful check is that reason: the rings only answer a question once
|
||||
// there is a home to sit beside them.
|
||||
mockGeocode.mockResolvedValue(northOf(200));
|
||||
await check('SE23 3NA');
|
||||
await screen.findByRole('status');
|
||||
@@ -154,8 +137,16 @@ describe('CutoffMapPanel', () => {
|
||||
});
|
||||
|
||||
it('can also show the map without a postcode, on request', () => {
|
||||
renderPanel([d(2024, 800)]);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Show these distances on a map/ }));
|
||||
renderPanel();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Show this distance on a map/ }));
|
||||
expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('states its limits before it is used, not with the answer', () => {
|
||||
renderPanel();
|
||||
const caveat = screen.getByText(/Distance is the last criterion applied/);
|
||||
expect(caveat).toBeInTheDocument();
|
||||
expect(caveat).toHaveTextContent(/not a catchment boundary/);
|
||||
expect(caveat).toHaveTextContent(/walking route/);
|
||||
});
|
||||
});
|
||||
@@ -89,128 +89,68 @@ describe('secondary detail page', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── The Distance view ──────────────────────────────────────────────────
|
||||
// ── The Distance section ───────────────────────────────────────────────
|
||||
|
||||
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 section', () => {
|
||||
it('appears as its own section once there are two or more published years', () => {
|
||||
// Not a third tab inside Admissions: stacking a 1402px view in the
|
||||
// admissions viewport sized the whole card to it and left the default view
|
||||
// as four tiles in ~1080px of blank card.
|
||||
describe('Distance section', () => {
|
||||
it('appears for a school with a figure and coordinates', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2021, 1000], [2022, 900], [2023, 800], [2024, 700]]),
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).toBeInTheDocument();
|
||||
expect(screen.getByText('How far the last place went')).toBeInTheDocument();
|
||||
// And it did not come back as a tab.
|
||||
expect(screen.queryByRole('button', { name: 'Distance' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('How far away are you?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays away 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.
|
||||
it('stays away when the school has no coordinates to measure from', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2024, 700]]),
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: null, longitude: null },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(container.querySelector('#distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every year in the span, including the ones with no figure', () => {
|
||||
renderSchoolDetail({
|
||||
it('stays away when no figure has been published', () => {
|
||||
const { container } = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: null,
|
||||
});
|
||||
|
||||
// 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);
|
||||
expect(container.querySelector('#distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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('draws the chart only once it will also state a direction', () => {
|
||||
const four = renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2021, 1000], [2022, 900], [2023, 800], [2024, 700]]),
|
||||
});
|
||||
expect(screen.getByText(/Last distance offered, by year/)).toBeInTheDocument();
|
||||
four.unmount();
|
||||
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2023, 800], [2024, 700]]),
|
||||
});
|
||||
expect(screen.queryByText(/Last distance offered, by year/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('states the caveat exactly once, even though the check also renders', () => {
|
||||
it('shows no year-by-year record — that is held back as a paid feature', () => {
|
||||
// The page must not leak the history through a table, a chart or a strip of
|
||||
// per-year verdicts. The API no longer sends it either; this guards the
|
||||
// render side so a future component cannot quietly put it back.
|
||||
renderSchoolDetail({
|
||||
...primaryFixture,
|
||||
schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
|
||||
admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
|
||||
admissionDistance: cutoff({ distance_m: 700, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/Distance is the last criterion/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
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();
|
||||
// Scoped to the section: the page has other tables (the history section's).
|
||||
const section = document.querySelector('#distance')!;
|
||||
expect(section.querySelector('table')).toBeNull();
|
||||
expect(screen.queryByText(/Last distance offered, by year/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Not published/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/too few to read as a trend/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondary Distance section', () => {
|
||||
it('renders as its own section once there are two published years', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
it('renders on the secondary template too', () => {
|
||||
const { container } = renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
admissionDistance: cutoff({ distance_m: 3472.96 }),
|
||||
admissionDistanceHistory: history([[2023, 3800], [2024, 3472.96]]),
|
||||
schoolInfo: { ...secondaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
|
||||
admissionDistance: cutoff({ distance_m: 3472.96, year: 2026 }),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('rowheader', { name: '2023' })).toBeInTheDocument();
|
||||
// Two points is below the threshold that lets us state a direction, so no
|
||||
// chart is drawn — a line through three points asserts a trend the
|
||||
// summary underneath would refuse to.
|
||||
expect(screen.queryByText(/Last distance offered, by year/)).not.toBeInTheDocument();
|
||||
expect(container.querySelector('#distance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains a selective school by how it admits rather than as missing data', () => {
|
||||
@@ -218,7 +158,6 @@ describe('secondary Distance section', () => {
|
||||
...secondaryFixture,
|
||||
schoolInfo: { ...secondaryFixture.schoolInfo, admissions_policy: 'Selective' },
|
||||
admissionDistance: null,
|
||||
admissionDistanceHistory: [],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/ranked by the entrance test/)).toBeInTheDocument();
|
||||
@@ -229,7 +168,6 @@ describe('secondary Distance section', () => {
|
||||
renderSecondarySchoolDetail({
|
||||
...secondaryFixture,
|
||||
admissionDistance: null,
|
||||
admissionDistanceHistory: [],
|
||||
admissionsHistory: [
|
||||
{ year: 2022, oversubscribed: false },
|
||||
{ year: 2023, oversubscribed: false },
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
|
||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
||||
import {
|
||||
describeCutoff, cutoffYearRows, cutoffTrendSummary, cutoffCoverageNote,
|
||||
describeCutoffAbsence, compareToCutoffs, entryYearOf, CUTOFF_UNCERTAINTY_M,
|
||||
describeCutoff, describeCutoffAbsence, compareToCutoff, CUTOFF_UNCERTAINTY_M,
|
||||
} from '@/components/school/lastDistanceOffered';
|
||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
||||
|
||||
@@ -82,97 +81,42 @@ describe('describeCutoff', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Year-by-year history ───────────────────────────────────────────────
|
||||
// ── "Would we have got in?" ────────────────────────────────────────────
|
||||
|
||||
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',
|
||||
]);
|
||||
describe('compareToCutoff', () => {
|
||||
it('calls a clearly nearer home inside, and names the year', () => {
|
||||
const r = compareToCutoff(300, 800, 2026);
|
||||
expect(r.verdict).toBe('inside');
|
||||
expect(r.headline).toContain('September 2026');
|
||||
});
|
||||
|
||||
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('calls a clearly further home beyond', () => {
|
||||
expect(compareToCutoff(4000, 800, 2026).verdict).toBe('outside');
|
||||
});
|
||||
|
||||
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('refuses to call a result inside the measurement error, either way', () => {
|
||||
// A postcode centroid covers several addresses, so a margin this fine is
|
||||
// noise. With one published year there is no other year to fall back on,
|
||||
// which makes this band the only thing standing between a parent and a
|
||||
// place they do not have.
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800 + CUTOFF_UNCERTAINTY_M / 2, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800, 800, 2026).detail).toContain('measurement error');
|
||||
// The explanation is not welded to the headline, so it does not run at
|
||||
// headline weight in the result block.
|
||||
expect(compareToCutoff(800, 800, 2026).headline).not.toContain('measurement error');
|
||||
expect(compareToCutoff(300, 800, 2026).detail).toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
it('treats the band as exclusive at its edge', () => {
|
||||
// Exactly on the boundary is still too close; one metre past it is not.
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M, 800, 2026).verdict).toBe('too-close');
|
||||
expect(compareToCutoff(800 - CUTOFF_UNCERTAINTY_M - 1, 800, 2026).verdict).toBe('inside');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCutoffAbsence', () => {
|
||||
it('explains a selective school by how it admits, not by missing data', () => {
|
||||
it('explains a selective school by how it admits, not as missing data', () => {
|
||||
const s = describeCutoffAbsence({ localAuthority: 'Kent', admissionsPolicy: 'Selective' });
|
||||
expect(s).toContain('entrance test');
|
||||
expect(s).not.toContain('has not published');
|
||||
@@ -195,37 +139,3 @@ describe('describeCutoffAbsence', () => {
|
||||
.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');
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, admission_distance_history, deprivation, finance } = data;
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -177,8 +177,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
ofsted: ofsted ?? null,
|
||||
admissions: admissions ?? null,
|
||||
admissionDistance: admission_distance ?? null,
|
||||
admissionDistanceHistory: admission_distance_history ?? [],
|
||||
admissionsHistory: admissions_history ?? [],
|
||||
hasLocation: school_info.latitude != null && school_info.longitude != null,
|
||||
yearlyDataLength: yearly_data.length,
|
||||
};
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
@@ -233,7 +232,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
admissionDistanceHistory={admission_distance_history ?? []}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
@@ -256,7 +254,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
admissionDistanceHistory={admission_distance_history ?? []}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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_CHECK_CAVEAT,
|
||||
type CutoffYearRow,
|
||||
} from './lastDistanceOffered';
|
||||
|
||||
export function CutoffDistanceDetail({
|
||||
rows,
|
||||
schoolInfo,
|
||||
}: {
|
||||
rows: CutoffYearRow[];
|
||||
schoolInfo: School;
|
||||
}) {
|
||||
const publishedYears = rows.filter((r) => r.status === 'published').length;
|
||||
if (publishedYears < 2) return null;
|
||||
|
||||
const trendSummary = cutoffTrendSummary(rows);
|
||||
const coverageNote = cutoffCoverageNote(rows);
|
||||
/*
|
||||
* The chart appears at the same four points that let cutoffTrendSummary
|
||||
* state a direction. Below that we already refuse to call the series a
|
||||
* trend, and drawing a trend line under that refusal contradicts it — three
|
||||
* points joined by a line say "look, it is falling" whatever the sentence
|
||||
* beneath admits. The table carries every one of those years anyway, with
|
||||
* the reasons a line cannot show, so nothing is lost by leaving it out.
|
||||
*/
|
||||
const showChart = publishedYears >= 4;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showChart && (
|
||||
<>
|
||||
<div className={styles.admissionsChartCap}>Last distance offered, by year</div>
|
||||
<CutoffTrendChart rows={rows} />
|
||||
<p className={styles.cutoffChartKey}>
|
||||
Gaps are years with no published figure — the line is never drawn
|
||||
across one. The table says what happened in each.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{trendSummary && <p className={styles.admissionsTrendSummary}>{trendSummary}</p>}
|
||||
|
||||
<CutoffYearTable rows={rows} />
|
||||
{coverageNote && <p className={styles.cutoffChartKey}>{coverageNote}</p>}
|
||||
|
||||
<CutoffMapPanel schoolInfo={schoolInfo} rows={rows} />
|
||||
|
||||
{/* The single caveat for the whole section. It lives here rather than
|
||||
inside the check, so it still renders for a school with no
|
||||
coordinates — where there is a table but no map and no check. */}
|
||||
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,36 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* CutoffMapPanel — "Where the last place went".
|
||||
* CutoffMapPanel — "How far away are you?"
|
||||
*
|
||||
* The rings and the postcode check live in one component because they are one
|
||||
* question asked twice: the form answers "how far are we?" and the map shows
|
||||
* that answer against the cut-offs. Entering a postcode drops a pin on the same
|
||||
* rings rather than producing a separate verdict elsewhere on the page.
|
||||
* Measures a postcode against the one cut-off we publish, and will draw that
|
||||
* cut-off as a ring around the school on request.
|
||||
*
|
||||
* The map is not rendered until asked for. Before a postcode is entered it is a
|
||||
* It used to compare against every published year and show a set of shrinking
|
||||
* rings. Earlier years are now held back as a paid feature and no longer leave
|
||||
* the API, so this answers one question about one year — which makes the
|
||||
* verdict sharper to state, and puts more weight on qualifying it properly,
|
||||
* since there is no run of years left to soften a single close call.
|
||||
*
|
||||
* The map is not rendered until asked for: before a postcode is entered it is a
|
||||
* circle drawn round a school, and it costs a Leaflet bundle and 240px of
|
||||
* section height to say that; a successful check opens it automatically,
|
||||
* because that is the point at which it starts answering something.
|
||||
* height to say that. A successful check opens it automatically, because that
|
||||
* is the point at which it starts answering something.
|
||||
*
|
||||
* 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 { useState, type FormEvent } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { School } from '@/lib/types';
|
||||
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
||||
import { geocodePostcode, calculateDistance } from '@/lib/api';
|
||||
import { isValidPostcode } from '@/lib/utils';
|
||||
import {
|
||||
compareToCutoffs,
|
||||
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
|
||||
compareToCutoff, CUTOFF_CHECK_CAVEAT,
|
||||
type CutoffCheckResult, type CutoffVerdict,
|
||||
} from './lastDistanceOffered';
|
||||
import type { CutoffRing } from '../LeafletCutoffMapInner';
|
||||
import styles from './schoolSections.module.css';
|
||||
|
||||
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
|
||||
@@ -35,65 +38,35 @@ const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
|
||||
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.
|
||||
// unstyled "outside" result 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,
|
||||
inside: styles.cutoffResultInside,
|
||||
outside: styles.cutoffResultOutside,
|
||||
'too-close': styles.cutoffResultTooClose,
|
||||
};
|
||||
|
||||
export function CutoffMapPanel({
|
||||
schoolInfo,
|
||||
rows,
|
||||
cutoff,
|
||||
}: {
|
||||
schoolInfo: School;
|
||||
rows: CutoffYearRow[];
|
||||
cutoff: SchoolAdmissionDistance;
|
||||
}) {
|
||||
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);
|
||||
/* The map is not rendered until it is asked for. It costs a Leaflet bundle
|
||||
and 240px of section height, and before a postcode is entered it is a
|
||||
circle drawn round a school — it only starts answering a question once
|
||||
there is a home to put beside the rings. A successful check therefore
|
||||
opens it automatically. */
|
||||
const [mapOpen, setMapOpen] = useState(false);
|
||||
|
||||
const lat = schoolInfo.latitude;
|
||||
const lng = schoolInfo.longitude;
|
||||
const cutoffM = cutoff.distance_m;
|
||||
|
||||
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;
|
||||
// Without coordinates there is nothing to measure against and nothing to draw.
|
||||
if (lat == null || lng == null || cutoffM == null) return null;
|
||||
|
||||
const onCheck = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -121,7 +94,7 @@ export function CutoffMapPanel({
|
||||
setMapOpen(true);
|
||||
// calculateDistance returns kilometres; everything here is metres.
|
||||
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
|
||||
setResult(compareToCutoffs(metres, rows));
|
||||
setResult(compareToCutoff(metres, cutoffM, cutoff.year));
|
||||
} catch {
|
||||
setError('Something went wrong looking up that postcode. Try again in a moment.');
|
||||
setResult(null);
|
||||
@@ -131,96 +104,78 @@ export function CutoffMapPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const latestRing = rings[0];
|
||||
|
||||
return (
|
||||
<div className={styles.cutoffMapPanel}>
|
||||
{/* No "Where the last place went" heading above this any more. It
|
||||
introduced a map that now sits behind a button, so it named nothing,
|
||||
and it echoed the section title directly above it. */}
|
||||
<div className={styles.cutoffCheck}>
|
||||
<h3 className={styles.cutoffCheckTitle}>How far away are you?</h3>
|
||||
<p className={styles.cutoffCheckSub}>
|
||||
Straight-line distance from your postcode, compared with each year’s
|
||||
cut-off. Not stored.
|
||||
</p>
|
||||
<div className={styles.cutoffCheck}>
|
||||
<p className={styles.cutoffCheckSub}>
|
||||
Straight-line distance from your postcode, compared with the September{' '}
|
||||
{cutoff.year} cut-off. 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>
|
||||
<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>}
|
||||
{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>}
|
||||
{result && (
|
||||
<div
|
||||
className={`${styles.cutoffCheckResult} ${VERDICT_CLASS[result.verdict]}`}
|
||||
role="status"
|
||||
>
|
||||
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
|
||||
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{mapOpen ? (
|
||||
<div className={styles.cutoffMapReveal}>
|
||||
<div className={styles.cutoffMapFigure}>
|
||||
<CutoffMap
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
rings={[{ year: cutoff.year, radiusM: cutoffM, latest: true }]}
|
||||
home={home}
|
||||
interactive={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapOpen ? (
|
||||
<div className={styles.cutoffMapReveal}>
|
||||
<div className={styles.cutoffMapFigure}>
|
||||
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
|
||||
</div>
|
||||
<ul className={styles.cutoffMapLegend}>
|
||||
<ul className={styles.cutoffMapLegend}>
|
||||
<li>
|
||||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
|
||||
September {cutoff.year} cut-off
|
||||
</li>
|
||||
{home && (
|
||||
<li>
|
||||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
|
||||
{latestRing.year} cut-off
|
||||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
|
||||
Your postcode
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cutoffMapToggle}
|
||||
onClick={() => setMapOpen(true)}
|
||||
>
|
||||
Show these distances on a map
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cutoffMapToggle}
|
||||
onClick={() => setMapOpen(true)}
|
||||
>
|
||||
Show this distance on a map
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,51 @@
|
||||
/**
|
||||
* DistanceSection — "How far the last place went".
|
||||
* DistanceSection — "How far away are you?"
|
||||
*
|
||||
* Its own section rather than a third tab inside Admissions.
|
||||
* The section exists to answer one question a parent cannot answer from a
|
||||
* number alone: whether their own address falls inside it. The figure itself is
|
||||
* already on the Admissions tile above; this turns it into something they can
|
||||
* act on.
|
||||
*
|
||||
* It began as a tab, which broke the admissions card in a way worth recording:
|
||||
* the two admissions views are stacked in one grid cell so switching them never
|
||||
* shifts layout, which means the card is sized by its TALLEST view. A view
|
||||
* carrying a chart, a table and a map came to 1402px against the tile grid's
|
||||
* 316px, so the default view — the one nobody had clicked — rendered as four
|
||||
* tiles adrift in ~1080px of blank card, and Admissions alone accounted for
|
||||
* half the height of every section on the page.
|
||||
*
|
||||
* The structural point stands on its own though: every other topic here is a
|
||||
* section with a nav entry, and "how close did we need to live, and would we
|
||||
* have got in?" is a topic, not a variant reading of the intake figures. The
|
||||
* headline number stays on the Admissions tile where the intake story is; the
|
||||
* record behind it lives here.
|
||||
* Scope note. This used to carry the full published record — chart, year table
|
||||
* and a set of shrinking rings. Earlier years are now held back as a paid
|
||||
* feature and no longer leave the API at all, so what remains is the latest
|
||||
* year and the check against it. The pipeline is unchanged: every published
|
||||
* year is still loaded into fact_admission_distance, so restoring history for
|
||||
* entitled callers is a serving change rather than a re-collection.
|
||||
*
|
||||
* Server component; the map and postcode form carry their own client boundary.
|
||||
*/
|
||||
|
||||
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
||||
import type { School, SchoolAdmissionDistance } from '@/lib/types';
|
||||
import { Section, sectionStyles as styles } from './sectionShared';
|
||||
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
|
||||
import { cutoffYearRows, hasCutoffDetail } from './lastDistanceOffered';
|
||||
import { CutoffMapPanel } from './CutoffMapPanel';
|
||||
|
||||
export function DistanceSection({
|
||||
admissionDistanceHistory,
|
||||
admissionsHistory,
|
||||
admissionDistance,
|
||||
schoolInfo,
|
||||
}: {
|
||||
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
schoolInfo: School;
|
||||
}) {
|
||||
const rows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
|
||||
if (!hasCutoffDetail(rows)) return null;
|
||||
// Without a figure there is nothing to compare against, and without
|
||||
// coordinates there is nothing to measure — CutoffMapPanel enforces the
|
||||
// second, but the section must not render an empty card either way.
|
||||
if (
|
||||
admissionDistance?.distance_m == null
|
||||
|| schoolInfo.latitude == null
|
||||
|| schoolInfo.longitude == null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section id="distance">
|
||||
<h2 className={styles.sectionTitle}>How far the last place went</h2>
|
||||
<h2 className={styles.sectionTitle}>How far away are you?</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
The distance of the furthest home offered a place each year, and how your
|
||||
own address compares.
|
||||
Check your postcode against the furthest home offered a place in
|
||||
September {admissionDistance.year}.
|
||||
</p>
|
||||
<CutoffDistanceDetail rows={rows} schoolInfo={schoolInfo} />
|
||||
<CutoffMapPanel schoolInfo={schoolInfo} cutoff={admissionDistance} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,6 @@ export interface PrimarySchoolSectionsProps {
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
@@ -45,7 +44,7 @@ export interface PrimarySchoolSectionsProps {
|
||||
|
||||
export function PrimarySchoolSections({
|
||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
|
||||
admissions, admissionsHistory, admissionDistance,
|
||||
deprivation, finance, nationalAvg, flags,
|
||||
}: PrimarySchoolSectionsProps) {
|
||||
const primaryAvg = nationalAvg?.primary ?? {};
|
||||
@@ -110,11 +109,7 @@ export function PrimarySchoolSections({
|
||||
|
||||
{/* Its own section, directly after Admissions: it answers the question
|
||||
the tile above raises. */}
|
||||
<DistanceSection
|
||||
admissionDistanceHistory={admissionDistanceHistory}
|
||||
admissionsHistory={admissionsHistory}
|
||||
schoolInfo={schoolInfo}
|
||||
/>
|
||||
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
|
||||
|
||||
{flags.hasInclusionData && (
|
||||
<InclusionSection
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface SecondarySchoolSectionsProps {
|
||||
* school simply was not oversubscribed. */
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistanceHistory: SchoolAdmissionDistance[];
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
@@ -46,7 +45,7 @@ export interface SecondarySchoolSectionsProps {
|
||||
|
||||
export function SecondarySchoolSections({
|
||||
schoolInfo, yearlyData, ofsted, census,
|
||||
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory,
|
||||
admissions, admissionsHistory, admissionDistance,
|
||||
deprivation, finance, nationalAvg, flags,
|
||||
}: SecondarySchoolSectionsProps) {
|
||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||
@@ -99,11 +98,7 @@ export function SecondarySchoolSections({
|
||||
)}
|
||||
|
||||
{/* See PrimarySchoolSections — same placement, same reason. */}
|
||||
<DistanceSection
|
||||
admissionDistanceHistory={admissionDistanceHistory}
|
||||
admissionsHistory={admissionsHistory}
|
||||
schoolInfo={schoolInfo}
|
||||
/>
|
||||
<DistanceSection admissionDistance={admissionDistance} schoolInfo={schoolInfo} />
|
||||
|
||||
{yearlyData.length > 1 && (
|
||||
<SecondaryHistorySection
|
||||
|
||||
@@ -24,8 +24,3 @@ export const AdmissionsTrendChart = dynamic(
|
||||
() => import('../AdmissionsTrendChart'),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const CutoffTrendChart = dynamic(
|
||||
() => import('../CutoffTrendChart'),
|
||||
{ ssr: false },
|
||||
);
|
||||
@@ -60,237 +60,75 @@ export function describeCutoff(
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
* How close a home has to be to the 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.
|
||||
* postcodes.io returns the centroid of a postcode unit covering roughly fifteen
|
||||
* addresses, 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 type CutoffVerdict = 'inside' | 'outside' | 'too-close';
|
||||
|
||||
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;
|
||||
verdict: CutoffVerdict;
|
||||
headline: string;
|
||||
detail: string;
|
||||
/** Why we cannot call it, on the one verdict that needs explaining. Kept
|
||||
* apart from the headline so it does not run at headline weight. */
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export function compareToCutoffs(
|
||||
/**
|
||||
* Compare a home against the one cut-off we publish.
|
||||
*
|
||||
* Only the latest year is compared because only the latest year is served:
|
||||
* earlier years are held back as a paid feature and no longer leave the API.
|
||||
* A single year makes the verdict sharper to state and easier to qualify — one
|
||||
* distance, one year, one margin — but it also removes the reassurance a run of
|
||||
* years gave, so the year is named in the headline rather than left implied.
|
||||
*/
|
||||
export function compareToCutoff(
|
||||
distanceM: number,
|
||||
rows: CutoffYearRow[],
|
||||
cutoffM: number,
|
||||
year: number,
|
||||
): 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 margin = cutoffM - distanceM;
|
||||
const verdict: CutoffVerdict =
|
||||
Math.abs(margin) <= CUTOFF_UNCERTAINTY_M ? 'too-close' : margin > 0 ? 'inside' : 'outside';
|
||||
|
||||
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`;
|
||||
const cutoffLabel = formatCutoffDistance(cutoffM)?.primary ?? `${Math.round(cutoffM)} 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`;
|
||||
}
|
||||
// "the September 2026 cut-off of 0.17 miles" rather than "the 0.17 miles
|
||||
// cut-off for September 2026": the figure carries its own unit word, which
|
||||
// reads wrong used attributively.
|
||||
const cutoffPhrase = `the September ${year} cut-off of ${cutoffLabel}`;
|
||||
const headline =
|
||||
verdict === 'inside'
|
||||
? `${label} away — inside ${cutoffPhrase}.`
|
||||
: verdict === 'outside'
|
||||
? `${label} away — beyond ${cutoffPhrase}.`
|
||||
: `${label} away — too close to ${cutoffPhrase} to call.`;
|
||||
|
||||
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.`);
|
||||
}
|
||||
const detail =
|
||||
verdict === 'too-close'
|
||||
? 'Your postcode is a centroid covering several addresses, so a margin '
|
||||
+ `under ${CUTOFF_UNCERTAINTY_M} m is inside the measurement error.`
|
||||
: null;
|
||||
|
||||
return {
|
||||
distanceM,
|
||||
distanceLabel: label,
|
||||
years,
|
||||
insideCount: inside.length,
|
||||
comparableCount: comparable.length,
|
||||
headline,
|
||||
detail: parts.join(' '),
|
||||
};
|
||||
return { distanceM, distanceLabel: label, verdict, headline, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,6 +162,12 @@ export const CUTOFF_CHECK_CAVEAT =
|
||||
// When there is no figure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The slice of an admissions year this file needs. */
|
||||
interface AdmissionsYearLike {
|
||||
year: number;
|
||||
oversubscribed?: boolean | null;
|
||||
}
|
||||
|
||||
interface AbsenceInput {
|
||||
localAuthority?: string | null;
|
||||
admissionsPolicy?: string | null;
|
||||
@@ -361,30 +205,3 @@ export function describeCutoffAbsence({
|
||||
: '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.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is enough of a record to justify a section of its own.
|
||||
*
|
||||
* Two published years is the floor: one is a fact the Admissions tile already
|
||||
* states, and a chart of a single point invites a trend reading that is not
|
||||
* there. Shared so the section, its nav entry and the detail component cannot
|
||||
* disagree about when it exists — a nav link to a section that did not render
|
||||
* is exactly the failure this codebase keeps warning about.
|
||||
*/
|
||||
export function hasCutoffDetail(rows: CutoffYearRow[]): boolean {
|
||||
return rows.filter((r) => r.status === 'published').length >= 2;
|
||||
}
|
||||
@@ -2099,105 +2099,6 @@
|
||||
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 {
|
||||
@@ -2252,12 +2153,6 @@
|
||||
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;
|
||||
@@ -2394,54 +2289,6 @@
|
||||
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;
|
||||
@@ -2488,3 +2335,25 @@
|
||||
.cutoffMapReveal {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ── Postcode check result, coloured by verdict ──
|
||||
*
|
||||
* These override the neutral brand tint on .cutoffCheckResult. Without them the
|
||||
* three outcomes render identically, which is the specific failure the class
|
||||
* map in CutoffMapPanel is written out longhand to avoid — and which the
|
||||
* stylesheet test caught when they were referenced before being defined.
|
||||
*/
|
||||
.cutoffResultInside {
|
||||
background-image: linear-gradient(var(--status-above-bg), var(--status-above-bg));
|
||||
}
|
||||
|
||||
.cutoffResultOutside {
|
||||
background-image: linear-gradient(var(--status-below-bg), var(--status-below-bg));
|
||||
}
|
||||
|
||||
/* Deliberately neutral: "we cannot tell" is not a bad outcome, and tinting it
|
||||
like one would push a reader toward a conclusion the data does not support. */
|
||||
.cutoffResultTooClose {
|
||||
background-image: none;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
|
||||
} from './types';
|
||||
import { isSpecialSchool } from './utils';
|
||||
import { cutoffYearRows, hasCutoffDetail } from '@/components/school/lastDistanceOffered';
|
||||
|
||||
export interface SchoolFlagsInput {
|
||||
schoolInfo: School;
|
||||
@@ -123,11 +122,10 @@ export interface NavItemsInput {
|
||||
/** A published cut-off distance renders the Admissions section on its own,
|
||||
* so it has to open the nav entry too. */
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
/** Drives the separate Distance section. Gated through hasCutoffDetail, the
|
||||
* same predicate the section itself uses, so the nav can never link to an
|
||||
* anchor that was not rendered. */
|
||||
admissionDistanceHistory?: SchoolAdmissionDistance[];
|
||||
admissionsHistory?: SchoolAdmissions[];
|
||||
/** Whether the school has coordinates — the Distance section needs them to
|
||||
* measure a postcode, so the nav must gate on them too or it will link to an
|
||||
* anchor that was never rendered. */
|
||||
hasLocation?: boolean;
|
||||
yearlyDataLength: number;
|
||||
}
|
||||
|
||||
@@ -142,10 +140,7 @@ export interface NavItemsInput {
|
||||
*/
|
||||
export function buildNavItems(
|
||||
flags: SchoolFlags,
|
||||
{
|
||||
ofsted, admissions, admissionDistance,
|
||||
admissionDistanceHistory, admissionsHistory, yearlyDataLength,
|
||||
}: NavItemsInput,
|
||||
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
|
||||
): NavItem[] {
|
||||
const navItems: NavItem[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
@@ -156,7 +151,7 @@ export function buildNavItems(
|
||||
});
|
||||
}
|
||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (hasCutoffDetail(cutoffYearRows(admissionDistanceHistory, admissionsHistory))) {
|
||||
if (admissionDistance?.distance_m != null && hasLocation) {
|
||||
navItems.push({ id: 'distance', label: 'Distance' });
|
||||
}
|
||||
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||
@@ -228,16 +223,13 @@ export function computeSecondaryFlags({
|
||||
*/
|
||||
export function buildSecondaryNavItems(
|
||||
flags: SecondaryFlags,
|
||||
{
|
||||
ofsted, admissions, admissionDistance,
|
||||
admissionDistanceHistory, admissionsHistory, yearlyDataLength,
|
||||
}: NavItemsInput,
|
||||
{ ofsted, admissions, admissionDistance, hasLocation, yearlyDataLength }: NavItemsInput,
|
||||
): NavItem[] {
|
||||
const navItems: NavItem[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (hasCutoffDetail(cutoffYearRows(admissionDistanceHistory, admissionsHistory))) {
|
||||
if (admissionDistance?.distance_m != null && hasLocation) {
|
||||
navItems.push({ id: 'distance', label: 'Distance' });
|
||||
}
|
||||
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
|
||||
|
||||
@@ -354,14 +354,14 @@ export interface SchoolDetailsResponse {
|
||||
admissions: SchoolAdmissions | null;
|
||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||
admissions_history: SchoolAdmissions[];
|
||||
/** Latest published cut-off distance, or null where the LA publishes none. */
|
||||
admission_distance: SchoolAdmissionDistance | null;
|
||||
/**
|
||||
* 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.
|
||||
* Latest published cut-off distance, or null where the LA publishes none.
|
||||
*
|
||||
* Only the latest year is served. Earlier years exist in the mart but are
|
||||
* held back as a paid feature and are not part of this public payload — see
|
||||
* data_loader._admission_distance.
|
||||
*/
|
||||
admission_distance_history: SchoolAdmissionDistance[];
|
||||
admission_distance: SchoolAdmissionDistance | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user