diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index 1d36ffb..165ad0d 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -1324,16 +1324,13 @@ test('a school with several published years gets the Distance view', async ({ pa
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');
- }
+ // Its own section on both templates, not a third tab inside Admissions —
+ // stacking a view that tall in the admissions viewport sized the whole card
+ // to it and left the default view mostly blank.
+ await expect(page.locator('#distance')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Distance' })).toHaveCount(0);
- // Every published year must appear as a row, whichever template rendered it.
+ // Every published year must appear as a row.
for (const h of found!.history as { year: number }[]) {
await expect(page.getByRole('rowheader', { name: String(h.year) })).toBeVisible();
}
@@ -1345,10 +1342,7 @@ test('the year table never leaves a gap unexplained', async ({ page }) => {
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.locator('#distance')).toBeVisible({ timeout: 15_000 });
const statuses = await page.locator('[class*="cutoffPill"]').allTextContents();
expect(statuses.length).toBeGreaterThan(0);
@@ -1364,10 +1358,7 @@ test('the postcode check answers with a distance and per-year verdicts', async (
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.locator('#distance')).toBeVisible({ timeout: 15_000 });
const input = page.getByLabel('Your postcode');
await expect(input).toBeVisible();
@@ -1391,13 +1382,14 @@ test('the postcode check states its limits before it is used', async ({ page })
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.locator('#distance')).toBeVisible({ timeout: 15_000 });
- await expect(page.getByText(/An indication only/)).toBeVisible();
- await expect(page.getByText(/not a catchment boundary/)).toBeVisible();
+ const caveat = page.getByText(/Distance is the last criterion applied/);
+ await expect(caveat).toBeVisible();
+ await expect(caveat).toContainText(/not a catchment boundary/);
+ await expect(caveat).toContainText(/walking route/);
+ // One caveat for the section, not the three paragraphs it replaced.
+ await expect(caveat).toHaveCount(1);
});
test('the distance section never scrolls the page sideways', async ({ page }) => {
@@ -1406,10 +1398,7 @@ test('the distance section never scrolls the page sideways', async ({ page }) =>
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();
- }
+ await expect(page.locator('#distance')).toBeVisible({ timeout: 15_000 });
// 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.
@@ -1417,3 +1406,44 @@ test('the distance section never scrolls the page sideways', async ({ page }) =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1);
});
+
+test('no single section dominates the height of a school page', async ({ page }) => {
+ /*
+ * The admissions views are stacked in one grid cell so switching them never
+ * shifts layout, which means the card is sized by its TALLEST view while the
+ * hidden ones keep their box. A distance view carrying a chart, a table and a
+ * map was added there and measured 1402px against the tile grid's 316px; the
+ * DEFAULT view rendered as four tiles adrift in ~1080px of blank card, and
+ * Admissions alone came to half the height of every section on the page
+ * (1503px against 526px for the next largest).
+ *
+ * Measuring the card's internals cannot catch it: the tile grid is
+ * `flex: 1`, so it absorbs the stretch and every box still looks full. What
+ * a reader actually sees is one section wildly out of proportion with its
+ * neighbours, so that is what this asserts.
+ */
+ 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 });
+ await page.waitForTimeout(1200); // charts settle, and they carry real height
+
+ const sections = await page.evaluate(() =>
+ [...document.querySelectorAll('section[id]')]
+ .map((s) => ({ id: s.id, h: Math.round(s.getBoundingClientRect().height) }))
+ .filter((s) => s.h > 0));
+
+ expect(sections.length).toBeGreaterThanOrEqual(3);
+ const heights = sections.map((s) => s.h).sort((a, b) => a - b);
+ const median = heights[Math.floor(heights.length / 2)];
+ const worst = sections.reduce((a, b) => (a.h > b.h ? a : b));
+
+ // Generous: a rich section may fairly run to twice a plain one. Nearly three
+ // times over is the shape of a layout fault, not of denser content.
+ expect(
+ worst.h / median,
+ `#${worst.id} is ${worst.h}px against a ${median}px median: `
+ + sections.map((s) => `${s.id}=${s.h}`).join(', '),
+ ).toBeLessThan(2.5);
+});
diff --git a/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx b/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx
index ad8c253..aabb4ca 100644
--- a/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx
+++ b/nextjs-app/__tests__/components/CutoffMapPanel.test.tsx
@@ -132,13 +132,30 @@ describe('CutoffMapPanel', () => {
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/);
});
- it('always states the limits of the check', async () => {
+ 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)]);
- // 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();
+ 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)]);
+ 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');
+ expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
+ });
+
+ 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/ }));
+ expect(screen.getByTestId('cutoff-map')).toBeInTheDocument();
});
});
diff --git a/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx b/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx
index 375883f..c3c13bd 100644
--- a/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx
+++ b/nextjs-app/__tests__/components/lastDistanceOffered.test.tsx
@@ -96,27 +96,33 @@ const history = (pts: [number, number][]): SchoolAdmissionDistance[] =>
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({
+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.
+ const { container } = 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();
+ 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();
});
- it('stays hidden on a single published year', () => {
+ 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.
- renderSchoolDetail({
+ const { container } = renderSchoolDetail({
...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2024, 700]]),
});
- expect(screen.queryByRole('button', { name: 'Distance' })).not.toBeInTheDocument();
+ expect(container.querySelector('#distance')).not.toBeInTheDocument();
});
it('lists every year in the span, including the ones with no figure', () => {
@@ -126,10 +132,6 @@ describe('primary Distance view', () => {
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']) {
@@ -156,6 +158,34 @@ describe('primary Distance view', () => {
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', () => {
+ renderSchoolDetail({
+ ...primaryFixture,
+ schoolInfo: { ...primaryFixture.schoolInfo, latitude: 51.5, longitude: -0.12 },
+ admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
+ admissionDistanceHistory: history([[2021, 1000], [2024, 700]]),
+ });
+
+ expect(screen.getAllByText(/Distance is the last criterion/)).toHaveLength(1);
+ });
+
it('withholds a trend reading while the record is thin', () => {
renderSchoolDetail({
...primaryFixture,
@@ -168,16 +198,19 @@ describe('primary Distance view', () => {
});
});
-describe('secondary Distance detail', () => {
- it('renders inline, with no extra tab, once there are two published years', () => {
+describe('secondary Distance section', () => {
+ it('renders as its own section 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();
+ // 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();
});
it('explains a selective school by how it admits rather than as missing data', () => {
diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx
index b29ac71..547e70a 100644
--- a/nextjs-app/app/school/[slug]/page.tsx
+++ b/nextjs-app/app/school/[slug]/page.tsx
@@ -177,6 +177,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
ofsted: ofsted ?? null,
admissions: admissions ?? null,
admissionDistance: admission_distance ?? null,
+ admissionDistanceHistory: admission_distance_history ?? [],
+ admissionsHistory: admissions_history ?? [],
yearlyDataLength: yearly_data.length,
};
const primaryNavItems = buildNavItems(primaryFlags, navInput);
diff --git a/nextjs-app/components/school/AdmissionsSection.tsx b/nextjs-app/components/school/AdmissionsSection.tsx
index 9cb9d24..6b423b7 100644
--- a/nextjs-app/components/school/AdmissionsSection.tsx
+++ b/nextjs-app/components/school/AdmissionsSection.tsx
@@ -9,28 +9,29 @@
* AdmissionsViewToggle client island, which receives both views as
* server-rendered children. When there is only one year of offer data no
* toggle renders at all, so such pages ship zero admissions JavaScript.
+ *
+ * The two views are stacked in one grid cell so switching never shifts layout,
+ * which means the card is sized by the TALLER of them. Keep any view added
+ * here close to the tile grid in height: a cut-off-distance view carrying a
+ * chart, a table and a map was tried, measured 1402px against the tiles' 316px,
+ * and left the default view as four tiles adrift in blank card. It now lives in
+ * DistanceSection.
*/
import type { ReactNode } from 'react';
-import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
+import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts';
-import { CutoffDistanceDetail } from './CutoffDistanceDetail';
-import {
- describeCutoff, cutoffYearRows,
- CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
-} from './lastDistanceOffered';
+import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
export function AdmissionsSection({
admissions,
admissionsHistory,
admissionDistance,
- admissionDistanceHistory,
- schoolInfo,
isAllThrough,
}: {
/* Nullable: the two data sources are independent. A minority of schools have
@@ -40,18 +41,9 @@ export function AdmissionsSection({
admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null;
- admissionDistanceHistory: SchoolAdmissionDistance[];
- schoolInfo: School;
isAllThrough: boolean;
}) {
const cutoff = describeCutoff(admissionDistance);
- // Every year in the span, including the ones with nothing in them — the
- // classification of a missing year is what the Distance view is mostly for.
- const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
- const publishedYears = cutoffRows.filter((r) => r.status === 'published').length;
- // One published year is a fact, not a history: the tile already says it, and
- // a chart of a single point invites a trend reading that is not there.
- const showDistanceView = publishedYears >= 2;
// Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
@@ -182,10 +174,6 @@ export function AdmissionsSection({
>
);
- const distanceView = showDistanceView && (
-
- 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.
-
+ Gaps are years with no published figure — the line is never drawn
+ across one. The table says what happened in each.
+ {trendSummary}
- {CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
- {CUTOFF_CHECK_CAVEAT} {CUTOFF_MAP_CAVEAT}
- 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.
+ Straight-line distance from your postcode, compared with each year’s
+ cut-off. Not stored.
{CUTOFF_CHECK_CAVEAT}
+ The distance of the furthest home offered a place each year, and how your
+ own address compares.
+ Where the last place went
-
-
-
-
- How far away are you?
+ How far away are you?
+
+ How far the last place went
+