PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 59s
The failing journey was wrong; the page was correct.
Locator: getByRole('button', { name: 'Distance' })
Expected: 0 Received: 1
getByRole matches accessible names by case-insensitive SUBSTRING, so
{ name: 'Distance' } matched <button>Show this distance on a map</button> —
the map toggle added by the same feature. The assertion was meant to say "no
Distance tab in the admissions segmented control" and instead said "no button
anywhere whose label contains the word distance".
Now scoped to the control it is about, via its own aria-label, and read
positively: the tab list must contain "This year" and must not contain
"Distance". An absence check against an unscoped locator passes for the wrong
reason the moment the selector stops matching, which is exactly how the
regression this test guards would return unnoticed.
Two sibling locators had the same weakness and are tightened: 'Check' is a
prefix of the button's own busy label "Checking…", and the figure matcher
accepted `(miles|m)` — a leftover from the mixed-unit era that would have kept
passing if the headline regressed to metres, which is the thing #105 just
fixed.
Tightening that matcher surfaced a real defect behind it. The cut-off figure
and its metric support are flex children with the gap drawn by CSS and nothing
between them in the text layer, so the element read "0.88 miles1.4 km" —
what a screen reader announces, and why a `miles\b` boundary could never
match. Both templates now carry an explicit space. Whitespace text nodes are
not rendered as flex items, so the reading changes and the layout does not.
Verified against staging: 54/54.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
214 lines
9.2 KiB
TypeScript
214 lines
9.2 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. */}
|
|
{/* 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. */}
|
|
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>
|
|
);
|
|
}
|