PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
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) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height of every section put together, and nearly three times the next largest — with its default view rendering as four tiles adrift in about 1080px of blank card. The cause was a layout trick meeting content it was never sized for. The admissions views are stacked in one grid cell so switching them never shifts layout, and the hidden ones keep their box: only visibility is dropped. That works while the views are comparable. The distance view added in #102 carries a chart, a table and a map, came to 1402px against the tile grid's 316px, and pinned every other view to its height — including the one that renders by default, which nobody had clicked. Rather than only unpinning it, the detail moves out. Every other topic on the page 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 now lives in a Distance section directly below. admissions 1503px -> 554px distance new -> 743px (median section on the page is ~528px) Three further changes, each of which also makes the content better rather than only shorter: * The map renders on request. Before a postcode is entered it is a circle drawn round a school, and it costs a Leaflet bundle and 240px to say so; a successful check opens it automatically, which is the point at which it starts answering something. Map height 320px -> 240px. * The chart appears only at the four published years that let the summary state a direction. Below that we already refuse to call the series a trend, and a line through three points asserts one regardless of what the sentence beneath it admits. The table carries those years anyway, with the reasons a line cannot show. * Three caveat paragraphs become one. They said walking-route twice and made the same point about priorities in two voices. It now sits in CutoffDistanceDetail rather than inside the check, so it still renders for a school with coordinates missing, where there is a table but no map and no check. The new e2e guard asserts no section exceeds 2.5x the median section height. Measuring the card's internals cannot catch this: the tile grid is flex: 1, so it absorbs the stretch and every box still looks full. The first version of this test targeted an arbitrary primary, passed against the live bug, and proved nothing; pointed at a school that actually holds cut-off history it fails on staging with "#admissions is 1459px against a 526px median". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
208 lines
8.7 KiB
TypeScript
208 lines
8.7 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* 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 { 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 { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
|
|
|
|
|
export function AdmissionsSection({
|
|
admissions,
|
|
admissionsHistory,
|
|
admissionDistance,
|
|
isAllThrough,
|
|
}: {
|
|
/* 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;
|
|
admissionsHistory: SchoolAdmissions[];
|
|
admissionDistance: SchoolAdmissionDistance | null;
|
|
isAllThrough: boolean;
|
|
}) {
|
|
const cutoff = describeCutoff(admissionDistance);
|
|
|
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
|
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
|
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
|
|
// 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.
|
|
const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
|
|
|
|
const title = (
|
|
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
|
|
);
|
|
|
|
{/* 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. */}
|
|
const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
|
|
<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;
|
|
|
|
{/* 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. */}
|
|
const distanceTile = cutoff && (
|
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
|
|
<dd className={styles.admissionsTileNum}>
|
|
{cutoff.primary}
|
|
<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(' ');
|
|
|
|
const yearView = (
|
|
<>
|
|
<dl className={tilesClass}>
|
|
{admissions?.places_offered != null && (
|
|
<div className={styles.admissionsTile}>
|
|
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
|
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
|
</div>
|
|
)}
|
|
{admissions?.first_preference_applications != null && (
|
|
<div className={styles.admissionsTile}>
|
|
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
|
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
|
</div>
|
|
)}
|
|
{admissions?.first_preference_offer_pct != null && (
|
|
<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>
|
|
)}
|
|
{admissions?.total_applications != null && (
|
|
<div className={styles.admissionsTile}>
|
|
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
|
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
|
</div>
|
|
)}
|
|
{distanceTile}
|
|
</dl>
|
|
{admissionsSummary?.chip && (
|
|
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
|
)}
|
|
{distanceNote}
|
|
</>
|
|
);
|
|
|
|
{/* 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 && (
|
|
<>
|
|
<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</>}
|
|
{admissions.total_applications != null && ` — ${admissions.total_applications.toLocaleString()} applications in total`}.
|
|
</p>
|
|
</>
|
|
);
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<Section id="admissions">
|
|
{views.length > 1 ? (
|
|
<AdmissionsViewToggle title={title} subtitle={subtitle} views={views} />
|
|
) : (
|
|
/* One view — render statically, with no client component at all. */
|
|
<>
|
|
<div className={styles.admissionsHeader}>
|
|
<h2 className={styles.sectionTitle}>{title}</h2>
|
|
</div>
|
|
{subtitle}
|
|
<div className={styles.admissionsViewport}>
|
|
<div className={styles.admissionsViewYear}>{yearView}</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|