PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m20s
Rewrites every visitor-facing string that used an em dash, choosing the punctuation for what the dash was doing: a colon before a list or explanation, a comma for an aside, a full stop between two thoughts, parentheses for an aside mid-sentence. Covers page titles and meta descriptions, the home and admissions guide copy, school page headings and notes, the compare page, metric labels and tooltips. Two rewrites also fix the sentence around them: the closure banner no longer repeats "proposed for closure", and the cut-off caveat's list of priorities now parses. A lone dash marking a missing value in a table cell stays: it is a data convention, not prose. A Jest guard walks the source with the TypeScript parser and fails on any other em dash in a string or JSX text node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
6.2 KiB
TypeScript
155 lines
6.2 KiB
TypeScript
/**
|
||
* Getting a place — admissions framed the way the expert review requires:
|
||
* total applications are "named on N forms" (any preference rank, not
|
||
* head-to-head), one consistent chip metric (first-preference success),
|
||
* equal-preference and offers-vs-intake explanations up front.
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { admissionsForPhase, summariseAdmissions } from '@/lib/compareLogic';
|
||
import type { ComparisonData, School } from '@/lib/types';
|
||
import { CHART_COLORS } from '@/lib/utils';
|
||
import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||
|
||
export function CompareAdmissions({
|
||
schools,
|
||
data,
|
||
isSecondary = false,
|
||
}: {
|
||
schools: School[];
|
||
data: Record<string, ComparisonData>;
|
||
isSecondary?: boolean;
|
||
}) {
|
||
// Admissions rounds are phase-specific: an all-through school's Year 7
|
||
// round must never stand in for Reception on the primary tab (and vice
|
||
// versa) — beside pure primaries it reads as Reception odds.
|
||
const rows = schools.map((school) => admissionsForPhase(data[String(school.urn)], isSecondary));
|
||
const roundLabel = isSecondary ? 'Year 7' : 'Reception';
|
||
const anyData = rows.some(Boolean);
|
||
const entryYear = rows.find(Boolean)?.year;
|
||
const entryLabel = entryYear
|
||
? `September ${String(entryYear).slice(0, 4)} entry`
|
||
: 'the most recent admissions round';
|
||
|
||
if (!anyData) {
|
||
return (
|
||
<Section
|
||
title="Getting a place"
|
||
how={`No ${roundLabel} admissions data is available for these schools yet.`}
|
||
>
|
||
<></>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Section
|
||
title="Getting a place"
|
||
how={
|
||
<>
|
||
From the most recent admissions round ({entryLabel}). "First choice" means
|
||
families who ranked the school top of their application form (officially a "first
|
||
preference"). Schools never see your ranking: places are decided only by the
|
||
school's admission criteria, so listing a school lower down never hurts your chances.
|
||
These are National Offer Day offers. Waiting lists and appeals can change the final
|
||
intake.
|
||
</>
|
||
}
|
||
>
|
||
<SectionGrid schools={schools}>
|
||
<Measure
|
||
tip="How many application forms named the school at any preference rank. This is not the number of families competing head-to-head for a place."
|
||
label="Interest in the school"
|
||
>
|
||
{schools.map((school, i) => {
|
||
const a = rows[i];
|
||
return (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{a?.total_applications != null && a?.places_offered != null ? (
|
||
<>
|
||
Named on <strong>{a.total_applications.toLocaleString('en-GB')}</strong> forms ·{' '}
|
||
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
|
||
</>
|
||
) : (
|
||
<span className={s.small}>
|
||
We don't hold {roundLabel} admissions data for this school
|
||
</span>
|
||
)}
|
||
</Cell>
|
||
);
|
||
})}
|
||
|
||
</Measure>
|
||
|
||
<Measure label="First-choice families offered a place">
|
||
{schools.map((school, i) => {
|
||
const summary = summariseAdmissions(rows[i]);
|
||
return (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{summary.firstPrefPct != null ? (
|
||
<>
|
||
<strong>{summary.firstPrefPct}%</strong>{' '}
|
||
{summary.chip && summary.chip.tone === 'warn' && (
|
||
<Chip tone="warn">{summary.chip.text}</Chip>
|
||
)}
|
||
<span className={s.barMini}>
|
||
<i
|
||
style={{
|
||
width: `${summary.firstPrefPct}%`,
|
||
background: CHART_COLORS[i % CHART_COLORS.length],
|
||
}}
|
||
/>
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className={s.small}>No data</span>
|
||
)}
|
||
</Cell>
|
||
);
|
||
})}
|
||
|
||
</Measure>
|
||
|
||
<Measure label="What this means">
|
||
{schools.map((school, i) => {
|
||
const a = rows[i];
|
||
const summary = summariseAdmissions(a);
|
||
const info = data[String(school.urn)]?.school_info;
|
||
const selective = (info?.admissions_policy ?? '').toLowerCase() === 'selective';
|
||
const faith =
|
||
!!info?.religious_denomination &&
|
||
!/^(none|does not apply|not applicable)$/i.test(info.religious_denomination);
|
||
let text: string | null = null;
|
||
if (summary.firstPrefPct != null) {
|
||
if (selective) {
|
||
// Selective schools: the entrance test decides, whatever the
|
||
// offer percentage looks like — never the distance template.
|
||
text =
|
||
'Entry is by entrance test. The school is selective, so distance and preference rank don’t decide places.';
|
||
} else if (summary.firstPrefPct >= 100) {
|
||
text = `Every family who put ${school.school_name} first got a place.`;
|
||
} else if (summary.firstPrefPct >= 90) {
|
||
text = `Nearly every family who put ${school.school_name} first got a place.`;
|
||
} else if (a?.oversubscribed) {
|
||
text = isSecondary
|
||
? faith
|
||
? 'More first-choice applications than places. Check the school’s admission criteria (faith-based criteria may apply).'
|
||
: 'More first-choice applications than places. Check the school’s admission criteria (catchment or distance often decides, but criteria vary).'
|
||
: 'More first-choice applications than places. Check the school’s admission criteria (for most non-faith primaries, distance decides).';
|
||
} else {
|
||
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
|
||
}
|
||
}
|
||
return (
|
||
<Cell key={school.urn} school={school} index={i}>
|
||
{text ? <span className={s.small}>{text}</span> : <span className={s.small}>—</span>}
|
||
</Cell>
|
||
);
|
||
})}
|
||
</Measure>
|
||
</SectionGrid>
|
||
</Section>
|
||
);
|
||
}
|