Compare commits

...
Author SHA1 Message Date
TudorandClaude Fable 5 5944d88f0b 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
2026-07-17 12:40:42 +01:00
tudor 163b501be6 Merge pull request 'fix(compare): stale-basket fetch blanking comparisons; rail caption' (#52) from fix/compare-final-review-mustfix into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 48s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 43s
Reviewed-on: #52
2026-07-17 06:28:27 +00:00
TudorandClaude Fable 5 80176cac4d fix(compare): drop the merge-on-fetch — cancellation alone fixes the race
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
Review finding: merging left a re-added school's old entry in the map
forever, so the covered check served stale data and the map grew
unboundedly. The cancelled flag already discards superseded responses,
which is the actual race fix; replacing keeps the map bounded to the
current selection and guarantees fresh refetches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 07:27:10 +01:00
TudorandClaude Fable 5 84baf95f68 fix(compare): stale basket fetch no longer blanks a freshly opened comparison
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m23s
Opening a compare link while localStorage held a different basket raced a
fetch for the OLD school set against the URL's SSR data; the stale
response replaced comparisonData, so no active school had data and every
section (including the trends chart) vanished until a hard refresh.
Responses from superseded effect runs are now dropped, successful ones
merge instead of replace, and the URL-seed effect re-runs when a
client-side navigation changes ?urns=.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 07:16:04 +01:00
TudorandClaude Fable 5 99b769ca9e feat(compare): fill the sticky bar's label rail with a comparison caption
'COMPARING / 3 primary schools' — 0.72rem uppercase eyebrow over a
0.95rem semibold count, sized to sit alongside the 0.92rem chip names
without dominating. Desktop only; mobile pills unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-16 21:27:25 +01:00
14 changed files with 490 additions and 15 deletions
+3
View File
@@ -740,6 +740,9 @@ async def compare_schools(
"religious_denomination": convert_to_native(latest.get("religious_denomination")),
"age_range": convert_to_native(latest.get("age_range")),
"gender": convert_to_native(latest.get("gender")),
# Needed by the admissions "What this means" copy: selective
# schools get entrance-test framing, never the distance template.
"admissions_policy": convert_to_native(latest.get("admissions_policy")),
"has_sixth_form": convert_to_native(latest.get("has_sixth_form")),
"capacity": convert_to_native(latest.get("capacity")),
"gias_total_pupils": convert_to_native(latest.get("gias_total_pupils")),
+32
View File
@@ -204,6 +204,8 @@ test('comparing two schools shows the parent-first sections side by side', async
.locator('[aria-label="Schools in this comparison"]')
.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
expect(barTemplate).toMatch(/^200px /);
// ...and its label rail carries the comparison caption.
await expect(page.getByText(/^\d+ (primary|secondary) schools?$/)).toBeVisible();
// Ofsted linkout goes to the school's provider page, never a report deep-link
const ofstedLink = page.getByRole('link', { name: /Ofsted page/i }).first();
@@ -235,6 +237,36 @@ test('comparing two secondary schools renders the secondary sections', async ({
// A KS4 measure proves the secondary academics variant rendered.
await expect(page.getByText(/Attainment 8/i).first()).toBeVisible();
await expect(page.getByText(/No primary schools in your comparison/)).toHaveCount(0);
// The admissions template must be phase-aware: the primaries' distance
// copy ("non-faith primaries") must never appear on a secondary comparison
// (expert sign-off must-fix M3).
await expect(page.getByText(/non-faith primaries/)).toHaveCount(0);
});
test('opening a different compare link after a previous comparison still renders', async ({ page }) => {
// Regression: the first visit stores a basket in localStorage; opening a
// link for a DIFFERENT school set then raced a stale fetch for the stored
// basket against the new SSR data, blanking every section (including the
// trends chart) until a hard refresh.
const [s0, s1] = await twoSecondaryUrns(page);
const [p0, p1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${s0},${s1}`);
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
timeout: 15_000,
});
await page.goto(`/compare?urns=${p0},${p1}`);
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible({
timeout: 15_000,
});
// Give any straggling stale response time to land, then confirm the new
// comparison is still on screen.
await page.waitForTimeout(1500);
await expect(page.getByRole('heading', { name: 'At a glance' }).first()).toBeVisible();
await expect(page.getByRole('heading', { name: 'Explore trends' }).first()).toBeVisible();
await expect(page.locator(`a[href*="${p0}"]`).first()).toBeVisible();
});
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
@@ -0,0 +1,140 @@
/**
* Getting a place — phase and school-type correctness (expert sign-off
* must-fixes M1/M3):
* - an all-through school's Year 7 round must never render on the primary
* tab as if it were Reception odds;
* - selective schools get entrance-test framing, and the secondary tab
* never shows the primaries' distance template.
*/
import { render, screen } from '@testing-library/react';
import { CompareAdmissions } from '@/components/compare/CompareAdmissions';
import type { ComparisonData, School, SchoolAdmissions } from '@/lib/types';
function school(urn: number, name: string, extra: Partial<School> = {}): School {
return { urn, school_name: name, ...extra } as School;
}
function admissions(partial: Partial<SchoolAdmissions>): SchoolAdmissions {
return {
year: 202627,
school_phase: 'Secondary',
places_offered: 173,
total_applications: 433,
first_preference_offer_pct: 83,
oversubscribed: true,
...partial,
} as SchoolAdmissions;
}
function entry(info: School, a: SchoolAdmissions | null): ComparisonData {
return {
school_info: info,
yearly_data: [],
ofsted: null,
census: null,
admissions: a,
admissions_history: a ? [a] : [],
deprivation: null,
};
}
describe('CompareAdmissions', () => {
it("does not show an all-through school's Year 7 round on the primary tab", () => {
// The real M1 scenario: an all-through school (Year 7 round only) beside
// a primary with a Reception round.
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const primary = school(138690, 'Barclay Primary School');
const data = {
'137306': entry(allThrough, admissions({ school_phase: 'Secondary' })),
'138690': entry(
primary,
admissions({
school_phase: 'Primary',
total_applications: 300,
places_offered: 120,
first_preference_offer_pct: 96,
}),
),
};
render(<CompareAdmissions schools={[allThrough, primary]} data={data} isSecondary={false} />);
// Hessle's Year 7 figures must not appear…
expect(screen.queryByText('433')).toBeNull();
expect(
screen.getByText(/We don't hold Reception admissions data for this school/),
).toBeInTheDocument();
// …while Barclay's Reception round renders normally.
expect(screen.getByText('300')).toBeInTheDocument();
});
it('phase-labels the section empty state when no matching round exists at all', () => {
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={false} />);
expect(
screen.getByText(/No Reception admissions data is available for these schools yet/),
).toBeInTheDocument();
expect(screen.queryByText('433')).toBeNull();
});
it('shows the Year 7 round on the secondary tab', () => {
const allThrough = school(137306, 'Hessle High and Penshurst Primary');
const data = { '137306': entry(allThrough, admissions({ school_phase: 'Secondary' })) };
render(<CompareAdmissions schools={[allThrough]} data={data} isSecondary={true} />);
expect(screen.getByText('433')).toBeInTheDocument();
expect(screen.getByText('173')).toBeInTheDocument();
});
it('gives selective schools entrance-test framing, never the distance template', () => {
const grammar = school(136276, 'Watford Grammar School for Boys', {
admissions_policy: 'Selective',
religious_denomination: 'Church of England',
});
const data = {
'136276': entry(grammar, admissions({ first_preference_offer_pct: 43.7 })),
};
render(<CompareAdmissions schools={[grammar]} data={data} isSecondary={true} />);
expect(
screen.getByText(/Entry is by entrance test — the school is selective/),
).toBeInTheDocument();
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
});
it('secondary faith school gets faith-aware copy, not the primaries template', () => {
const faithSchool = school(102052, "Bishop Stopford's School", {
admissions_policy: 'Non-selective',
religious_denomination: 'Church of England',
});
const data = {
'102052': entry(faithSchool, admissions({ first_preference_offer_pct: 68 })),
};
render(<CompareAdmissions schools={[faithSchool]} data={data} isSecondary={true} />);
expect(screen.getByText(/faith-based criteria may apply/)).toBeInTheDocument();
expect(screen.queryByText(/non-faith primaries/)).toBeNull();
});
it('keeps the reviewed distance copy for oversubscribed non-faith primaries', () => {
const primary = school(100140, 'Plumcroft Primary School');
const data = {
'100140': entry(
primary,
admissions({ school_phase: 'Primary', first_preference_offer_pct: 73.4 }),
),
};
render(<CompareAdmissions schools={[primary]} data={data} isSecondary={false} />);
expect(screen.getByText(/for most non-faith primaries, distance decides/)).toBeInTheDocument();
});
});
@@ -96,6 +96,31 @@ describe('CompareOfsted', () => {
expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1');
});
it('never renders Ofsted sentinel codes (9 = not applicable) as judgement chips', () => {
const sentinelSchool = school(6, 'Sentinel School');
const sentinelData: Record<string, ComparisonData> = {
'6': {
school_info: sentinelSchool,
yearly_data: [],
ofsted: ofsted({
overall_effectiveness: 2,
grade_source: 'graded',
quality_of_education: 1,
early_years_provision: 9,
sixth_form_provision: 2,
}),
},
};
render(<CompareOfsted schools={[sentinelSchool]} data={sentinelData} />);
// Real grades render…
expect(screen.getByText('Quality of education')).toBeInTheDocument();
// …the applicable sixth-form judgement renders (was previously dropped)…
expect(screen.getByText('Sixth form provision')).toBeInTheDocument();
// …and the not-applicable sentinel never appears, neither as area nor code.
expect(screen.queryByText('Early years provision')).toBeNull();
expect(screen.queryByText('9')).toBeNull();
});
it('dates a report card with the report-card inspection date, never the legacy date', () => {
const cardSchool = school(4, 'Dated Card School');
const cardData: Record<string, ComparisonData> = {
@@ -72,5 +72,7 @@ test('an all-secondary comparison renders the sections, not an empty primary tab
});
expect(screen.getAllByText('Gamma High').length).toBeGreaterThan(0);
expect(screen.queryByText(/No primary schools in your comparison/)).toBeNull();
// The sticky bar's rail caption reflects the active phase and count.
expect(screen.getByText('2 secondary schools')).toBeInTheDocument();
expect(fetchComparison).not.toHaveBeenCalled();
});
@@ -0,0 +1,107 @@
/**
* Regression: opening a compare link while a DIFFERENT basket is stored must
* not blank the page.
*
* The basket hydrates from localStorage first, which can fire a fetch for the
* OLD school set; the URL-seed effect then replaces the basket with the URL's
* schools (already covered by SSR data, so no new fetch). When the stale
* response for the old set finally lands, it must not clobber the fresh SSR
* data — that left every section (including the trends chart) empty until a
* hard refresh.
*/
import { act, render, screen, waitFor } from '@testing-library/react';
import { ComparisonView } from '@/components/ComparisonView';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import type { ComparisonData, School } from '@/lib/types';
const fetchComparison = jest.fn();
jest.mock('@/lib/api', () => ({
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
function school(urn: number, name: string): School {
return {
urn,
school_name: name,
local_authority: 'Testshire',
school_type: 'Community school',
rwm_expected_pct: 80,
phase: 'Primary',
} as School;
}
function data(urn: number, name: string): ComparisonData {
return {
school_info: school(urn, name),
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
// The visitor's previously stored basket (a different school entirely).
const STORED_SCHOOL = school(900, 'Old Stored School');
// The comparison the URL (and SSR) actually asked for.
const URL_DATA = {
'100': data(100, 'Alpha Primary'),
'200': data(200, 'Beta Primary'),
};
beforeEach(() => {
fetchComparison.mockReset();
localStorage.clear();
});
test('a stale fetch for the previously stored basket does not clobber the URL comparison', async () => {
localStorage.setItem('selectedSchools', JSON.stringify([STORED_SCHOOL]));
const pending: Array<(v: unknown) => void> = [];
fetchComparison.mockImplementation(() => new Promise((resolve) => pending.push(resolve)));
render(
<ComparisonProvider>
<ComparisonView
initialData={URL_DATA}
initialNationalAverages={{
year: 202425,
primary: { rwm_expected_pct: 62 },
secondary: {},
by_year: [],
}}
initialBenchmarks={undefined}
initialUrns={[100, 200]}
metrics={[]}
selectedMetric="rwm_expected_pct"
/>
</ComparisonProvider>,
);
// The URL's schools render from SSR data once the basket is reseeded.
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
});
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
// The transient stored-basket fetch (for school 900) resolves LATE, after
// the basket has moved on to the URL's schools.
await act(async () => {
for (const resolve of pending) {
resolve({
comparison: { '900': data(900, 'Old Stored School') },
national_averages: { year: 202425, primary: {}, secondary: {}, by_year: [] },
benchmarks: undefined,
});
}
});
// The page must still show the URL comparison — not go blank.
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
});
@@ -7,6 +7,7 @@
import {
OFSTED_LEGACY_GRADES,
admissionsForPhase,
ofstedDisplay,
progressBand,
rcAreaLabel,
@@ -188,6 +189,44 @@ describe('summariseAdmissions', () => {
});
});
describe('admissionsForPhase', () => {
const row = (year: number, school_phase: string | null): SchoolAdmissions =>
({ year, school_phase, places_offered: 100, total_applications: 200, first_preference_offer_pct: 80 }) as SchoolAdmissions;
it('returns the latest round matching the active phase', () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary'), row(202526, 'Primary'), row(202425, 'Primary')],
};
expect(admissionsForPhase(data, true)?.year).toBe(202627);
expect(admissionsForPhase(data, false)?.year).toBe(202526);
expect(admissionsForPhase(data, false)?.school_phase).toBe('Primary');
});
it("never substitutes the other phase's round (all-through with Year 7 data only)", () => {
const data = {
admissions: row(202627, 'Secondary'),
admissions_history: [row(202526, 'Secondary')],
};
expect(admissionsForPhase(data, false)).toBeNull();
expect(admissionsForPhase(data, true)?.year).toBe(202627);
});
it('uses untagged legacy rows only when no row carries a phase', () => {
const untagged = { admissions: row(202627, null), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(untagged, false)?.year).toBe(202627);
expect(admissionsForPhase(untagged, true)?.year).toBe(202627);
const mixed = { admissions: row(202627, 'Secondary'), admissions_history: [row(202526, null)] };
expect(admissionsForPhase(mixed, false)).toBeNull();
});
it('handles missing data', () => {
expect(admissionsForPhase(null, false)).toBeNull();
expect(admissionsForPhase({ admissions: null, admissions_history: [] }, true)).toBeNull();
});
});
describe('progressBand', () => {
it('CI entirely above zero → above', () => {
expect(progressBand(1.2, 0.4, 2.0)).toBe('above');
@@ -148,10 +148,16 @@
text-overflow: ellipsis;
}
/* Caption filling the label rail on desktop ("Comparing / 3 primary
schools"). Hidden on mobile, where the bar is a row of compact pills. */
.barCaption {
display: none;
}
/* Desktop (matches the sections' 761px breakpoint): the bar adopts the same
grid template as compareSections' .grid — a 200px row-label rail plus one
column per school — so each chip sits exactly over the column it labels.
The first chip starts after the empty label rail. */
The caption occupies the rail; chips flow into the school columns. */
@media (min-width: 761px) {
.schoolBar {
display: grid;
@@ -164,8 +170,29 @@
min-width: 0;
}
.schoolChip:first-child {
grid-column: 2;
.barCaption {
grid-column: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.1rem;
padding-right: 0.5rem;
min-width: 0;
}
.barCaptionEyebrow {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
}
.barCaptionCount {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary, #1a1612);
}
}
+29 -2
View File
@@ -85,7 +85,9 @@ export function ComparisonView({
replaceSchools(urlSchools);
}
}
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
// Re-seed when a client-side navigation lands on a different ?urns= set
// (initialUrns/initialData are new props on the same component instance).
}, [isInitialized, initialUrns.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
const urnKey = selectedSchools.map((s) => s.urn).join(',');
@@ -128,8 +130,18 @@ export function ComparisonView({
const covered = urnKey.split(',').every((urn) => have[urn] != null);
if (covered) return;
// Guard against out-of-order responses: while the basket hydrates from
// localStorage it can transiently hold a DIFFERENT school set than the
// URL, firing a fetch for schools the user is no longer comparing. That
// stale response must not replace data for the current set — it blanked
// every section until a hard refresh. Cleanup marks the run cancelled
// when urnKey moves on, so only the current selection's response is
// applied (replacing the map keeps it bounded and guarantees a re-added
// school is refetched fresh rather than served a lingering old entry).
let cancelled = false;
fetchComparison(urnKey, { cache: 'no-store' })
.then((data) => {
if (cancelled) return;
setComparisonData(data.comparison);
setNationalAverages(data.national_averages);
setBenchmarks(data.benchmarks);
@@ -140,6 +152,9 @@ export function ComparisonView({
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err);
});
return () => {
cancelled = true;
};
}, [urnKey, isInitialized]);
const primarySchools = selectedSchools.filter((school) => {
@@ -350,6 +365,14 @@ export function ComparisonView({
style={{ '--school-count': activeSchools.length } as CSSProperties}
aria-label="Schools in this comparison"
>
{/* Fills the 200px label rail on desktop (hidden on mobile). */}
<div className={styles.barCaption}>
<span className={styles.barCaptionEyebrow}>Comparing</span>
<span className={styles.barCaptionCount}>
{activeSchools.length} {comparePhase} school
{activeSchools.length === 1 ? '' : 's'}
</span>
</div>
{activeSchools.map((school, index) => (
<div
key={school.urn}
@@ -399,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 (
+35
View File
@@ -143,6 +143,41 @@ export interface AdmissionsSummary {
interest: string | null;
}
/**
* Pick the admissions round for the ACTIVE phase tab. An all-through school
* can carry only a Year 7 (Secondary) round — rendering that beside pure
* primaries' Reception rounds made 433-forms-for-173-places read as
* Reception odds. Rows matching the target phase win (latest year first);
* rows tagged with the OTHER phase are never substituted. Untagged rows
* (legacy data, no school_phase) are used only when no row carries a phase.
*/
export function admissionsForPhase(
data:
| { admissions?: SchoolAdmissions | null; admissions_history?: SchoolAdmissions[] }
| null
| undefined,
isSecondary: boolean,
): SchoolAdmissions | null {
if (!data) return null;
const rows: SchoolAdmissions[] = [
...(data.admissions_history ?? []),
...(data.admissions ? [data.admissions] : []),
];
if (rows.length === 0) return null;
const target = isSecondary ? 'secondary' : 'primary';
const byYearDesc = (a: SchoolAdmissions, b: SchoolAdmissions) => (b.year ?? 0) - (a.year ?? 0);
const matching = rows
.filter((r) => r.school_phase?.toLowerCase() === target)
.sort(byYearDesc);
if (matching.length > 0) return matching[0];
const tagged = rows.some((r) => r.school_phase != null);
if (!tagged) return [...rows].sort(byYearDesc)[0];
return null;
}
export function summariseAdmissions(
a: SchoolAdmissions | null | undefined,
): AdmissionsSummary {
+2
View File
@@ -87,6 +87,8 @@ export interface OfstedInspection {
quality_of_education: number | null;
behaviour_attitudes: number | null;
personal_development: number | null;
/** Sixth-form judgement where applicable; sentinel 9 = not applicable. */
sixth_form_provision?: number | null;
leadership_management: number | null;
early_years_provision: number | null;
previous_overall: number | null;