Merge pull request 'fix(admissions): move the cut-off detail into its own section' (#103) from fix/admissions-section-height into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m29s

Reviewed-on: #103
This commit was merged in pull request #103.
This commit is contained in:
tudor committed 2026-08-20 14:03:01 +00:00
commit 50b599a09b
14 files changed
+403 -185

No files matched your search

+57 -27
View File
@@ -1324,16 +1324,13 @@ test('a school with several published years gets the Distance view', async ({ pa
await page.goto(`/school/${found!.urn}`); await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 });
const isSecondary = /secondary/i.test(found!.phase ?? ''); // Its own section on both templates, not a third tab inside Admissions —
if (!isSecondary) { // stacking a view that tall in the admissions viewport sized the whole card
// Primary pages carry a segmented control; the detail sits behind it. // to it and left the default view mostly blank.
const distanceTab = page.getByRole('button', { name: 'Distance' }); await expect(page.locator('#distance')).toBeVisible();
await expect(distanceTab).toBeVisible(); await expect(page.getByRole('button', { name: 'Distance' })).toHaveCount(0);
await distanceTab.click();
await expect(distanceTab).toHaveAttribute('aria-pressed', 'true');
}
// 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 }[]) { for (const h of found!.history as { year: number }[]) {
await expect(page.getByRole('rowheader', { name: String(h.year) })).toBeVisible(); 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'); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`); await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('#distance')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const statuses = await page.locator('[class*="cutoffPill"]').allTextContents(); const statuses = await page.locator('[class*="cutoffPill"]').allTextContents();
expect(statuses.length).toBeGreaterThan(0); 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'); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`); await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('#distance')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
const input = page.getByLabel('Your postcode'); const input = page.getByLabel('Your postcode');
await expect(input).toBeVisible(); 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'); test.skip(found === null, 'no school in the sample has 2+ published cut-off years yet');
await page.goto(`/school/${found!.urn}`); await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('#distance')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
await expect(page.getByText(/An indication only/)).toBeVisible(); const caveat = page.getByText(/Distance is the last criterion applied/);
await expect(page.getByText(/not a catchment boundary/)).toBeVisible(); 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 }) => { 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.setViewportSize({ width: 390, height: 844 });
await page.goto(`/school/${found!.urn}`); await page.goto(`/school/${found!.urn}`);
await expect(page.locator('#admissions')).toBeVisible({ timeout: 15_000 }); await expect(page.locator('#distance')).toBeVisible({ timeout: 15_000 });
if (!/secondary/i.test(found!.phase ?? '')) {
await page.getByRole('button', { name: 'Distance' }).click();
}
// The year table is deliberately wider than a phone; its own wrapper has to // 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. // 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); document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, 'page must not scroll horizontally').toBeLessThanOrEqual(1); 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);
});
@@ -132,13 +132,30 @@ describe('CutoffMapPanel', () => {
expect(screen.getByRole('alert')).toHaveTextContent(/Something went wrong/); 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)]); renderPanel([d(2024, 800)]);
// Present before any postcode is entered, not revealed with the answer. expect(screen.queryByText(/Distance is the last criterion/)).not.toBeInTheDocument();
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. it('keeps the map behind a request until there is a reason to show it', async () => {
expect(screen.getByText(/not a catchment boundary/)).toBeInTheDocument(); renderPanel([d(2024, 800)]);
expect(screen.getByText(/walking route rather than a straight line/)).toBeInTheDocument(); 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();
}); });
}); });
@@ -96,27 +96,33 @@ const history = (pts: [number, number][]): SchoolAdmissionDistance[] =>
year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles', year, distance_m, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles',
})); }));
describe('primary Distance view', () => { describe('primary Distance section', () => {
it('appears once there are two or more published years', () => { it('appears as its own section once there are two or more published years', () => {
renderSchoolDetail({ // 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, ...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }), admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2021, 1000], [2022, 900], [2023, 800], [2024, 700]]), 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 // One point is a fact, not a history; a chart of it invites a trend reading
// that is not there. // that is not there.
renderSchoolDetail({ const { container } = renderSchoolDetail({
...primaryFixture, ...primaryFixture,
admissionDistance: cutoff({ distance_m: 700, year: 2024 }), admissionDistance: cutoff({ distance_m: 700, year: 2024 }),
admissionDistanceHistory: history([[2024, 700]]), 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', () => { 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]]), 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 // 2022 and 2023 were never published but must still appear as rows, or the
// gap in the chart has nothing explaining it. // gap in the chart has nothing explaining it.
for (const year of ['2021', '2022', '2023', '2024']) { for (const year of ['2021', '2022', '2023', '2024']) {
@@ -156,6 +158,34 @@ describe('primary Distance view', () => {
expect(screen.queryByText(/all offered/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', () => {
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', () => { it('withholds a trend reading while the record is thin', () => {
renderSchoolDetail({ renderSchoolDetail({
...primaryFixture, ...primaryFixture,
@@ -168,16 +198,19 @@ describe('primary Distance view', () => {
}); });
}); });
describe('secondary Distance detail', () => { describe('secondary Distance section', () => {
it('renders inline, with no extra tab, once there are two published years', () => { it('renders as its own section once there are two published years', () => {
renderSecondarySchoolDetail({ renderSecondarySchoolDetail({
...secondaryFixture, ...secondaryFixture,
admissionDistance: cutoff({ distance_m: 3472.96 }), admissionDistance: cutoff({ distance_m: 3472.96 }),
admissionDistanceHistory: history([[2023, 3800], [2024, 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(); 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', () => { it('explains a selective school by how it admits rather than as missing data', () => {
+2
View File
@@ -177,6 +177,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
ofsted: ofsted ?? null, ofsted: ofsted ?? null,
admissions: admissions ?? null, admissions: admissions ?? null,
admissionDistance: admission_distance ?? null, admissionDistance: admission_distance ?? null,
admissionDistanceHistory: admission_distance_history ?? [],
admissionsHistory: admissions_history ?? [],
yearlyDataLength: yearly_data.length, yearlyDataLength: yearly_data.length,
}; };
const primaryNavItems = buildNavItems(primaryFlags, navInput); const primaryNavItems = buildNavItems(primaryFlags, navInput);
@@ -9,28 +9,29 @@
* AdmissionsViewToggle client island, which receives both views as * AdmissionsViewToggle client island, which receives both views as
* server-rendered children. When there is only one year of offer data no * server-rendered children. When there is only one year of offer data no
* toggle renders at all, so such pages ship zero admissions JavaScript. * 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 { 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 { formatAcademicYear, formatPercentage } from '@/lib/utils';
import { summariseAdmissions } from '@/lib/compareLogic'; import { summariseAdmissions } from '@/lib/compareLogic';
import { Section, sectionStyles as styles } from './sectionShared'; import { Section, sectionStyles as styles } from './sectionShared';
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle'; import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
import { AdmissionsTrendChart } from './charts'; import { AdmissionsTrendChart } from './charts';
import { CutoffDistanceDetail } from './CutoffDistanceDetail'; import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
import {
describeCutoff, cutoffYearRows,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered';
export function AdmissionsSection({ export function AdmissionsSection({
admissions, admissions,
admissionsHistory, admissionsHistory,
admissionDistance, admissionDistance,
admissionDistanceHistory,
schoolInfo,
isAllThrough, isAllThrough,
}: { }: {
/* Nullable: the two data sources are independent. A minority of schools have /* Nullable: the two data sources are independent. A minority of schools have
@@ -40,18 +41,9 @@ export function AdmissionsSection({
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[]; admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null; admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
schoolInfo: School;
isAllThrough: boolean; isAllThrough: boolean;
}) { }) {
const cutoff = describeCutoff(admissionDistance); const cutoff = describeCutoff(admissionDistance);
// Every year in the span, including the ones with nothing in them — the
// classification of a missing year is what the Distance view is mostly for.
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
const publishedYears = cutoffRows.filter((r) => r.status === 'published').length;
// One published year is a fact, not a history: the tile already says it, and
// a chart of a single point invites a trend reading that is not there.
const showDistanceView = publishedYears >= 2;
// Trend toggle only appears with ≥2 years carrying an offer rate. // Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
@@ -182,10 +174,6 @@ export function AdmissionsSection({
</> </>
); );
const distanceView = showDistanceView && (
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} />
);
const views: AdmissionsView[] = [ const views: AdmissionsView[] = [
{ id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear }, { id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear },
]; ];
@@ -197,14 +185,6 @@ export function AdmissionsSection({
className: styles.admissionsViewTrend, className: styles.admissionsViewTrend,
}); });
} }
if (showDistanceView) {
views.push({
id: 'distance',
label: 'Distance',
content: distanceView,
className: styles.admissionsViewTrend,
});
}
return ( return (
<Section id="admissions"> <Section id="admissions">
@@ -21,38 +21,44 @@ import { CutoffTrendChart } from './charts';
import { CutoffYearTable } from './CutoffYearTable'; import { CutoffYearTable } from './CutoffYearTable';
import { CutoffMapPanel } from './CutoffMapPanel'; import { CutoffMapPanel } from './CutoffMapPanel';
import { import {
cutoffTrendSummary, cutoffCoverageNote, cutoffTrendSummary, cutoffCoverageNote, CUTOFF_CHECK_CAVEAT,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
type CutoffYearRow, type CutoffYearRow,
} from './lastDistanceOffered'; } from './lastDistanceOffered';
export function CutoffDistanceDetail({ export function CutoffDistanceDetail({
rows, rows,
schoolInfo, schoolInfo,
/** Repeat the "not a catchment" caveat here. On the primary page this view is
* behind a tab, so a reader can reach it without ever seeing the tile that
* carries the caveat; on the secondary page the caveat is already directly
* above, and repeating it verbatim reads as a stutter. */
showCaveat = true,
}: { }: {
rows: CutoffYearRow[]; rows: CutoffYearRow[];
schoolInfo: School; schoolInfo: School;
showCaveat?: boolean;
}) { }) {
const publishedYears = rows.filter((r) => r.status === 'published').length; const publishedYears = rows.filter((r) => r.status === 'published').length;
if (publishedYears < 2) return null; if (publishedYears < 2) return null;
const trendSummary = cutoffTrendSummary(rows); const trendSummary = cutoffTrendSummary(rows);
const coverageNote = cutoffCoverageNote(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 ( return (
<> <>
<div className={styles.admissionsChartCap}>Last distance offered, by year</div> {showChart && (
<CutoffTrendChart rows={rows} /> <>
<p className={styles.cutoffChartKey}> <div className={styles.admissionsChartCap}>Last distance offered, by year</div>
The line breaks where no figure was published — it is never drawn across <CutoffTrendChart rows={rows} />
a gap. The table below says what happened in each of those years. <p className={styles.cutoffChartKey}>
</p> 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>} {trendSummary && <p className={styles.admissionsTrendSummary}>{trendSummary}</p>}
<CutoffYearTable rows={rows} /> <CutoffYearTable rows={rows} />
@@ -60,11 +66,10 @@ export function CutoffDistanceDetail({
<CutoffMapPanel schoolInfo={schoolInfo} rows={rows} /> <CutoffMapPanel schoolInfo={schoolInfo} rows={rows} />
{showCaveat && ( {/* The single caveat for the whole section. It lives here rather than
<p className={styles.admissionsDistanceNote}> inside the check, so it still renders for a school with no
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE} coordinates — where there is a table but no map and no check. */}
</p> <p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p>
)}
</> </>
); );
} }
+56 -35
View File
@@ -4,9 +4,14 @@
* CutoffMapPanel — "Where the last place went". * CutoffMapPanel — "Where the last place went".
* *
* The rings and the postcode check live in one component because they are one * The rings and the postcode check live in one component because they are one
* question asked twice: the map answers "how far is that?" and the form answers * question asked twice: the form answers "how far are we?" and the map shows
* "how far are we?". Entering a postcode drops a pin on the same rings rather * that answer against the cut-offs. Entering a postcode drops a pin on the same
* than producing a separate verdict somewhere else on the page. * rings rather than producing a separate verdict elsewhere on the page.
*
* 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.
* *
* The postcode never leaves the browser except to postcodes.io for a lat/long, * 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 * and nothing is stored — this is a client-side measurement, not a lookup
@@ -19,7 +24,7 @@ import type { School } from '@/lib/types';
import { geocodePostcode, calculateDistance } from '@/lib/api'; import { geocodePostcode, calculateDistance } from '@/lib/api';
import { isValidPostcode } from '@/lib/utils'; import { isValidPostcode } from '@/lib/utils';
import { import {
compareToCutoffs, CUTOFF_CHECK_CAVEAT, CUTOFF_MAP_CAVEAT, compareToCutoffs,
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict, type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
} from './lastDistanceOffered'; } from './lastDistanceOffered';
import type { CutoffRing } from '../LeafletCutoffMapInner'; import type { CutoffRing } from '../LeafletCutoffMapInner';
@@ -66,6 +71,12 @@ export function CutoffMapPanel({
const [result, setResult] = useState<CutoffCheckResult | null>(null); const [result, setResult] = useState<CutoffCheckResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); 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 lat = schoolInfo.latitude;
const lng = schoolInfo.longitude; const lng = schoolInfo.longitude;
@@ -107,6 +118,7 @@ export function CutoffMapPanel({
return; return;
} }
setHome({ lat: point.latitude, lng: point.longitude }); setHome({ lat: point.latitude, lng: point.longitude });
setMapOpen(true);
// calculateDistance returns kilometres; everything here is metres. // calculateDistance returns kilometres; everything here is metres.
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000; const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
setResult(compareToCutoffs(metres, rows)); setResult(compareToCutoffs(metres, rows));
@@ -123,38 +135,14 @@ export function CutoffMapPanel({
return ( return (
<div className={styles.cutoffMapPanel}> <div className={styles.cutoffMapPanel}>
<h3 className={styles.cutoffMapTitle}>Where the last place went</h3> {/* 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,
<div className={styles.cutoffMapFigure}> and it echoed the section title directly above it. */}
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
</div>
<ul className={styles.cutoffMapLegend}>
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
{latestRing.year} cut-off
</li>
{rings.length > 1 && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
</li>
)}
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
)}
</ul>
<p className={styles.cutoffMapCaveat}>{CUTOFF_MAP_CAVEAT}</p>
<div className={styles.cutoffCheck}> <div className={styles.cutoffCheck}>
<h4 className={styles.cutoffCheckTitle}>How far away are you?</h4> <h3 className={styles.cutoffCheckTitle}>How far away are you?</h3>
<p className={styles.cutoffCheckSub}> <p className={styles.cutoffCheckSub}>
We measure a straight line from your postcode to the school and compare Straight-line distance from your postcode, compared with each year’s
it with each year’s cut-off. Your postcode is not stored. cut-off. Not stored.
</p> </p>
<form className={styles.cutoffCheckForm} onSubmit={onCheck}> <form className={styles.cutoffCheckForm} onSubmit={onCheck}>
@@ -198,7 +186,40 @@ export function CutoffMapPanel({
</div> </div>
)} )}
<p className={styles.cutoffCheckCaveat}>{CUTOFF_CHECK_CAVEAT}</p> {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}>
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
{latestRing.year} cut-off
</li>
{rings.length > 1 && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
</li>
)}
{home && (
<li>
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
Your postcode
</li>
)}
</ul>
</div>
) : (
<button
type="button"
className={styles.cutoffMapToggle}
onClick={() => setMapOpen(true)}
>
Show these distances on a map
</button>
)}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,50 @@
/**
* DistanceSection — "How far the last place went".
*
* Its own section rather than a third tab inside Admissions.
*
* 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.
*
* Server component; the map and postcode form carry their own client boundary.
*/
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { Section, sectionStyles as styles } from './sectionShared';
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
import { cutoffYearRows, hasCutoffDetail } from './lastDistanceOffered';
export function DistanceSection({
admissionDistanceHistory,
admissionsHistory,
schoolInfo,
}: {
admissionDistanceHistory: SchoolAdmissionDistance[];
admissionsHistory: SchoolAdmissions[];
schoolInfo: School;
}) {
const rows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
if (!hasCutoffDetail(rows)) return null;
return (
<Section id="distance">
<h2 className={styles.sectionTitle}>How far the last place went</h2>
<p className={styles.sectionSubtitle}>
The distance of the furthest home offered a place each year, and how your
own address compares.
</p>
<CutoffDistanceDetail rows={rows} schoolInfo={schoolInfo} />
</Section>
);
}
@@ -20,6 +20,7 @@ import type { SchoolFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection'; import { OfstedSection } from './OfstedSection';
import { ResultsSection } from './ResultsSection'; import { ResultsSection } from './ResultsSection';
import { AdmissionsSection } from './AdmissionsSection'; import { AdmissionsSection } from './AdmissionsSection';
import { DistanceSection } from './DistanceSection';
import { InclusionSection } from './InclusionSection'; import { InclusionSection } from './InclusionSection';
import { HistorySection } from './HistorySection'; import { HistorySection } from './HistorySection';
import { SchoolLifeSection } from './SchoolLifeSection'; import { SchoolLifeSection } from './SchoolLifeSection';
@@ -103,12 +104,18 @@ export function PrimarySchoolSections({
admissions={admissions} admissions={admissions}
admissionsHistory={admissionsHistory} admissionsHistory={admissionsHistory}
admissionDistance={admissionDistance} admissionDistance={admissionDistance}
admissionDistanceHistory={admissionDistanceHistory}
schoolInfo={schoolInfo}
isAllThrough={flags.isAllThrough} isAllThrough={flags.isAllThrough}
/> />
)} )}
{/* Its own section, directly after Admissions: it answers the question
the tile above raises. */}
<DistanceSection
admissionDistanceHistory={admissionDistanceHistory}
admissionsHistory={admissionsHistory}
schoolInfo={schoolInfo}
/>
{flags.hasInclusionData && ( {flags.hasInclusionData && (
<InclusionSection <InclusionSection
latestResults={flags.latestResults} latestResults={flags.latestResults}
@@ -9,27 +9,23 @@
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types'; import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
import { formatPercentage } from '@/lib/utils'; import { formatPercentage } from '@/lib/utils';
import { Section, sectionStyles as styles } from './sectionShared'; import { Section, sectionStyles as styles } from './sectionShared';
import { CutoffDistanceDetail } from './CutoffDistanceDetail';
import { import {
describeCutoff, cutoffYearRows, describeCutoffAbsence, describeCutoff, describeCutoffAbsence,
CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE,
} from './lastDistanceOffered'; } from './lastDistanceOffered';
export function SecondaryAdmissionsSection({ export function SecondaryAdmissionsSection({
admissions, admissionsHistory, admissionDistance, admissionDistanceHistory, admissions, admissionsHistory, admissionDistance, schoolInfo, hasSixthForm,
schoolInfo, hasSixthForm,
}: { }: {
/* Nullable for the same reason as the primary section: a school can have a /* Nullable for the same reason as the primary section: a school can have a
published cut-off and no EES admissions row. */ published cut-off and no EES admissions row. */
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[]; admissionsHistory: SchoolAdmissions[];
admissionDistance: SchoolAdmissionDistance | null; admissionDistance: SchoolAdmissionDistance | null;
admissionDistanceHistory: SchoolAdmissionDistance[];
schoolInfo: School; schoolInfo: School;
hasSixthForm: boolean; hasSixthForm: boolean;
}) { }) {
const cutoff = describeCutoff(admissionDistance); const cutoff = describeCutoff(admissionDistance);
const cutoffRows = cutoffYearRows(admissionDistanceHistory, admissionsHistory);
// Moved with this section from SecondarySchoolDetailView, its only consumer. // Moved with this section from SecondarySchoolDetailView, its only consumer.
const admissionsTag = (() => { const admissionsTag = (() => {
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? ''; const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
@@ -116,11 +112,6 @@ export function SecondaryAdmissionsSection({
</p> </p>
)} )}
{/* Inline rather than behind a tab: this template is one flat panel by
design and has no segmented control to hang a third view on. The
caveat is suppressed because it is already directly above. */}
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} showCaveat={false} />
{hasSixthForm && ( {hasSixthForm && (
<div className={styles.sixthFormNote}> <div className={styles.sixthFormNote}>
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon. This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
@@ -20,6 +20,7 @@ import type { SecondaryFlags } from '@/lib/schoolSections';
import { OfstedSection } from './OfstedSection'; import { OfstedSection } from './OfstedSection';
import { GcseSection } from './GcseSection'; import { GcseSection } from './GcseSection';
import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection'; import { SecondaryAdmissionsSection } from './SecondaryAdmissionsSection';
import { DistanceSection } from './DistanceSection';
import { SecondaryHistorySection } from './SecondaryHistorySection'; import { SecondaryHistorySection } from './SecondaryHistorySection';
import { WellbeingSection } from './WellbeingSection'; import { WellbeingSection } from './WellbeingSection';
import { FinancesSection } from './FinancesSection'; import { FinancesSection } from './FinancesSection';
@@ -91,13 +92,19 @@ export function SecondarySchoolSections({
<SecondaryAdmissionsSection <SecondaryAdmissionsSection
admissions={admissions} admissions={admissions}
admissionDistance={admissionDistance} admissionDistance={admissionDistance}
admissionDistanceHistory={admissionDistanceHistory}
admissionsHistory={admissionsHistory} admissionsHistory={admissionsHistory}
schoolInfo={schoolInfo} schoolInfo={schoolInfo}
hasSixthForm={flags.hasSixthForm} hasSixthForm={flags.hasSixthForm}
/> />
)} )}
{/* See PrimarySchoolSections — same placement, same reason. */}
<DistanceSection
admissionDistanceHistory={admissionDistanceHistory}
admissionsHistory={admissionsHistory}
schoolInfo={schoolInfo}
/>
{yearlyData.length > 1 && ( {yearlyData.length > 1 && (
<SecondaryHistorySection <SecondaryHistorySection
yearlyData={yearlyData} yearlyData={yearlyData}
@@ -294,31 +294,31 @@ export function compareToCutoffs(
} }
/** /**
* The limits of the check, stated where a parent will act on it. * The limits of the figure, and of the check made against it.
* *
* Every clause is load-bearing. Distance is the last criterion applied, not the * One caveat, rendered once at the end of the section. It was previously three
* first; the authority's own measurement may be a walking route rather than a * paragraphs — under the map, under the check, and a trailing "not a catchment"
* straight line, which is always longer for the same home; and a past cut-off * line — which took ~180px between them, said walking-route twice, and made
* constrains next year's not at all. * the same point about priorities in two voices.
*
* Phrased to stand up whether or not the postcode check is on the page: it
* opens on the figure rather than on "your result", because a school with no
* coordinates renders the table with no check beneath it.
*
* Every claim is still here:
* * distance is the last criterion applied, not the first;
* * the figures and rings are straight-line, and not a boundary;
* * some authorities measure a walking route, always longer for the same home;
* * a past cut-off constrains next year's not at all.
*/ */
export const CUTOFF_CHECK_CAVEAT = export const CUTOFF_CHECK_CAVEAT =
'An indication only. Places go first to children in care, EHCP places, ' 'Distance is the last criterion applied. Places go first to children in care, '
+ 'siblings and — at faith schools — on faith criteria, so a home inside the ' + 'EHCP places, siblings and — at faith schools — on faith criteria, so a home '
+ 'distance can still miss out. Some authorities measure a walking route ' + 'inside the distance can still miss out. Figures are straight-line distances '
+ 'rather than a straight line, which is always longer for the same home, and ' + 'and not a catchment boundary; some authorities measure a walking route '
+ "next year's cut-off depends on next year's applicants. Always check the " + "instead, which is always longer for the same home. Next year's cut-off "
+ "school's own admissions policy."; + "depends on next year's applicants — always check the school's own "
+ 'admissions policy.';
/**
* The rings are a drawing of a number, not a boundary anyone administers.
*
* The walking-route caveat belongs to CUTOFF_CHECK_CAVEAT immediately below
* this on the page, which states it more usefully ("always longer for the same
* home"). Saying it in both places read as a stutter.
*/
export const CUTOFF_MAP_CAVEAT =
'Each ring is the straight-line cut-off for that year, drawn around the '
+ 'school. It illustrates the distance — it is not a catchment boundary.';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// When there is no figure // When there is no figure
@@ -375,3 +375,16 @@ export function cutoffCoverageNote(rows: CutoffYearRow[]): string | null {
return `Only ${published} ${published === 1 ? 'year has' : 'years have'} a published figure, ` return `Only ${published} ${published === 1 ? 'year has' : 'years have'} a published figure, `
+ 'which is too few to read as a trend.'; + '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;
}
@@ -1627,8 +1627,23 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Stack both views in one grid cell so the card sizes to the taller view — /*
toggling modes never shifts layout. */ * Stack both views in one grid cell so the card sizes to the taller view —
* toggling modes never shifts layout.
*
* This only works while the views are of COMPARABLE height, and it fails
* silently rather than loudly when they are not. The hidden view keeps its
* box (display stays block below; only visibility is dropped), so the grid
* row is sized by the tallest view and every other view is padded out to
* match it. A cut-off-distance view carrying a chart, a table and a map was
* added here and came to 1402px against the tile grid's 316px, which left
* the DEFAULT view — the one nobody had clicked — as four tiles floating in
* ~1080px of blank card.
*
* Anything substantially taller than the tile grid belongs in its own
* section, not in this viewport. That is where the distance detail now
* lives; see DistanceSection.
*/
.admissionsViewport { .admissionsViewport {
display: grid; display: grid;
} }
@@ -2191,17 +2206,11 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.cutoffMapTitle {
margin: 0 0 0.85rem;
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.cutoffMapFigure { .cutoffMapFigure {
height: 320px; /* Enough to read a set of concentric rings and no more — this is a
diagram of a number, not a map anyone navigates by. */
height: 240px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -2267,16 +2276,18 @@
/* ── Postcode check ── */ /* ── Postcode check ── */
/* The check now leads the panel, so it carries no divider of its own — the
rule above it belonged to a map that used to sit there. */
.cutoffCheck { .cutoffCheck {
margin-top: 1.5rem; margin-top: 0;
padding-top: 1.25rem;
border-top: 1px solid var(--border);
} }
.cutoffCheckTitle { .cutoffCheckTitle {
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1rem; /* Now the panel's only heading (an h3), so it carries the weight the
retired .cutoffMapTitle used to. */
font-size: 1.1rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -2441,6 +2452,39 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.cutoffMapFigure { .cutoffMapFigure {
height: 260px; height: 200px;
} }
} }
/* ── Map, revealed on request ── */
.cutoffMapToggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 1rem;
padding: 0.5rem 0.9rem;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
color: var(--brand-strong);
background: none;
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.cutoffMapToggle:hover {
border-color: var(--brand);
background: var(--brand-bg);
}
.cutoffMapToggle:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.cutoffMapReveal {
margin-top: 1rem;
}
+20 -2
View File
@@ -12,6 +12,7 @@ import type {
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
} from './types'; } from './types';
import { isSpecialSchool } from './utils'; import { isSpecialSchool } from './utils';
import { cutoffYearRows, hasCutoffDetail } from '@/components/school/lastDistanceOffered';
export interface SchoolFlagsInput { export interface SchoolFlagsInput {
schoolInfo: School; schoolInfo: School;
@@ -122,6 +123,11 @@ export interface NavItemsInput {
/** A published cut-off distance renders the Admissions section on its own, /** A published cut-off distance renders the Admissions section on its own,
* so it has to open the nav entry too. */ * so it has to open the nav entry too. */
admissionDistance: SchoolAdmissionDistance | null; 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[];
yearlyDataLength: number; yearlyDataLength: number;
} }
@@ -136,7 +142,10 @@ export interface NavItemsInput {
*/ */
export function buildNavItems( export function buildNavItems(
flags: SchoolFlags, flags: SchoolFlags,
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput, {
ofsted, admissions, admissionDistance,
admissionDistanceHistory, admissionsHistory, yearlyDataLength,
}: NavItemsInput,
): NavItem[] { ): NavItem[] {
const navItems: NavItem[] = []; const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -147,6 +156,9 @@ export function buildNavItems(
}); });
} }
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasCutoffDetail(cutoffYearRows(admissionDistanceHistory, admissionsHistory))) {
navItems.push({ id: 'distance', label: 'Distance' });
}
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' }); if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
@@ -216,12 +228,18 @@ export function computeSecondaryFlags({
*/ */
export function buildSecondaryNavItems( export function buildSecondaryNavItems(
flags: SecondaryFlags, flags: SecondaryFlags,
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput, {
ofsted, admissions, admissionDistance,
admissionDistanceHistory, admissionsHistory, yearlyDataLength,
}: NavItemsInput,
): NavItem[] { ): NavItem[] {
const navItems: NavItem[] = []; const navItems: NavItem[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' }); if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasCutoffDetail(cutoffYearRows(admissionDistanceHistory, admissionsHistory))) {
navItems.push({ id: 'distance', label: 'Distance' });
}
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' }); if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' }); if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });