PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
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 2m39s
Completes the last-distance-offered feature against the mockup: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them. Serving the history The first cut deliberately served only the latest year, because a plain series would draw a trend line straight through gaps that are absences of publication, not of a cut-off. That reasoning is answered rather than abandoned: cutoffYearRows classifies every year in the span, and the chart breaks the line rather than interpolating across it. A missing year is not one fact but three. It may be unpublished; it may be a year the school was not oversubscribed; or there may be no record at all. Collapsing them into "no data" throws away the reassuring case and hides the important caveat, so each is stated in words in the table. The claim is held to what the data supports. fact_admissions.oversubscribed compares FIRST PREFERENCES against places, which does not establish that every applicant was offered one — so the copy says "places available on first preferences" and a test asserts the stronger claim never appears. The trend summary is not a verdict It names both endpoints and their years and lets the reader conclude. It is withheld below four published points, and a swing under a tenth of the earlier figure is reported as "broadly the same" rather than dressed up as a direction. The postcode check This is the only place on the site that answers a question about a family rather than a school, so most of the care went into what it refuses to say. postcodes.io returns a centroid covering roughly fifteen addresses, which against a 500 m cut-off is a fifth of the whole distance — so a margin inside 100 m returns "too close to call" rather than a place a family does not have. Unpublished years count as unknown, never as a pass. The limits are stated before the check is used, not revealed with the answer. The postcode is geocoded in the browser and never stored. Both templates Banded and selective secondaries are exactly where this matters most, so the detail is shared. The primary page gives it a third tab; the secondary page is one flat panel by design and renders it inline. Absence is explained rather than reported. A selective school's missing figure is explained by how it admits; a consistently undersubscribed school reads as good news. Also makes the batch loader's test double honour ORDER BY. It was a no-op, so "latest row per URN" was really "first row in the fixture" and the test would have passed with the sort reversed or removed. Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging (the 8 cut-off journeys skip until the DAG runs). Rendered offline against the real compiled CSS in both themes and at 390px; every new surface clears WCAG AA, measured on composited pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
228 lines
9.3 KiB
TypeScript
228 lines
9.3 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.
|
|
*/
|
|
|
|
import type { ReactNode } from 'react';
|
|
import type { School, 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';
|
|
|
|
|
|
export function AdmissionsSection({
|
|
admissions,
|
|
admissionsHistory,
|
|
admissionDistance,
|
|
admissionDistanceHistory,
|
|
schoolInfo,
|
|
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;
|
|
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;
|
|
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 distanceView = showDistanceView && (
|
|
<CutoffDistanceDetail rows={cutoffRows} schoolInfo={schoolInfo} />
|
|
);
|
|
|
|
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,
|
|
});
|
|
}
|
|
if (showDistanceView) {
|
|
views.push({
|
|
id: 'distance',
|
|
label: 'Distance',
|
|
content: distanceView,
|
|
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>
|
|
);
|
|
}
|