fix(compare): expert sign-off must-fixes — phase-matched admissions, Ofsted sentinel codes, selective-school copy
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
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 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m53s

M1: admissions rounds are now selected by the active phase tab
(admissionsForPhase) — an all-through school's Year 7 round no longer
masquerades as Reception odds beside pure primaries; honest per-cell and
section fallbacks name the round (Reception / Year 7).
M2: Ofsted sentinel codes (9 = not applicable) never render as judgement
chips, and the sixth-form judgement — previously dropped — now renders
for schools that have one.
M3: 'What this means' is phase- and type-aware: selective schools get
entrance-test framing, secondary faith schools a faith-criteria note, and
the primaries' distance template never appears on the secondary tab
(admissions_policy now exposed in compare school_info).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
Tudor
2026-07-17 12:40:42 +01:00
co-authored by Claude Fable 5
parent 163b501be6
commit 5944d88f0b
11 changed files with 300 additions and 11 deletions
+5 -1
View File
@@ -422,7 +422,11 @@ export function ComparisonView({
benchmarks={benchmarks}
isSecondary={!isPrimary}
/>
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
<CompareAdmissions
schools={activeSchools}
data={activeComparisonData}
isSecondary={!isPrimary}
/>
<CompareCommunity
schools={activeSchools}
data={activeComparisonData}
@@ -7,7 +7,7 @@
'use client';
import { summariseAdmissions } from '@/lib/compareLogic';
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';
@@ -15,11 +15,17 @@ import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from '.
export function CompareAdmissions({
schools,
data,
isSecondary = false,
}: {
schools: School[];
data: Record<string, ComparisonData>;
isSecondary?: boolean;
}) {
const rows = schools.map((school) => data[String(school.urn)]?.admissions ?? null);
// 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
@@ -28,7 +34,10 @@ export function CompareAdmissions({
if (!anyData) {
return (
<Section title="Getting a place" how="No admissions data is available for these schools yet.">
<Section
title="Getting a place"
how={`No ${roundLabel} admissions data is available for these schools yet.`}
>
<></>
</Section>
);
@@ -63,7 +72,9 @@ export function CompareAdmissions({
<strong>{a.places_offered.toLocaleString('en-GB')}</strong> places
</>
) : (
<span className={s.small}>No data</span>
<span className={s.small}>
We don&apos;t hold {roundLabel} admissions data for this school
</span>
)}
</Cell>
);
@@ -104,15 +115,28 @@ export function CompareAdmissions({
{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 (summary.firstPrefPct >= 100) {
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; distance and preference rank dont 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 =
'More first-choice applications than places — check the schools admission criteria (for most non-faith primaries, distance decides).';
text = isSecondary
? faith
? 'More first-choice applications than places — check the schools admission criteria (faith-based criteria may apply).'
: 'More first-choice applications than places — check the schools admission criteria (catchment or distance often decides, but criteria vary).'
: 'More first-choice applications than places — check the schools admission criteria (for most non-faith primaries, distance decides).';
} else {
text = `${summary.firstPrefPct}% of first-choice families received an offer.`;
}
@@ -8,6 +8,7 @@
'use client';
import {
admissionsForPhase,
latestValues,
ofstedDisplay,
summariseAdmissions,
@@ -145,7 +146,11 @@ export function CompareAtAGlance({
<Measure label="Getting a place">
{schools.map((school, i) => {
const summary = summariseAdmissions(data[String(school.urn)]?.admissions);
// Phase-matched round only — an all-through school's Year 7 round
// must not masquerade as Reception odds on the primary tab.
const summary = summariseAdmissions(
admissionsForPhase(data[String(school.urn)], isSecondary),
);
return (
<Cell key={school.urn} school={school} index={i}>
{summary.chip ? (
@@ -108,14 +108,21 @@ function JudgementDetailCell({
);
}
const legacyAreas: Array<[string, number | null]> = [
const legacyAreas: Array<[string, number | null | undefined]> = [
['Quality of education', ofsted.quality_of_education],
['Behaviour & attitudes', ofsted.behaviour_attitudes],
['Personal development', ofsted.personal_development],
['Leadership & management', ofsted.leadership_management],
['Early years provision', ofsted.early_years_provision],
['Sixth form provision', ofsted.sixth_form_provision],
];
const published = legacyAreas.filter(([, grade]) => grade != null);
// Only real Ofsted grades (14) are judgements. The MI file uses sentinel
// codes for "not applicable / no judgement" (9, and 0/8 variants) — those
// must never render as a rating chip.
const published = legacyAreas.filter(
(entry): entry is [string, number] =>
entry[1] != null && entry[1] >= 1 && entry[1] <= 4,
);
if (published.length === 0) {
return (