2026-08-02 21:33:28 +01:00
|
|
|
/**
|
|
|
|
|
* AdmissionsSection — primary detail pages.
|
|
|
|
|
*
|
|
|
|
|
* Not shared with the secondary page: the two versions were only 14% similar
|
|
|
|
|
* (this one carries the year/trend toggle and the offer-rate chart; the
|
|
|
|
|
* secondary one is a much simpler panel). See SecondaryAdmissionsSection.
|
|
|
|
|
*
|
|
|
|
|
* Server component. The year/trend toggle is delegated to the small
|
|
|
|
|
* 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.
|
2026-08-20 14:54:02 +01:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-08-02 21:33:28 +01:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { ReactNode } from 'react';
|
2026-08-20 14:54:02 +01:00
|
|
|
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
2026-08-02 21:33:28 +01:00
|
|
|
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
|
|
|
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
|
|
|
|
import { Section, sectionStyles as styles } from './sectionShared';
|
2026-08-16 13:42:06 +01:00
|
|
|
import { AdmissionsViewToggle, type AdmissionsView } from './AdmissionsViewToggle';
|
2026-08-02 21:39:21 +01:00
|
|
|
import { AdmissionsTrendChart } from './charts';
|
2026-08-20 14:54:02 +01:00
|
|
|
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
2026-08-02 21:33:28 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
export function AdmissionsSection({
|
|
|
|
|
admissions,
|
|
|
|
|
admissionsHistory,
|
2026-08-15 22:48:30 +01:00
|
|
|
admissionDistance,
|
2026-08-02 21:33:28 +01:00
|
|
|
isAllThrough,
|
|
|
|
|
}: {
|
2026-08-15 22:48:30 +01:00
|
|
|
/* Nullable: the two data sources are independent. A minority of schools have
|
|
|
|
|
a published cut-off distance but no EES admissions row (3% of the schools
|
|
|
|
|
that render, sampled against staging), and for those this section exists
|
|
|
|
|
to carry the distance alone. */
|
|
|
|
|
admissions: SchoolAdmissions | null;
|
2026-08-02 21:33:28 +01:00
|
|
|
admissionsHistory: SchoolAdmissions[];
|
2026-08-15 22:48:30 +01:00
|
|
|
admissionDistance: SchoolAdmissionDistance | null;
|
2026-08-02 21:33:28 +01:00
|
|
|
isAllThrough: boolean;
|
|
|
|
|
}) {
|
2026-08-15 22:48:30 +01:00
|
|
|
const cutoff = describeCutoff(admissionDistance);
|
|
|
|
|
|
2026-08-02 21:33:28 +01:00
|
|
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
|
|
|
|
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
2026-08-15 22:48:30 +01:00
|
|
|
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
|
2026-08-02 21:33:28 +01:00
|
|
|
// Banded interpretation of the first-choice offer rate ("More than half of
|
|
|
|
|
// first choices missed out" etc.) — the same banding the compare screen
|
|
|
|
|
// uses, so a low offer rate reads as how severe it actually is.
|
2026-08-15 22:48:30 +01:00
|
|
|
const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
|
2026-08-02 21:33:28 +01:00
|
|
|
|
2026-08-15 22:48:30 +01:00
|
|
|
const title = (
|
|
|
|
|
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
|
|
|
|
|
);
|
2026-08-02 21:33:28 +01:00
|
|
|
|
|
|
|
|
{/* All-through admissions data covers a single entry point (usually the
|
|
|
|
|
Year 7 secondary intake), not reception — say so, or a parent could
|
|
|
|
|
read these as the whole-school figures. */}
|
2026-08-15 22:48:30 +01:00
|
|
|
const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
|
2026-08-02 21:33:28 +01:00
|
|
|
<p className={styles.sectionSubtitle}>
|
|
|
|
|
These figures are for {admissions.school_phase.toLowerCase()} entry
|
|
|
|
|
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
|
|
|
|
</p>
|
|
|
|
|
) : null;
|
|
|
|
|
|
2026-08-15 22:48:30 +01:00
|
|
|
{/* Spans both columns rather than taking a half-width cell. This is the
|
|
|
|
|
figure parents come to the page for, and at tile width the two-line
|
|
|
|
|
"0.31 miles / September 2025" pairing wraps badly. */}
|
2026-08-20 21:01:28 +01:00
|
|
|
{/* The {' '} between the figure and its metric support is not decoration.
|
|
|
|
|
The two are flex children, so the gap is drawn by CSS and the text layer
|
|
|
|
|
had nothing between them: textContent read "0.88 miles1.4 km", which is
|
|
|
|
|
what a screen reader announces and what any text matcher sees. Whitespace
|
|
|
|
|
text nodes are not rendered as flex items, so this changes the reading
|
|
|
|
|
without changing the layout. */}
|
2026-08-15 22:48:30 +01:00
|
|
|
const distanceTile = cutoff && (
|
|
|
|
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
|
|
|
|
|
<dd className={styles.admissionsTileNum}>
|
2026-08-20 21:01:28 +01:00
|
|
|
{cutoff.primary}{' '}
|
2026-08-15 22:48:30 +01:00
|
|
|
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
|
|
|
|
|
</dd>
|
|
|
|
|
<dt className={styles.admissionsTileLabel}>
|
|
|
|
|
Last distance offered · {cutoff.entryYear}
|
|
|
|
|
</dt>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const distanceNote = cutoff && (
|
|
|
|
|
<p className={styles.admissionsDistanceNote}>
|
|
|
|
|
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
|
|
|
|
{cutoff.routeNote && <> {cutoff.routeNote}</>}
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Which row template the tile grid needs.
|
|
|
|
|
*
|
|
|
|
|
* The grid reserves two equal rows so the year view matches the height of the
|
|
|
|
|
* trend chart beside it. With a cut-off and no EES admissions there are no
|
|
|
|
|
* metric tiles to fill them, and the reserved rows render as a bare block of
|
|
|
|
|
* the grid's own gap colour under the distance tile.
|
|
|
|
|
*/
|
|
|
|
|
const hasMetricTiles = admissions != null && (
|
|
|
|
|
admissions.places_offered != null
|
|
|
|
|
|| admissions.first_preference_applications != null
|
|
|
|
|
|| admissions.first_preference_offer_pct != null
|
|
|
|
|
|| admissions.total_applications != null
|
|
|
|
|
);
|
|
|
|
|
const tilesClass = [
|
|
|
|
|
styles.admissionsTiles,
|
|
|
|
|
cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '',
|
|
|
|
|
cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '',
|
|
|
|
|
].filter(Boolean).join(' ');
|
|
|
|
|
|
2026-08-02 21:33:28 +01:00
|
|
|
const yearView = (
|
|
|
|
|
<>
|
2026-08-15 22:48:30 +01:00
|
|
|
<dl className={tilesClass}>
|
|
|
|
|
{admissions?.places_offered != null && (
|
2026-08-02 21:33:28 +01:00
|
|
|
<div className={styles.admissionsTile}>
|
|
|
|
|
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
|
|
|
|
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-15 22:48:30 +01:00
|
|
|
{admissions?.first_preference_applications != null && (
|
2026-08-02 21:33:28 +01:00
|
|
|
<div className={styles.admissionsTile}>
|
|
|
|
|
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
|
|
|
|
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-15 22:48:30 +01:00
|
|
|
{admissions?.first_preference_offer_pct != null && (
|
2026-08-02 21:33:28 +01:00
|
|
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
|
|
|
|
<dd className={styles.admissionsTileNum}>
|
|
|
|
|
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
|
|
|
|
<>
|
|
|
|
|
{admissions.first_preference_offers}
|
|
|
|
|
<span className={styles.admissionsTileSub}>
|
|
|
|
|
of {admissions.first_preference_applications} · {formatPercentage(admissions.first_preference_offer_pct)}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
formatPercentage(admissions.first_preference_offer_pct)
|
|
|
|
|
)}
|
|
|
|
|
</dd>
|
|
|
|
|
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-15 22:48:30 +01:00
|
|
|
{admissions?.total_applications != null && (
|
2026-08-02 21:33:28 +01:00
|
|
|
<div className={styles.admissionsTile}>
|
|
|
|
|
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
|
|
|
|
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-15 22:48:30 +01:00
|
|
|
{distanceTile}
|
2026-08-02 21:33:28 +01:00
|
|
|
</dl>
|
2026-08-15 22:48:30 +01:00
|
|
|
{admissionsSummary?.chip && (
|
2026-08-02 21:33:28 +01:00
|
|
|
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
|
|
|
|
)}
|
2026-08-15 22:48:30 +01:00
|
|
|
{distanceNote}
|
2026-08-02 21:33:28 +01:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-15 22:48:30 +01:00
|
|
|
{/* Guarded rather than asserted: showAdmissionsTrend already requires
|
|
|
|
|
admissions, and tying the two together in one expression keeps that
|
|
|
|
|
invariant checked by the compiler instead of assumed. */}
|
|
|
|
|
const trendView = admissions && (
|
2026-08-02 21:33:28 +01:00
|
|
|
<>
|
|
|
|
|
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
|
|
|
|
<AdmissionsTrendChart history={admissionsHistory} />
|
|
|
|
|
<p className={styles.admissionsTrendSummary}>
|
|
|
|
|
This year ({formatAcademicYear(admissions.year)}),{' '}
|
|
|
|
|
{admissions.first_preference_applications != null && (
|
|
|
|
|
<><strong>{admissions.first_preference_applications}</strong> families put it first for </>
|
|
|
|
|
)}
|
|
|
|
|
{admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
|
2026-09-24 22:04:27 +01:00
|
|
|
{admissions.total_applications != null && ` (${admissions.total_applications.toLocaleString()} applications in total)`}.
|
2026-08-02 21:33:28 +01:00
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-16 13:42:06 +01:00
|
|
|
const views: AdmissionsView[] = [
|
|
|
|
|
{ id: 'year', label: 'This year', content: yearView, className: styles.admissionsViewYear },
|
|
|
|
|
];
|
|
|
|
|
if (showAdmissionsTrend) {
|
|
|
|
|
views.push({
|
|
|
|
|
id: 'trend',
|
|
|
|
|
label: `${admissionsHistory.length}-year trend`,
|
|
|
|
|
content: trendView,
|
|
|
|
|
className: styles.admissionsViewTrend,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 21:33:28 +01:00
|
|
|
return (
|
|
|
|
|
<Section id="admissions">
|
2026-08-16 13:42:06 +01:00
|
|
|
{views.length > 1 ? (
|
|
|
|
|
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
|
2026-08-02 21:33:28 +01:00
|
|
|
) : (
|
2026-08-16 13:42:06 +01:00
|
|
|
/* One view — render statically, with no client component at all. */
|
2026-08-02 21:33:28 +01:00
|
|
|
<>
|
|
|
|
|
<div className={styles.admissionsHeader}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>{title}</h2>
|
|
|
|
|
</div>
|
|
|
|
|
{subtitle}
|
|
|
|
|
<div className={styles.admissionsViewport}>
|
|
|
|
|
<div className={styles.admissionsViewYear}>{yearView}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Section>
|
|
|
|
|
);
|
|
|
|
|
}
|