Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e9eb2d48 | ||
|
|
13253116c2 | ||
|
|
1b8adb91ee | ||
|
|
ca4fc647a5 | ||
|
|
261403bcd2 | ||
|
|
e4c8b86fe7 | ||
|
|
1c1df77961 | ||
|
|
993822d769 | ||
|
|
0b36eace9d | ||
|
|
de943ded19 | ||
|
|
ae6ef6860b | ||
|
|
4e316b4e38 | ||
|
|
733faefb98 | ||
|
|
5e370d09f1 | ||
|
|
f6bb037c47 | ||
|
|
9c93c3d9c2 | ||
|
|
17bd4d5a5e | ||
|
|
31ae13451a | ||
|
|
4e6be0ce65 | ||
|
|
452ec77449 | ||
|
|
aa87fa917d | ||
|
|
e36125b24a | ||
|
|
fdda52ff0a | ||
|
|
a2c63b3cda |
@@ -148,6 +148,59 @@ test('a report-card school shows its report card, dated to the report-card inspe
|
||||
}
|
||||
});
|
||||
|
||||
test('an all-through school shows BOTH its KS2 SATs and its GCSE results, not just one phase', async ({ page }) => {
|
||||
// All-through schools carry both KS2 and KS4 data in the same yearly rows.
|
||||
// The detail view used to flip them to isSecondary and render GCSE-only,
|
||||
// hiding the primary phase. It now renders both phases and labels the school
|
||||
// "All-through".
|
||||
const AT_URN = 137306; // Hessle High School and Penshurst Primary — all-through
|
||||
const res = await page.request.get(`/api/schools/${AT_URN}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const detail = await res.json();
|
||||
const rows: Array<{ rwm_expected_pct: number | null; attainment_8_score: number | null }> =
|
||||
detail.yearly_data ?? [];
|
||||
const hasKS2 = rows.some((r) => r.rwm_expected_pct != null);
|
||||
const hasKS4 = rows.some((r) => r.attainment_8_score != null);
|
||||
test.skip(
|
||||
(detail.school_info?.phase ?? '').toLowerCase() !== 'all-through' || !hasKS2 || !hasKS4,
|
||||
'precondition: chosen URN must currently be all-through with both KS2 and KS4 results',
|
||||
);
|
||||
|
||||
await page.goto(`/school/${AT_URN}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Labelled as all-through in the hero meta.
|
||||
await expect(page.getByText(/All-through/i).first()).toBeVisible();
|
||||
|
||||
// The combined results section carries both phases.
|
||||
const results = page.locator('#results');
|
||||
await expect(results.getByText(/SATs & GCSE Results/)).toBeVisible();
|
||||
await expect(results.getByRole('heading', { name: /Primary.*KS2 SATs/ })).toBeVisible(); // KS2 block
|
||||
await expect(results.getByRole('heading', { name: /Secondary.*GCSEs/ })).toBeVisible(); // KS4 block
|
||||
});
|
||||
|
||||
test('a special school is not shown as failing against the mainstream England average', async ({ page }) => {
|
||||
// Special schools sit the same tests but very few pupils reach the mainstream
|
||||
// "expected standard", so a "0.0% · −62 pts below England average" rendering
|
||||
// portrays them as failing against a benchmark that doesn't fit. The results
|
||||
// section drops the England comparison and explains the context instead.
|
||||
const SP_URN = 101099; // Greenmead School — a community special school
|
||||
const res = await page.request.get(`/api/schools/${SP_URN}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const detail = await res.json();
|
||||
test.skip(
|
||||
!/special|pupil referral|alternative provision/i.test(detail.school_info?.school_type ?? ''),
|
||||
'precondition: chosen URN must currently be a special school',
|
||||
);
|
||||
|
||||
await page.goto(`/school/${SP_URN}`);
|
||||
const results = page.locator('#results');
|
||||
// The special-school context note is shown…
|
||||
await expect(results.getByText(/This is a special school/i)).toBeVisible({ timeout: 15_000 });
|
||||
// …and the mainstream England-average comparison is dropped entirely.
|
||||
await expect(results.getByText(/England avg/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('school with no performance data still gets a working detail page', async ({ page }) => {
|
||||
// Schools without KS2/KS4 results (special post-16 institutions, sixth-form
|
||||
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* SpecialSchoolNote Component Tests
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpecialSchoolNote } from '@/components/SpecialSchoolNote';
|
||||
|
||||
describe('SpecialSchoolNote', () => {
|
||||
it('renders nothing for a mainstream school', () => {
|
||||
const { container } = render(<SpecialSchoolNote school={{ school_type: 'Academy converter' }} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('describes a special school as teaching pupils with SEND', () => {
|
||||
render(<SpecialSchoolNote school={{ school_type: 'Community special school' }} />);
|
||||
expect(screen.getByText(/This is a special school/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/special educational needs/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT call PRU pupils SEND — they are educated outside a mainstream school', () => {
|
||||
render(<SpecialSchoolNote school={{ school_type: 'Pupil referral unit' }} />);
|
||||
expect(screen.getByText(/This is a pupil referral unit/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/educated outside a mainstream school/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT call alternative-provision pupils SEND', () => {
|
||||
render(<SpecialSchoolNote school={{ school_type: 'Academy alternative provision converter' }} />);
|
||||
expect(screen.getByText(/This is an alternative provision setting/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/special educational needs/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isValidPostcode,
|
||||
debounce,
|
||||
buildOfstedListBadge,
|
||||
ofstedLegacyAreas,
|
||||
metricKind,
|
||||
shortName,
|
||||
computeYBounds,
|
||||
@@ -111,6 +112,35 @@ describe('debounce', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ofstedLegacyAreas', () => {
|
||||
it('returns only real grades (1-4) and drops sentinel/null values', () => {
|
||||
const areas = ofstedLegacyAreas({
|
||||
quality_of_education: 1,
|
||||
behaviour_attitudes: 2,
|
||||
personal_development: null,
|
||||
leadership_management: 9, // sentinel "not applicable" — must be dropped
|
||||
early_years_provision: 9, // secondary school — not applicable
|
||||
sixth_form_provision: 2,
|
||||
});
|
||||
expect(areas.map((a) => a.label)).toEqual([
|
||||
'Quality of Teaching',
|
||||
'Behaviour in School',
|
||||
'Sixth Form',
|
||||
]);
|
||||
expect(areas.every((a) => a.value >= 1 && a.value <= 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes the sixth-form judgement where present (was previously dropped)', () => {
|
||||
const areas = ofstedLegacyAreas({ sixth_form_provision: 1 });
|
||||
expect(areas).toEqual([{ label: 'Sixth Form', value: 1 }]);
|
||||
});
|
||||
|
||||
it('includes Early Years for a primary school', () => {
|
||||
const areas = ofstedLegacyAreas({ quality_of_education: 2, early_years_provision: 1 });
|
||||
expect(areas.map((a) => a.label)).toContain('Early Years (Reception)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOfstedListBadge', () => {
|
||||
it('returns grade word + year for OEIF Outstanding', () => {
|
||||
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
|
||||
@@ -239,6 +269,44 @@ describe('isProposedToClose', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSpecialSchool', () => {
|
||||
const { isSpecialSchool } = require('@/lib/utils');
|
||||
|
||||
it('matches every DfE special-school establishment type', () => {
|
||||
for (const t of [
|
||||
'Community special school',
|
||||
'Foundation special school',
|
||||
'Academy special converter',
|
||||
'Academy special sponsor led',
|
||||
'Non-maintained special school',
|
||||
'Free schools special',
|
||||
'Other independent special school',
|
||||
]) {
|
||||
expect(isSpecialSchool({ school_type: t })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches pupil referral units and alternative provision', () => {
|
||||
expect(isSpecialSchool({ school_type: 'Pupil referral unit' })).toBe(true);
|
||||
expect(isSpecialSchool({ school_type: 'Academy alternative provision converter' })).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match mainstream types (no false positives)', () => {
|
||||
for (const t of [
|
||||
'Academy converter',
|
||||
'Community school',
|
||||
'Voluntary aided school',
|
||||
'Free schools',
|
||||
'University technical college',
|
||||
'Other independent school',
|
||||
null,
|
||||
undefined,
|
||||
]) {
|
||||
expect(isSpecialSchool({ school_type: t })).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortName', () => {
|
||||
it('drops the trailing establishment-type words', () => {
|
||||
expect(shortName('Barclay Primary School')).toBe('Barclay');
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Line } from 'react-chartjs-2';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||
import type { SchoolAdmissions } from '@/lib/types';
|
||||
import styles from './AdmissionsTrendChart.module.css';
|
||||
|
||||
@@ -17,13 +18,22 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
const pts = history.filter((h) => h.first_preference_offer_pct != null);
|
||||
if (pts.length < 2) return null;
|
||||
|
||||
const labels = pts.map((p) => formatAcademicYear(p.year));
|
||||
const values = pts.map((p) => p.first_preference_offer_pct as number);
|
||||
const lastIdx = pts.length - 1;
|
||||
// Gap-honest axis: every academic year between the first and last data point
|
||||
// appears, so a missing admissions year renders as a real gap (spanGaps:false)
|
||||
// rather than compressing time between distant years.
|
||||
const axisYears = fillAcademicYears(pts.map((p) => p.year));
|
||||
const byYear = new Map(pts.map((p) => [p.year, p.first_preference_offer_pct as number]));
|
||||
const labels = axisYears.map(formatAcademicYear);
|
||||
const values: (number | null)[] = axisYears.map((y) => byYear.get(y) ?? null);
|
||||
const present = values
|
||||
.map((v, i) => (v != null ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
const lastIdx = present[present.length - 1];
|
||||
|
||||
// Auto-scale with headroom so variation is visible, clamped to 0–100.
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const numeric = values.filter((v): v is number => v != null);
|
||||
const lo = Math.min(...numeric);
|
||||
const hi = Math.max(...numeric);
|
||||
const padded = Math.max(5, Math.round((hi - lo) * 0.25));
|
||||
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
|
||||
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
|
||||
@@ -69,15 +79,16 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
|
||||
label: 'First-choice offer rate',
|
||||
data: values,
|
||||
clip: false as const,
|
||||
spanGaps: false,
|
||||
borderColor: '#e07256',
|
||||
backgroundColor: 'rgba(224,114,86,0.10)',
|
||||
borderWidth: 2.5,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
|
||||
pointBackgroundColor: '#e07256',
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,17 +13,17 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
||||
<div className={styles.editorialGrid}>
|
||||
<div className={styles.editorialText}>
|
||||
<div className={styles.editorialKicker}>About school data</div>
|
||||
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
||||
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||
<p>
|
||||
School performance data in England is rich but fragmented. The Department for Education publishes
|
||||
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
||||
demographics — each in its own table, each with its own jargon.
|
||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||
and demographics — each in its own table, each with its own jargon.
|
||||
</p>
|
||||
<p>
|
||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||
40-page inspection report.
|
||||
full inspection report.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.factbox}>
|
||||
|
||||
@@ -720,10 +720,13 @@
|
||||
.miniRowHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.48rem;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.44rem;
|
||||
color: var(--text-muted, #6d685f);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.miniRowHead strong {
|
||||
@@ -869,28 +872,77 @@
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
|
||||
.ofstedBadge {
|
||||
.ofstedFramework {
|
||||
align-self: flex-start;
|
||||
padding: 0.2rem 0.55rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(45, 125, 125, 0.12);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-size: 0.55rem;
|
||||
background: rgba(90, 58, 110, 0.12);
|
||||
color: #5a3a6e;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ofstedVerdict {
|
||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||
font-size: 1.05rem;
|
||||
.rcList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.rcRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rcArea {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-secondary, #4a453d);
|
||||
}
|
||||
|
||||
.rcChip {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary, #1a1612);
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ofstedVerdict em {
|
||||
.rcChipGood {
|
||||
background: rgba(45, 125, 125, 0.12);
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rcChipNeutral {
|
||||
background: var(--accent-gold-bg, rgba(180, 150, 40, 0.12));
|
||||
color: var(--accent-gold-text, #7a6800);
|
||||
}
|
||||
|
||||
/* Safeguarding is a separate binary met/not-met judgement, not a graded area,
|
||||
so it sits below the five-point list behind a divider with its own chip. */
|
||||
.rcSafeguarding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.05rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px dashed var(--border-color, #e5dfd5);
|
||||
}
|
||||
|
||||
.rcSep {
|
||||
color: var(--text-muted, #8a847a);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rcChipMet {
|
||||
background: transparent;
|
||||
color: var(--accent-teal, #2d7d7d);
|
||||
border: 1px solid rgba(45, 125, 125, 0.4);
|
||||
}
|
||||
|
||||
.ofstedMeta {
|
||||
|
||||
@@ -261,7 +261,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
<div className={styles.heroSection}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated with 2026/2027 admissions results
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
|
||||
@@ -5,15 +5,26 @@
|
||||
import styles from './HomeView.module.css';
|
||||
|
||||
export function HowItWorksSection() {
|
||||
// "exc" is the school's above-expected result. DfE names this differently
|
||||
// by subject: "higher standard" for the reading and maths tests, "greater
|
||||
// depth" for teacher-assessed writing. "nat" is the national expected %.
|
||||
const miniCascade = [
|
||||
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
||||
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
||||
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
||||
{ subj: 'Reading', exp: 96, exc: 44, nat: 75, excLabel: 'Higher std' },
|
||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||
];
|
||||
const compareRows = [
|
||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||||
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
|
||||
];
|
||||
// Report card (Ofsted's framework from Nov 2025): one rating per area of
|
||||
// school life on a five-point scale. Safeguarding is judged separately, as a
|
||||
// binary met/not-met outcome — kept out of this list and shown on its own.
|
||||
const reportCard: Array<{ area: string; grade: string; good: boolean }> = [
|
||||
{ area: 'Achievement', grade: 'Strong', good: true },
|
||||
{ area: 'Curriculum & teaching', grade: 'Expected standard', good: false },
|
||||
{ area: 'Attendance & behaviour', grade: 'Strong', good: true },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -29,7 +40,7 @@ export function HowItWorksSection() {
|
||||
<div className={styles.hiwPhaseBlock}>
|
||||
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||||
<div className={styles.miniCascade}>
|
||||
{miniCascade.map(({ subj, exp, exc, nat }) => (
|
||||
{miniCascade.map(({ subj, exp, exc, nat, excLabel }) => (
|
||||
<div key={subj} className={styles.miniCascadeCol}>
|
||||
<div className={styles.miniSubj}>{subj}</div>
|
||||
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||||
@@ -37,7 +48,7 @@ export function HowItWorksSection() {
|
||||
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||||
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||||
</div>
|
||||
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
||||
<div className={styles.miniRowHead}><span>{excLabel}</span><strong>{exc}%</strong></div>
|
||||
<div className={styles.miniTrack}>
|
||||
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||||
</div>
|
||||
@@ -49,15 +60,15 @@ export function HowItWorksSection() {
|
||||
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||||
<div className={styles.att8Row}>
|
||||
<div className={styles.att8BarWrap}>
|
||||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
||||
<div className={styles.att8BarHead}><span>This school</span><span>National avg 45.9</span></div>
|
||||
<div className={styles.att8Track}>
|
||||
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
||||
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
||||
<div className={styles.att8Fill} style={{ width: '54.8%' }} />
|
||||
<div className={styles.att8NatLine} style={{ left: '45.9%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.att8Score}>
|
||||
<div className={styles.att8Value}>62.4</div>
|
||||
<div className={styles.att8Delta}>+12.2 vs national</div>
|
||||
<div className={styles.att8Value}>54.8</div>
|
||||
<div className={styles.att8Delta}>+8.9 vs national</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,15 +88,26 @@ export function HowItWorksSection() {
|
||||
<span className={styles.ofstedBullet} />
|
||||
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||||
</div>
|
||||
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
||||
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
||||
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
||||
<span className={styles.ofstedFramework}>Report card · five-point scale</span>
|
||||
<div className={styles.rcList}>
|
||||
{reportCard.map(({ area, grade, good }) => (
|
||||
<div key={area} className={styles.rcRow}>
|
||||
<span className={styles.rcArea}>{area}</span>
|
||||
<span className={`${styles.rcChip} ${good ? styles.rcChipGood : styles.rcChipNeutral}`}>{grade}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.rcSafeguarding}>
|
||||
<span className={styles.rcArea}>Safeguarding <span className={styles.rcSep}>· separate check</span></span>
|
||||
<span className={`${styles.rcChip} ${styles.rcChipMet}`}>Met</span>
|
||||
</div>
|
||||
<div className={styles.ofstedMeta}>Full inspection · March 2026</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwCardBody}>
|
||||
<div className={styles.hiwStep}>Judgement</div>
|
||||
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||||
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
||||
<p className={styles.hiwDesc}>Since November 2025, Ofsted rates each area of school life on a five-point scale rather than one overall word. We show every area and the inspection date — and, for schools not yet reinspected, the older overall grade they still carry.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js';
|
||||
import '@/lib/chartSetup';
|
||||
import type { SchoolResult } from '@/lib/types';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { fillAcademicYears } from '@/lib/compareChartData';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { track } from '@/lib/analytics';
|
||||
import styles from './PerformanceChart.module.css';
|
||||
@@ -35,8 +36,6 @@ interface PerformanceChartProps {
|
||||
nationalByYear?: NationalByYear[];
|
||||
}
|
||||
|
||||
const COVID_YEARS = new Set([201920, 202021]);
|
||||
|
||||
// Mobile chip definitions: which datasets render when each chip is active.
|
||||
// `series` keys reference the dataset labels so we can filter cleanly.
|
||||
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
||||
@@ -67,21 +66,33 @@ export function PerformanceChart({
|
||||
nationalByYear,
|
||||
}: PerformanceChartProps) {
|
||||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||||
const years = sortedData.map(d => formatAcademicYear(d.year));
|
||||
|
||||
// Gap-honest year axis: every academic year between the first and last data
|
||||
// point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
|
||||
// KS2 — 2021/22) render as real gaps rather than compressed time. School
|
||||
// lines never bridge these gaps (spanGaps:false below).
|
||||
const axisYears = fillAcademicYears(sortedData.map(d => d.year));
|
||||
const byYear = new Map(sortedData.map(d => [d.year, d]));
|
||||
const col = (key: keyof SchoolResult): (number | null)[] =>
|
||||
axisYears.map(y => {
|
||||
const v = byYear.get(y)?.[key];
|
||||
return typeof v === 'number' ? v : null;
|
||||
});
|
||||
const years = axisYears.map(formatAcademicYear);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// ── Build per-year national averages ─────────────────────────────────
|
||||
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
||||
// ── Build per-year national averages (aligned to the filled axis) ────
|
||||
const natRefRwm: (number | null)[] = axisYears.map(y => {
|
||||
if (nationalByYear) {
|
||||
const match = nationalByYear.find(n => n.year === d.year);
|
||||
const match = nationalByYear.find(n => n.year === y);
|
||||
return match?.primary?.rwm_expected_pct ?? null;
|
||||
}
|
||||
return nationalRwmAvg ?? null;
|
||||
});
|
||||
const natRefAtt8: (number | null)[] = sortedData.map(d => {
|
||||
const natRefAtt8: (number | null)[] = axisYears.map(y => {
|
||||
if (nationalByYear) {
|
||||
const match = nationalByYear.find(n => n.year === d.year);
|
||||
const match = nationalByYear.find(n => n.year === y);
|
||||
return match?.secondary?.attainment_8_score ?? null;
|
||||
}
|
||||
return nationalAtt8Avg ?? null;
|
||||
@@ -107,10 +118,11 @@ export function PerformanceChart({
|
||||
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||||
})();
|
||||
|
||||
const hasCovidGap = isSecondary
|
||||
? false
|
||||
: COVID_YEARS.size > 0 &&
|
||||
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
|
||||
// A gap year is any filled axis year the school has no results row for —
|
||||
// exactly the cancelled/unpublished years the fill introduced. Drives the
|
||||
// honest note below (KS2 and KS4 have different gap stories).
|
||||
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||||
const hasGap = gapYears.length > 0;
|
||||
|
||||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||
const refLineStyle = {
|
||||
@@ -120,13 +132,14 @@ export function PerformanceChart({
|
||||
borderDash: [6, 4] as number[],
|
||||
pointRadius: 0,
|
||||
tension: 0,
|
||||
spanGaps: false as const,
|
||||
order: 10,
|
||||
};
|
||||
|
||||
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||
{
|
||||
label: 'Attainment 8',
|
||||
data: sortedData.map(d => d.attainment_8_score),
|
||||
data: col('attainment_8_score'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderWidth: 2.5,
|
||||
@@ -137,7 +150,7 @@ export function PerformanceChart({
|
||||
},
|
||||
{
|
||||
label: 'English & Maths Grade 4+',
|
||||
data: sortedData.map(d => d.english_maths_standard_pass_pct),
|
||||
data: col('english_maths_standard_pass_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderWidth: 1.5,
|
||||
@@ -147,7 +160,7 @@ export function PerformanceChart({
|
||||
},
|
||||
{
|
||||
label: 'Progress 8',
|
||||
data: sortedData.map(d => d.progress_8_score),
|
||||
data: col('progress_8_score'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderWidth: 1.5,
|
||||
@@ -165,7 +178,7 @@ export function PerformanceChart({
|
||||
] : [
|
||||
{
|
||||
label: 'Reading, Writing & Maths expected %',
|
||||
data: sortedData.map(d => d.rwm_expected_pct),
|
||||
data: col('rwm_expected_pct'),
|
||||
borderColor: '#2d7d7d',
|
||||
backgroundColor: 'rgba(45,125,125,0.08)',
|
||||
borderWidth: 2.5,
|
||||
@@ -176,7 +189,7 @@ export function PerformanceChart({
|
||||
},
|
||||
{
|
||||
label: 'Exceeding expected level',
|
||||
data: sortedData.map(d => d.rwm_high_pct),
|
||||
data: col('rwm_high_pct'),
|
||||
borderColor: '#c9a227',
|
||||
backgroundColor: 'rgba(201,162,39,0.08)',
|
||||
borderWidth: 1.5,
|
||||
@@ -192,7 +205,7 @@ export function PerformanceChart({
|
||||
} as ChartDataset<'line'>] : []),
|
||||
{
|
||||
label: 'Reading progress',
|
||||
data: sortedData.map(d => d.reading_progress),
|
||||
data: col('reading_progress'),
|
||||
borderColor: 'rgb(59,130,246)',
|
||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||
borderWidth: 1.5,
|
||||
@@ -203,7 +216,7 @@ export function PerformanceChart({
|
||||
},
|
||||
{
|
||||
label: 'Writing progress',
|
||||
data: sortedData.map(d => d.writing_progress),
|
||||
data: col('writing_progress'),
|
||||
borderColor: 'rgb(139,92,246)',
|
||||
backgroundColor: 'rgba(139,92,246,0.08)',
|
||||
borderWidth: 1.5,
|
||||
@@ -214,7 +227,7 @@ export function PerformanceChart({
|
||||
},
|
||||
{
|
||||
label: 'Maths progress',
|
||||
data: sortedData.map(d => d.maths_progress),
|
||||
data: col('maths_progress'),
|
||||
borderColor: 'rgb(236,72,153)',
|
||||
backgroundColor: 'rgba(236,72,153,0.08)',
|
||||
borderWidth: 1.5,
|
||||
@@ -275,6 +288,8 @@ export function PerformanceChart({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
// Never bridge missing years — cancelled/unpublished years are real gaps.
|
||||
datasets: { line: { spanGaps: false } },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
@@ -326,6 +341,7 @@ export function PerformanceChart({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
datasets: { line: { spanGaps: false } },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
@@ -353,7 +369,10 @@ export function PerformanceChart({
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { size: 10 }, autoSkip: false },
|
||||
// With the gap-honest axis (more year labels) autoSkip keeps the
|
||||
// phone axis readable; the broken line still shows where a year is
|
||||
// missing even when its tick label is skipped.
|
||||
ticks: { font: { size: 10 }, autoSkip: true, maxTicksLimit: 5, maxRotation: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -416,9 +435,11 @@ export function PerformanceChart({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCovidGap && (
|
||||
{hasGap && (
|
||||
<p className={styles.covidNote}>
|
||||
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
||||
{isSecondary
|
||||
? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available."
|
||||
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1040,6 +1040,41 @@
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
/* ── Ofsted grade grids (Report Card + OEIF) ──
|
||||
Uniform, vertically-aligned grade chips. Labels reserve two lines so
|
||||
single- and double-line labels put their chips on the same baseline;
|
||||
every chip (Met, Strong, Expected standard, …) shares one font size,
|
||||
padding and min-height regardless of how many lines its text wraps to. */
|
||||
.gradeGrid .metricCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 0.75rem;
|
||||
}
|
||||
.gradeGrid .metricLabel {
|
||||
min-height: 2.6em;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.gradeGrid .metricValue {
|
||||
margin-top: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
min-height: 2.6em;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ofstedDisclaimer {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
|
||||
@@ -18,9 +18,11 @@ import type {
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
|
||||
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, isSpecialSchool,
|
||||
} from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||
|
||||
const PerformanceChart = dynamic(
|
||||
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||
@@ -85,6 +87,10 @@ export function SchoolDetailView({
|
||||
// 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 = 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 = summariseAdmissions(admissions);
|
||||
// Only the section links scroll horizontally; Back and "All" stay pinned.
|
||||
const sectionLinksRef = useRef<HTMLDivElement | null>(null);
|
||||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||
@@ -153,10 +159,16 @@ export function SchoolDetailView({
|
||||
|
||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||
|
||||
// Phase detection
|
||||
// Phase detection. All-through schools cover BOTH key stages, so they are
|
||||
// neither "pure primary" nor "pure secondary": isSecondary stays true (they
|
||||
// have KS4 data) but isAllThrough gates the primary-only content (phonics,
|
||||
// KS2 trend) back on and switches phase-specific copy to an all-ages framing.
|
||||
const phase = schoolInfo.phase ?? '';
|
||||
const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
|
||||
const isAllThrough = phase.toLowerCase() === 'all-through';
|
||||
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
|
||||
const isPrimary = !isSecondary;
|
||||
// Primary-stage content shows for pure-primary AND all-through schools.
|
||||
const showPrimaryContent = isPrimary || isAllThrough;
|
||||
|
||||
// National averages (fetched dynamically so they stay current)
|
||||
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||
@@ -222,17 +234,38 @@ export function SchoolDetailView({
|
||||
const hasKS4Results = latestResults != null && latestResults.attainment_8_score != null;
|
||||
const hasAnyResults = hasKS2Results || hasKS4Results;
|
||||
|
||||
// Special schools / PRUs / AP: their pupils sit the same tests but very few
|
||||
// reach the mainstream "expected standard", so a 0% headline and an England
|
||||
// comparison portray them as failing against a benchmark that doesn't fit.
|
||||
const isSpecial = isSpecialSchool(schoolInfo);
|
||||
// Belt-and-braces for KS2: a whole-row zero attainment (every subject 0 — a
|
||||
// special/suppressed signature) is a placeholder, not a real result. This
|
||||
// needs ALL of RWM + reading + writing + maths to be 0, so a genuine 0%
|
||||
// combined (some pupils met individual subjects but not all three) stays
|
||||
// comparable. Attainment 8 is a single 0–80 score with no subject breakdown
|
||||
// to form such a signature, so KS4 keys off establishment type only — a
|
||||
// genuine (if extreme) 0.0 still shows its real figure and comparison.
|
||||
const ks2Placeholder = latestResults != null
|
||||
&& latestResults.rwm_expected_pct === 0
|
||||
&& (latestResults.reading_expected_pct ?? 0) === 0
|
||||
&& (latestResults.writing_expected_pct ?? 0) === 0
|
||||
&& (latestResults.maths_expected_pct ?? 0) === 0;
|
||||
// Whether to drop the England-average deltas / national markers / "below"
|
||||
// framing on the attainment measures.
|
||||
const suppressKs2Comparison = isSpecial || ks2Placeholder;
|
||||
const suppressKs4Comparison = isSpecial;
|
||||
|
||||
// Build section nav items dynamically — only sections with data.
|
||||
// Order is engagement-led (from section_nav_used analytics): the most-sought
|
||||
// sections — results, admissions, inclusion, history — sit near the top,
|
||||
// after the recognised Ofsted badge; low-demand context sections stay last.
|
||||
const navItems: { id: string; label: string }[] = [];
|
||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||
if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
|
||||
if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' });
|
||||
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||||
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||||
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' });
|
||||
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
|
||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||
@@ -285,17 +318,12 @@ export function SchoolDetailView({
|
||||
: ofsted?.inspection_date ?? null;
|
||||
|
||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||
const oeifAllSameGrade = (() => {
|
||||
if (!ofsted || isReportCard) return false;
|
||||
const subs = [
|
||||
ofsted.quality_of_education,
|
||||
ofsted.behaviour_attitudes,
|
||||
ofsted.personal_development,
|
||||
ofsted.leadership_management,
|
||||
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
||||
].filter((v): v is number => v != null);
|
||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
||||
})();
|
||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||
const oeifAllSameGrade =
|
||||
!!ofsted &&
|
||||
!isReportCard &&
|
||||
oeifAreas.length >= 3 &&
|
||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||
|
||||
// Label shown in the mobile "section" menu button — the section in view.
|
||||
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
|
||||
@@ -324,6 +352,9 @@ export function SchoolDetailView({
|
||||
{schoolInfo.school_type && (
|
||||
<span className={styles.metaItem}>{schoolInfo.school_type}</span>
|
||||
)}
|
||||
{isAllThrough && (
|
||||
<span className={styles.metaItem}>All-through (primary & secondary)</span>
|
||||
)}
|
||||
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||
<span className={styles.metaItem}>{schoolInfo.gender}'s school</span>
|
||||
)}
|
||||
@@ -546,7 +577,7 @@ export function SchoolDetailView({
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||
</p>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Safeguarding</div>
|
||||
@@ -583,23 +614,17 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
|
||||
{ofsted.grade_source === 'ungraded_carried_forward'
|
||||
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
||||
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.'}
|
||||
</p>
|
||||
{oeifAllSameGrade ? (
|
||||
<p className={styles.ofstedAllSame}>
|
||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||
</p>
|
||||
) : (
|
||||
<div className={styles.metricsGrid}>
|
||||
{[
|
||||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
||||
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
||||
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
||||
{ label: 'School Leadership', value: ofsted.leadership_management },
|
||||
...(ofsted.early_years_provision != null
|
||||
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
||||
: []),
|
||||
].map(({ label, value }) => value != null && (
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{oeifAreas.map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
@@ -618,17 +643,31 @@ export function SchoolDetailView({
|
||||
{hasAnyResults && latestResults && (
|
||||
<section id="results" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
{isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||||
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
{isSecondary
|
||||
? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
|
||||
: 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
|
||||
{isSpecial
|
||||
? (isSecondary
|
||||
? 'GCSE results for Year 11 pupils.'
|
||||
: 'End-of-primary-school tests taken by Year 6 pupils.')
|
||||
: isAllThrough
|
||||
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.'
|
||||
: isSecondary
|
||||
? 'GCSE results for Year 11 pupils. England averages shown for comparison.'
|
||||
: 'End-of-primary-school tests taken by Year 6 pupils. England averages shown for comparison.'}
|
||||
</p>
|
||||
|
||||
{/* Explains up front why the England comparison is dropped below, so
|
||||
a 0% headline never reads as a failing grade against a benchmark
|
||||
that doesn't fit. Type-aware copy (special vs PRU vs AP). */}
|
||||
<SpecialSchoolNote school={schoolInfo} />
|
||||
|
||||
{/* ── Primary / KS2 content ── */}
|
||||
{hasKS2Results && (
|
||||
<>
|
||||
{isAllThrough && (
|
||||
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs (Year 6)</h3>
|
||||
)}
|
||||
<div className={styles.heroStatGrid}>
|
||||
{latestResults.rwm_expected_pct !== null && (
|
||||
<div className={styles.heroStatCard}>
|
||||
@@ -638,7 +677,7 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||
{primaryAvg.rwm_expected_pct != null && (
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.rwm_expected_pct}
|
||||
baseline={primaryAvg.rwm_expected_pct}
|
||||
@@ -647,8 +686,8 @@ export function SchoolDetailView({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.rwm_expected_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_expected_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -660,7 +699,7 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.rwm_high_pct)}
|
||||
{primaryAvg.rwm_high_pct != null && (
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.rwm_high_pct}
|
||||
baseline={primaryAvg.rwm_high_pct}
|
||||
@@ -669,14 +708,15 @@ export function SchoolDetailView({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.rwm_high_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
{!suppressKs2Comparison && primaryAvg.rwm_high_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latestResults.rwm_expected_pct != null &&
|
||||
{!suppressKs2Comparison &&
|
||||
latestResults.rwm_expected_pct != null &&
|
||||
latestResults.reading_expected_pct != null &&
|
||||
latestResults.writing_expected_pct != null &&
|
||||
latestResults.maths_expected_pct != null && (
|
||||
@@ -700,28 +740,34 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SatsChart
|
||||
subjects={[
|
||||
{
|
||||
name: 'Reading',
|
||||
expectedPct: latestResults.reading_expected_pct,
|
||||
exceedingPct: latestResults.reading_high_pct,
|
||||
nationalExpectedPct: primaryAvg.reading_expected_pct,
|
||||
},
|
||||
{
|
||||
name: 'Writing',
|
||||
expectedPct: latestResults.writing_expected_pct,
|
||||
exceedingPct: latestResults.writing_high_pct,
|
||||
nationalExpectedPct: primaryAvg.writing_expected_pct,
|
||||
},
|
||||
{
|
||||
name: 'Maths',
|
||||
expectedPct: latestResults.maths_expected_pct,
|
||||
exceedingPct: latestResults.maths_high_pct,
|
||||
nationalExpectedPct: primaryAvg.maths_expected_pct,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* All-zero placeholder rows (special / suppressed) would render as
|
||||
three empty bars against the national markers — misleading, so
|
||||
skip the chart. For a special school with some non-zero
|
||||
subjects, keep the bars but drop the national markers. */}
|
||||
{!ks2Placeholder && (
|
||||
<SatsChart
|
||||
subjects={[
|
||||
{
|
||||
name: 'Reading',
|
||||
expectedPct: latestResults.reading_expected_pct,
|
||||
exceedingPct: latestResults.reading_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
|
||||
},
|
||||
{
|
||||
name: 'Writing',
|
||||
expectedPct: latestResults.writing_expected_pct,
|
||||
exceedingPct: latestResults.writing_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
|
||||
},
|
||||
{
|
||||
name: 'Maths',
|
||||
expectedPct: latestResults.maths_expected_pct,
|
||||
exceedingPct: latestResults.maths_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress scores row */}
|
||||
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||
@@ -767,6 +813,9 @@ export function SchoolDetailView({
|
||||
{/* ── Secondary / KS4 content ── */}
|
||||
{hasKS4Results && (
|
||||
<>
|
||||
{isAllThrough && (
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs (Year 11)</h3>
|
||||
)}
|
||||
<div className={styles.metricsGrid}>
|
||||
{latestResults.attainment_8_score !== null && (
|
||||
<div className={styles.metricCard}>
|
||||
@@ -775,8 +824,8 @@ export function SchoolDetailView({
|
||||
<MetricTooltip metricKey="attainment_8_score" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
{!suppressKs4Comparison && secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -799,8 +848,8 @@ export function SchoolDetailView({
|
||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
{!suppressKs4Comparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -811,8 +860,8 @@ export function SchoolDetailView({
|
||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
{!suppressKs4Comparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -876,6 +925,15 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className={styles.admissionsViewport}>
|
||||
{/* This-year Q&A */}
|
||||
@@ -917,6 +975,9 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{admissionsSummary.chip && (
|
||||
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Multi-year trend */}
|
||||
@@ -952,7 +1013,7 @@ export function SchoolDetailView({
|
||||
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
{latestResults?.eal_pct != null && (
|
||||
@@ -968,7 +1029,7 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.eal_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -985,7 +1046,7 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
{primaryAvg.sen_support_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1049,16 +1110,52 @@ export function SchoolDetailView({
|
||||
{yearlyData.length > 0 && (
|
||||
<section id="history" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={isSecondary}
|
||||
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||||
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||||
nationalByYear={nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
{isAllThrough ? (
|
||||
// All-through: KS2 and KS4 trends are on different scales and have
|
||||
// different gap stories, so render them as two stacked charts
|
||||
// rather than crowding 8+ series onto one axis.
|
||||
<>
|
||||
{hasKS2Results && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle}>Primary — KS2 SATs</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={false}
|
||||
nationalRwmAvg={suppressKs2Comparison ? null : (primaryAvg.rwm_expected_pct ?? null)}
|
||||
nationalByYear={suppressKs2Comparison ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasKS4Results && (
|
||||
<>
|
||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary — GCSEs</h3>
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
nationalAtt8Avg={suppressKs4Comparison ? null : (secondaryAvg.attainment_8_score ?? null)}
|
||||
nationalByYear={suppressKs4Comparison ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.chartContainer}>
|
||||
<PerformanceChart
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={isSecondary}
|
||||
nationalRwmAvg={isPrimary && !suppressKs2Comparison ? (primaryAvg.rwm_expected_pct ?? null) : null}
|
||||
nationalAtt8Avg={isSecondary && !suppressKs4Comparison ? (secondaryAvg.attainment_8_score ?? null) : null}
|
||||
nationalByYear={(isPrimary ? suppressKs2Comparison : suppressKs4Comparison) ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{yearlyData.length > 1 && (
|
||||
<details className={styles.historyDisclosure}>
|
||||
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||
@@ -1067,7 +1164,15 @@ export function SchoolDetailView({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
{isSecondary ? (
|
||||
{isAllThrough ? (
|
||||
<>
|
||||
<th>RWM (expected %)</th>
|
||||
<th>Exceeding (%)</th>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
<th>English & Maths Grade 4+</th>
|
||||
</>
|
||||
) : isSecondary ? (
|
||||
<>
|
||||
<th>Attainment 8</th>
|
||||
<th>Progress 8</th>
|
||||
@@ -1089,7 +1194,15 @@ export function SchoolDetailView({
|
||||
{yearlyData.map((result) => (
|
||||
<tr key={result.year}>
|
||||
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||
{isSecondary ? (
|
||||
{isAllThrough ? (
|
||||
<>
|
||||
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
||||
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
<td>{result.english_maths_standard_pass_pct !== null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||
</>
|
||||
) : isSecondary ? (
|
||||
<>
|
||||
<td>{result.attainment_8_score !== null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||
<td>{result.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||
@@ -1114,8 +1227,8 @@ export function SchoolDetailView({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{/* Year 1 Phonics — primary only */}
|
||||
{hasPhonics && isPrimary && phonics && (
|
||||
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */}
|
||||
{hasPhonics && showPrimaryContent && phonics && (
|
||||
<section id="phonics" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
@@ -1157,7 +1270,7 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
||||
{primaryAvg.overall_absence_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
||||
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1169,7 +1282,7 @@ export function SchoolDetailView({
|
||||
</div>
|
||||
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
|
||||
{primaryAvg.persistent_absence_pct != null && (
|
||||
<div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
||||
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
||||
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||
import styles from './SchoolRow.module.css';
|
||||
|
||||
interface SchoolRowProps {
|
||||
@@ -38,10 +38,23 @@ export function SchoolRow({
|
||||
school.religious_denomination &&
|
||||
school.religious_denomination !== 'Does not apply';
|
||||
|
||||
// vs-national delta
|
||||
// A placeholder all-zero row (every subject 0 — a special/suppressed
|
||||
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
|
||||
// so its figure is hidden. A genuine 0% combined (some pupils met individual
|
||||
// subjects but not all three) is NOT all-zero and stays shown.
|
||||
const rwmPlaceholder =
|
||||
school.rwm_expected_pct === 0 &&
|
||||
(school.reading_expected_pct ?? 0) === 0 &&
|
||||
(school.writing_expected_pct ?? 0) === 0 &&
|
||||
(school.maths_expected_pct ?? 0) === 0;
|
||||
// The school's OWN figure and its year-over-year trend are same-school
|
||||
// measures — shown whenever there's a real value, special schools included.
|
||||
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
|
||||
// The vs-England delta is a mainstream benchmark: additionally dropped for
|
||||
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||
const rwmDelta =
|
||||
school.rwm_expected_pct != null && nationalAvgRwm != null
|
||||
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
|
||||
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
|
||||
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
|
||||
: null;
|
||||
|
||||
const handleCompareClick = () => {
|
||||
@@ -87,9 +100,9 @@ export function SchoolRow({
|
||||
<div className={styles.line3}>
|
||||
<span className={styles.stat}>
|
||||
<strong className={styles.statValue}>
|
||||
{school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
||||
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
|
||||
</strong>
|
||||
{school.prev_rwm_expected_pct != null && (
|
||||
{showRwmValue && school.prev_rwm_expected_pct != null && (
|
||||
<span
|
||||
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
|
||||
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
|
||||
|
||||
@@ -623,6 +623,41 @@
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
}
|
||||
|
||||
/* ── Ofsted grade grids (Report Card + OEIF) ──
|
||||
Uniform, vertically-aligned grade chips. Labels reserve two lines so
|
||||
single- and double-line labels put their chips on the same baseline;
|
||||
every chip (Met, Strong, Expected standard, …) shares one font size,
|
||||
padding and min-height regardless of how many lines its text wraps to. */
|
||||
.gradeGrid .metricCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 0.75rem;
|
||||
}
|
||||
.gradeGrid .metricLabel {
|
||||
min-height: 2.6em;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.gradeGrid .metricValue {
|
||||
margin-top: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
min-height: 2.6em;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ofstedDisclaimer {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #8a847a);
|
||||
@@ -1049,8 +1084,12 @@
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
/* On mobile let the chart container flow naturally — PerformanceChart's
|
||||
own .chartWrapper carries the definite canvas height (220px) plus the
|
||||
chip strip above it. A fixed 220px here double-constrained the two and
|
||||
clipped the chips onto the plot area. */
|
||||
.chartContainer {
|
||||
height: 220px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
|
||||
@@ -23,8 +23,9 @@ import type {
|
||||
SchoolAdmissions, SenDetail, Phonics,
|
||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||
} from '@/lib/types';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
||||
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas, isSpecialSchool } from '@/lib/utils';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { SpecialSchoolNote } from './SpecialSchoolNote';
|
||||
import { track, getNavigationSource } from '@/lib/analytics';
|
||||
import styles from './SecondarySchoolDetailView.module.css';
|
||||
|
||||
@@ -107,6 +108,16 @@ export function SecondarySchoolDetailView({
|
||||
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||
const hasResults = latestResults?.attainment_8_score != null;
|
||||
|
||||
// Special schools / PRUs / AP sit the same GCSEs but teach pupils with SEND,
|
||||
// so their headline attainment is far below the mainstream average by design.
|
||||
// Drop the England comparison + "below" framing so the page doesn't portray
|
||||
// them as failing against a benchmark that doesn't fit. Attainment 8 is a
|
||||
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||||
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||||
// mainstream school still shows its real value and comparison.
|
||||
const isSpecial = isSpecialSchool(schoolInfo);
|
||||
const suppressComparison = isSpecial;
|
||||
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
if (policy.includes('selective')) return 'Selective';
|
||||
@@ -201,17 +212,12 @@ export function SecondarySchoolDetailView({
|
||||
: ofsted?.inspection_date ?? null;
|
||||
|
||||
// ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
|
||||
const oeifAllSameGrade = (() => {
|
||||
if (!ofsted || isReportCard) return false;
|
||||
const subs = [
|
||||
ofsted.quality_of_education,
|
||||
ofsted.behaviour_attitudes,
|
||||
ofsted.personal_development,
|
||||
ofsted.leadership_management,
|
||||
...(ofsted.early_years_provision != null ? [ofsted.early_years_provision] : []),
|
||||
].filter((v): v is number => v != null);
|
||||
return subs.length >= 3 && subs.every(v => v === ofsted.overall_effectiveness);
|
||||
})();
|
||||
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : [];
|
||||
const oeifAllSameGrade =
|
||||
!!ofsted &&
|
||||
!isReportCard &&
|
||||
oeifAreas.length >= 3 &&
|
||||
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness);
|
||||
|
||||
// National Attainment 8 baseline for the "Results Over Time" chart.
|
||||
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||
@@ -368,7 +374,7 @@ export function SecondarySchoolDetailView({
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||
</p>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{ofsted.rc_safeguarding_met != null && (
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>Safeguarding</div>
|
||||
@@ -404,23 +410,17 @@ export function SecondarySchoolDetailView({
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.ofstedDisclaimer}>
|
||||
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
|
||||
{ofsted.grade_source === 'ungraded_carried_forward'
|
||||
? 'This overall grade is carried forward from an earlier inspection — Ofsted has since visited without issuing a new overall grade. From September 2024, Ofsted no longer makes an overall effectiveness judgement.'
|
||||
: 'From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.'}
|
||||
</p>
|
||||
{oeifAllSameGrade ? (
|
||||
<p className={styles.ofstedAllSame}>
|
||||
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||
</p>
|
||||
) : (
|
||||
<div className={styles.metricsGrid}>
|
||||
{[
|
||||
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
||||
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
||||
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
||||
{ label: 'School Leadership', value: ofsted.leadership_management },
|
||||
...(ofsted.early_years_provision != null
|
||||
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
||||
: []),
|
||||
].map(({ label, value }) => value != null && (
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{oeifAreas.map(({ label, value }) => (
|
||||
<div key={label} className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>{label}</div>
|
||||
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||
@@ -436,7 +436,7 @@ export function SecondarySchoolDetailView({
|
||||
<p className={styles.sectionSubtitle}>
|
||||
From September 2024, Ofsted no longer gives a single overall grade.
|
||||
</p>
|
||||
<div className={styles.metricsGrid}>
|
||||
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}>
|
||||
{[
|
||||
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
||||
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
||||
@@ -463,12 +463,15 @@ export function SecondarySchoolDetailView({
|
||||
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||
</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
GCSE results for Year 11 pupils. National averages shown for comparison.
|
||||
GCSE results for Year 11 pupils.{!suppressComparison && ' England averages shown for comparison.'}
|
||||
</p>
|
||||
|
||||
<SpecialSchoolNote school={schoolInfo} />
|
||||
|
||||
{p8Suspended && (
|
||||
<div className={styles.p8Banner}>
|
||||
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
|
||||
Progress 8 isn't published for 2024/25: this GCSE year group sat no KS2 tests
|
||||
(COVID), so DfE has no starting point to measure their progress from.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -482,7 +485,7 @@ export function SecondarySchoolDetailView({
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{latestResults.attainment_8_score.toFixed(1)}
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.attainment_8_score}
|
||||
baseline={secondaryAvg.attainment_8_score}
|
||||
@@ -491,8 +494,8 @@ export function SecondarySchoolDetailView({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
{!suppressComparison && secondaryAvg.attainment_8_score != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -522,7 +525,7 @@ export function SecondarySchoolDetailView({
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_strong_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||
@@ -531,8 +534,8 @@ export function SecondarySchoolDetailView({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
{!suppressComparison && secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -544,7 +547,7 @@ export function SecondarySchoolDetailView({
|
||||
</div>
|
||||
<div className={styles.heroStatValue}>
|
||||
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<DeltaChip
|
||||
value={latestResults.english_maths_standard_pass_pct}
|
||||
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||
@@ -553,15 +556,17 @@ export function SecondarySchoolDetailView({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
{!suppressComparison && secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attainment 8 visual bar (0–80 scale) */}
|
||||
{latestResults.attainment_8_score != null && (
|
||||
{/* Attainment 8 visual bar (0–80 scale). This viz is explicitly
|
||||
"school vs national", so it's dropped for special schools where
|
||||
that comparison isn't meaningful. */}
|
||||
{!suppressComparison && latestResults.attainment_8_score != null && (
|
||||
<div className={styles.att8Viz}>
|
||||
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||
<div className={styles.att8VizTrack}>
|
||||
@@ -753,8 +758,8 @@ export function SecondarySchoolDetailView({
|
||||
data={yearlyData}
|
||||
schoolName={schoolInfo.school_name}
|
||||
isSecondary={true}
|
||||
nationalAtt8Avg={heroAtt8Nat}
|
||||
nationalByYear={nationalAvg?.by_year}
|
||||
nationalAtt8Avg={suppressComparison ? null : heroAtt8Nat}
|
||||
nationalByYear={suppressComparison ? undefined : nationalAvg?.by_year}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
'use client';
|
||||
|
||||
import type { School } from '@/lib/types';
|
||||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose } from '@/lib/utils';
|
||||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||||
import styles from './SecondarySchoolRow.module.css';
|
||||
|
||||
function detectAdmissionsTag(school: School): string | null {
|
||||
@@ -55,8 +55,14 @@ export function SecondarySchoolRow({
|
||||
const ofstedBadge = buildOfstedListBadge(school);
|
||||
const phase = getPhaseStyle(school.phase);
|
||||
const att8 = school.attainment_8_score;
|
||||
// The school's own Attainment 8 is a same-school figure — shown whenever it
|
||||
// exists (special schools included; their type tag on line 2 gives context).
|
||||
// Only the vs-LA-average delta, a benchmark comparison, is dropped for
|
||||
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
||||
const laDelta =
|
||||
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
|
||||
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
|
||||
? att8 - laAvgAttainment8
|
||||
: null;
|
||||
|
||||
const admissionsTag = detectAdmissionsTag(school);
|
||||
const sixthForm = hasSixthForm(school);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Special-school / PRU / AP context note: soft, informational (teal), not a
|
||||
warning. Shared by both detail views so the styling can't drift. */
|
||||
.note {
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
border-left: 4px solid var(--accent-teal, #2d7d7d);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 0.7rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary, #5c564d);
|
||||
}
|
||||
.note strong {
|
||||
color: var(--text-primary, #1a1612);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* SpecialSchoolNote — the context note shown on special-school / PRU / AP
|
||||
* detail pages explaining why the mainstream England-average comparison is
|
||||
* dropped. Renders nothing for mainstream schools.
|
||||
*
|
||||
* The copy is type-aware: only genuine special schools have pupils with special
|
||||
* educational needs. Pupil referral units and alternative provision teach
|
||||
* pupils educated outside a mainstream setting (e.g. after exclusion, or for
|
||||
* medical reasons) who are not necessarily SEND — so their note says so rather
|
||||
* than mischaracterising them. Suppressing the England comparison is reasonable
|
||||
* for all three.
|
||||
*/
|
||||
|
||||
import { isSpecialSchool } from '@/lib/utils';
|
||||
import styles from './SpecialSchoolNote.module.css';
|
||||
|
||||
type SpecialKind = 'special' | 'pru' | 'ap';
|
||||
|
||||
function specialKind(schoolType: string | null | undefined): SpecialKind {
|
||||
const t = (schoolType ?? '').toLowerCase();
|
||||
if (/pupil referral/.test(t)) return 'pru';
|
||||
if (/alternative provision/.test(t)) return 'ap';
|
||||
return 'special';
|
||||
}
|
||||
|
||||
export function SpecialSchoolNote({ school }: { school: { school_type?: string | null } }) {
|
||||
if (!isSpecialSchool(school)) return null;
|
||||
const kind = specialKind(school.school_type);
|
||||
|
||||
return (
|
||||
<div className={styles.note} role="note">
|
||||
{kind === 'special' && (
|
||||
<>
|
||||
<strong>This is a special school.</strong> Its pupils have special educational needs and
|
||||
work towards individual targets. They sit the same national assessments, but very few
|
||||
reach the mainstream “expected standard” these measures report — so a
|
||||
comparison with the England average isn’t a meaningful guide to the school.
|
||||
</>
|
||||
)}
|
||||
{kind === 'pru' && (
|
||||
<>
|
||||
<strong>This is a pupil referral unit.</strong> It teaches pupils educated outside a
|
||||
mainstream school — for example after exclusion, or for medical or behavioural reasons.
|
||||
The mainstream “expected standard” and the England-average comparison
|
||||
aren’t a meaningful guide to the school.
|
||||
</>
|
||||
)}
|
||||
{kind === 'ap' && (
|
||||
<>
|
||||
<strong>This is an alternative provision setting.</strong> It teaches pupils educated
|
||||
outside a mainstream school. The mainstream “expected standard” and the
|
||||
England-average comparison aren’t a meaningful guide to the school.
|
||||
</>
|
||||
)}{' '}
|
||||
Where available, the progress its pupils make is a fairer measure.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
'use client';
|
||||
|
||||
import { latestValues, verdict } from '@/lib/compareLogic';
|
||||
import { isSpecialSchool } from '@/lib/utils';
|
||||
import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types';
|
||||
import { DotStrip } from '@/components/DotStrip';
|
||||
import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared';
|
||||
@@ -90,15 +91,19 @@ function Strip({
|
||||
urns,
|
||||
schoolNames,
|
||||
national,
|
||||
special,
|
||||
}: {
|
||||
spec: StripSpec;
|
||||
data: Record<string, ComparisonData>;
|
||||
urns: number[];
|
||||
schoolNames: string[];
|
||||
national: Record<string, number> | undefined;
|
||||
/** Per-school special-school flag; special schools' mainstream attainment is
|
||||
* not a fair comparison, so it's dropped from the strip (no dot). */
|
||||
special: boolean[];
|
||||
}) {
|
||||
const values = latestValues(data, urns, spec.metric).map((v) =>
|
||||
v != null ? Math.round(v) : null,
|
||||
const values = latestValues(data, urns, spec.metric).map((v, i) =>
|
||||
v != null && !special[i] ? Math.round(v) : null,
|
||||
);
|
||||
const anchorValue = spec.anchorKey ? national?.[spec.anchorKey] : undefined;
|
||||
const anchor =
|
||||
@@ -135,12 +140,19 @@ export function CompareAcademics({
|
||||
}) {
|
||||
const urns = schools.map((school) => school.urn);
|
||||
const schoolNames = schools.map((school) => school.school_name);
|
||||
// Special schools / PRUs / AP: their pupils sit the same assessments but very
|
||||
// few reach the mainstream standard, so their attainment isn't a fair
|
||||
// like-for-like comparison — drop it (progress banding, which IS meaningful,
|
||||
// is kept).
|
||||
const specialFlags = schools.map((school) => isSpecialSchool(school));
|
||||
const dropSpecial = (vals: Array<number | null>) =>
|
||||
vals.map((v, i) => (specialFlags[i] ? null : v));
|
||||
const isSecondary = propIsSecondary !== undefined ? propIsSecondary : schools.some(
|
||||
(school) => data[String(school.urn)]?.school_info?.attainment_8_score != null,
|
||||
);
|
||||
|
||||
if (isSecondary) {
|
||||
const att8 = latestValues(data, urns, 'attainment_8_score');
|
||||
const att8 = dropSpecial(latestValues(data, urns, 'attainment_8_score'));
|
||||
const banding = urns.map((urn) => {
|
||||
const rows = data[String(urn)]?.yearly_data ?? [];
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
@@ -159,8 +171,8 @@ export function CompareAcademics({
|
||||
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
|
||||
return y >= 202425;
|
||||
});
|
||||
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
|
||||
const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
|
||||
const grade5 = dropSpecial(latestValues(data, urns, 'english_maths_strong_pass_pct'));
|
||||
const ebacc = dropSpecial(latestValues(data, urns, 'ebacc_entry_pct'));
|
||||
const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
|
||||
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
|
||||
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
|
||||
@@ -272,7 +284,7 @@ export function CompareAcademics({
|
||||
}
|
||||
|
||||
const national = nationalAverages?.primary;
|
||||
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
|
||||
const disadvantaged = dropSpecial(latestValues(data, urns, 'rwm_expected_disadvantaged_pct'));
|
||||
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
|
||||
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
|
||||
// groups where single pupils move the percentage — show roughly how many
|
||||
@@ -307,6 +319,7 @@ export function CompareAcademics({
|
||||
urns={urns}
|
||||
schoolNames={schoolNames}
|
||||
national={national}
|
||||
special={specialFlags}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -320,6 +333,7 @@ export function CompareAcademics({
|
||||
urns={urns}
|
||||
schoolNames={schoolNames}
|
||||
national={national}
|
||||
special={specialFlags}
|
||||
/>
|
||||
))}
|
||||
<p className={styles.stripNote}>
|
||||
|
||||
@@ -680,6 +680,42 @@ export function buildSchoolSummary(
|
||||
return parts.join(', ') + '.';
|
||||
}
|
||||
|
||||
// ─── Legacy (OEIF) sub-judgement areas ────────────────────────────────────────
|
||||
|
||||
export interface OfstedLegacyArea {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The published OEIF sub-judgement areas for the legacy Ofsted layout, in
|
||||
* display order. Only real grades (1–4) are returned: Ofsted's sentinel
|
||||
* codes for "not applicable / no judgement" (9, and any 0/8 variants) and
|
||||
* nulls are filtered out, so a cryptic "9" never renders as a rating.
|
||||
* Sixth Form provision is included where a school has one — it was
|
||||
* previously dropped from the detail grid entirely.
|
||||
*/
|
||||
export function ofstedLegacyAreas(ofsted: {
|
||||
quality_of_education?: number | null;
|
||||
behaviour_attitudes?: number | null;
|
||||
personal_development?: number | null;
|
||||
leadership_management?: number | null;
|
||||
early_years_provision?: number | null;
|
||||
sixth_form_provision?: number | null;
|
||||
}): OfstedLegacyArea[] {
|
||||
const candidates: Array<[string, number | null | undefined]> = [
|
||||
['Quality of Teaching', ofsted.quality_of_education],
|
||||
['Behaviour in School', ofsted.behaviour_attitudes],
|
||||
["Pupils' Wider Development", ofsted.personal_development],
|
||||
['School Leadership', ofsted.leadership_management],
|
||||
['Early Years (Reception)', ofsted.early_years_provision],
|
||||
['Sixth Form', ofsted.sixth_form_provision],
|
||||
];
|
||||
return candidates
|
||||
.filter((c): c is [string, number] => c[1] != null && c[1] >= 1 && c[1] <= 4)
|
||||
.map(([label, value]) => ({ label, value }));
|
||||
}
|
||||
|
||||
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
|
||||
|
||||
export interface OfstedListBadge {
|
||||
@@ -759,3 +795,24 @@ export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
|
||||
export function isProposedToClose(school: { status?: string | null }): boolean {
|
||||
return school.status === PROPOSED_TO_CLOSE_STATUS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Special schools, pupil referral units and alternative provision teach pupils
|
||||
* with SEND or outside mainstream settings. Their pupils sit the same KS2/KS4
|
||||
* assessments but very few reach the mainstream "expected standard", so the
|
||||
* headline attainment measures — and any comparison to the England average —
|
||||
* are not a fair judgement of the school. Callers use this to drop the
|
||||
* mainstream-benchmark framing (deltas, "below England", national markers)
|
||||
* rather than portray these schools as failing.
|
||||
*
|
||||
* Detection is by establishment type: every DfE special-school type contains
|
||||
* "special" (e.g. "Community special school", "Academy special converter/sponsor
|
||||
* led", "Foundation special school", "Non-maintained special school", "Free
|
||||
* schools special", "Other independent special school"); PRUs and alternative
|
||||
* provision are matched by name. Special schools carry a mainstream `phase`
|
||||
* (Primary/Secondary/All-through), so `phase` alone can't identify them.
|
||||
*/
|
||||
export function isSpecialSchool(school: { school_type?: string | null }): boolean {
|
||||
const t = (school.school_type ?? '').toLowerCase();
|
||||
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user