Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 993a6133cf fix(compare): give the trend chart its own definite canvas height so the gap note can't squash it
The chart note (and the 2-row mobile chip legend) lived inside the fixed
360px/420px chartBox and competed with the canvas for it, so a longer
caption — e.g. the new KS4 gap note — shrank the mobile canvas to ~178px
and tripped the e2e >220px guard. The canvas now owns a definite height
(280px mobile / 380px desktop) with chips above and the note flowing
below at natural size, so neither can eat the plot. Verified on staging:
patched canvas renders 280px vs the squashed 178px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 22:54:17 +01:00
23 changed files with 173 additions and 713 deletions
+1 -4
View File
@@ -67,12 +67,9 @@ jobs:
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)" echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
- name: Verify the staging E2E gate passed for this commit - name: Verify the staging E2E gate passed for this commit
# Use the built-in Actions token (GITHUB_TOKEN is the documented name;
# it carries repository read scope), NOT REGISTRY_TOKEN — the registry
# token has no repo scope, so the commit-status API returns 403.
run: | run: |
STATUS_JSON=$(curl -fsS \ STATUS_JSON=$(curl -fsS \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status") "https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
echo "$STATUS_JSON" | python3 -c " echo "$STATUS_JSON" | python3 -c "
import json, sys import json, sys
+1 -7
View File
@@ -172,7 +172,6 @@ _MAIN_QUERY = text("""
foi.ofsted_grade, foi.ofsted_grade,
foi.ofsted_date, foi.ofsted_date,
foi.ofsted_framework, foi.ofsted_framework,
foi.ofsted_rc_date,
l.local_authority_name AS local_authority, l.local_authority_name AS local_authority,
l.local_authority_code, l.local_authority_code,
l.address_line1 AS address1, l.address_line1 AS address1,
@@ -257,12 +256,7 @@ _MAIN_QUERY = text("""
-- Fall back to the ungraded-inspection grade when no graded grade exists. -- Fall back to the ungraded-inspection grade when no graded grade exists.
COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade, COALESCE(overall_effectiveness, ungraded_grade) AS ofsted_grade,
inspection_date AS ofsted_date, inspection_date AS ofsted_date,
framework AS ofsted_framework, framework AS ofsted_framework
-- Report-card signal for list/map badges: non-null only when the
-- latest inspection carries report-card grades. framework is the
-- raw event grouping ("Schools - S5"), never "ReportCard", so it
-- can't be used to detect report cards.
rc_inspection_date AS ofsted_rc_date
FROM marts.fact_ofsted_inspection FROM marts.fact_ofsted_inspection
ORDER BY urn, inspection_date DESC NULLS LAST ORDER BY urn, inspection_date DESC NULLS LAST
) foi ON s.urn = foi.urn ) foi ON s.urn = foi.urn
-1
View File
@@ -550,7 +550,6 @@ SCHOOL_COLUMNS = [
"ofsted_grade", "ofsted_grade",
"ofsted_date", "ofsted_date",
"ofsted_framework", "ofsted_framework",
"ofsted_rc_date",
"latitude", "latitude",
"longitude", "longitude",
] ]
-85
View File
@@ -80,33 +80,6 @@ test('searching by postcode returns nearby schools', async ({ page }) => {
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
}); });
test('a report-card school shows a Report Card badge in search results, not its old grade', async ({ page }) => {
// List/map badges keyed off ofsted_grade (the carried-forward legacy grade)
// and never reached the report-card branch, so report-card schools were
// labelled by their old grade (e.g. "Outstanding · 2021"). The list now
// carries ofsted_rc_date and the badge treats a report card as winning.
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
const res = await page.request.get(`/api/schools?search=Barclay%20Primary&page_size=5`);
expect(res.ok()).toBeTruthy();
const barclay = ((await res.json()).schools ?? []).find(
(s: { urn: number }) => s.urn === RC_URN,
);
// Hard assertions, not test.skip: if the backend stops exposing
// ofsted_rc_date for this report-card school, that IS the regression this
// test exists to catch, so it must fail loudly rather than skip.
expect(barclay, 'Barclay must appear in the search results').toBeTruthy();
expect(
barclay.ofsted_rc_date,
'the list must expose ofsted_rc_date for a report-card school',
).toBeTruthy();
await searchByName(page, 'Barclay Primary');
// The Barclay row must be present…
await expect(page.locator(`a[href*="${RC_URN}"]`).first()).toBeVisible({ timeout: 15_000 });
// …badged as a Report Card, not its carried-forward "Outstanding" grade.
await expect(page.getByText(/Report Card ·/).first()).toBeVisible();
});
test('school detail page renders name and performance data', async ({ page }) => { test('school detail page renders name and performance data', async ({ page }) => {
await searchByName(page, 'primary'); await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first(); const firstSchool = schoolLinks(page).first();
@@ -121,64 +94,6 @@ test('school detail page renders name and performance data', async ({ page }) =>
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
}); });
test('a report-card school shows its report card, dated to the report-card inspection', async ({ page }) => {
// Detail views detected report cards via `framework`, which the API never
// sets to "ReportCard" — so report-card schools rendered as legacy ratings
// dated to a pre-Nov-2025 inspection. Detection now keys off the report_card
// object and dates it with rc_inspection_date.
const RC_URN = 138690; // Barclay Primary — has a Nov-2025+ report card
const res = await page.request.get(`/api/schools/${RC_URN}`);
expect(res.ok()).toBeTruthy();
const ofsted = (await res.json()).ofsted;
test.skip(
!ofsted?.report_card || Object.keys(ofsted.report_card).length === 0,
'precondition: chosen URN must currently have a report card',
);
const rcYear = new Date(ofsted.rc_inspection_date).getFullYear();
const legacyYear = new Date(ofsted.inspection_date).getFullYear();
await page.goto(`/school/${RC_URN}`);
const ofstedSection = page.locator('#ofsted');
// Detection fixed: rendered as a Report Card, not a legacy "Ofsted Rating".
await expect(ofstedSection.getByText('Ofsted Report Card')).toBeVisible({ timeout: 15_000 });
// Dating fixed: dated to the report-card inspection, never the legacy one.
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${rcYear}`))).toBeVisible();
if (legacyYear !== rcYear) {
await expect(ofstedSection.getByText(new RegExp(`Inspected .*${legacyYear}`))).toHaveCount(0);
}
});
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.getByText('Reading, Writing & Maths combined')).toBeVisible(); // KS2
await expect(results.getByText('Attainment 8').first()).toBeVisible(); // KS4
});
test('school with no performance data still gets a working detail page', async ({ page }) => { 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 // Schools without KS2/KS4 results (special post-16 institutions, sixth-form
// centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON // centres, PRUs) used to 500 in the API — NaN GIAS fields broke JSON
@@ -1,65 +0,0 @@
/**
* Secondary academics: every headline number carries its England anchor and a
* verdict chip (expert sign-off SF1 — the grade-5 and EBacc rows previously
* rendered as bare numbers, breaking the "anchored against England" promise).
*/
import { render, screen, within } from '@testing-library/react';
import { CompareAcademics } from '@/components/compare/CompareAcademics';
import type { ComparisonData, NationalAverages, School } from '@/lib/types';
function school(urn: number, name: string): School {
return { urn, school_name: name, attainment_8_score: 58.7 } as School;
}
function data(urn: number): ComparisonData {
return {
school_info: school(urn, 'Test High'),
yearly_data: [
{
year: 202425,
attainment_8_score: 58.7,
english_maths_strong_pass_pct: 30,
ebacc_entry_pct: 10,
},
] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const NATIONAL: NationalAverages = {
year: 202425,
primary: {},
secondary: {
attainment_8_score: 46.0,
english_maths_strong_pass_pct: 45.4,
ebacc_entry_pct: 40.5,
},
by_year: [],
};
test('grade-5 and EBacc rows show the England anchor and a Below chip when under it', () => {
const s = school(137086, 'Bishop Stopford School');
render(
<CompareAcademics
schools={[s]}
data={{ '137086': data(137086) }}
nationalAverages={NATIONAL}
isSecondary
/>,
);
// The official anchors appear (45.4% and 40.5%), not just the school numbers.
expect(screen.getByText(/England average 45%/)).toBeInTheDocument();
expect(screen.getByText(/England average 41%/)).toBeInTheDocument();
// 30% grade-5 and 10% EBacc are both well below their anchors → Below chips.
// Attainment 8 (58.7 vs 46.0) is above → at least one "Above" chip too.
expect(screen.getAllByText(/Below England average/).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText(/Above England average/).length).toBeGreaterThanOrEqual(1);
});
+3 -47
View File
@@ -9,7 +9,6 @@ import {
isValidPostcode, isValidPostcode,
debounce, debounce,
buildOfstedListBadge, buildOfstedListBadge,
ofstedLegacyAreas,
metricKind, metricKind,
shortName, shortName,
computeYBounds, computeYBounds,
@@ -112,35 +111,6 @@ 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', () => { describe('buildOfstedListBadge', () => {
it('returns grade word + year for OEIF Outstanding', () => { it('returns grade word + year for OEIF Outstanding', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' }); const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
@@ -160,23 +130,9 @@ describe('buildOfstedListBadge', () => {
expect(badge.cssClass).toBe('ofsted2'); expect(badge.cssClass).toBe('ofsted2');
}); });
it('returns a Report Card badge when ofsted_rc_date is present', () => { it('returns Report Card badge when framework is ReportCard', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_rc_date: '2026-02-03' }); const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' });
expect(badge.label).toBe('Report Card · 2026'); expect(badge.label).toBe('Report Card · 2025');
expect(badge.cssClass).toBe('ofstedRc');
});
it('a report card wins over a carried-forward legacy grade', () => {
// The production bug: a report-card school (e.g. Barclay) also carries a
// carried-forward legacy grade (ofsted_grade), which used to win and label
// the pin "Outstanding · 2021" instead of "Report Card · 2026".
const badge = buildOfstedListBadge({
ofsted_grade: 1,
ofsted_date: '2021-10-07',
ofsted_framework: 'Schools - S5',
ofsted_rc_date: '2026-02-03',
});
expect(badge.label).toBe('Report Card · 2026');
expect(badge.cssClass).toBe('ofstedRc'); expect(badge.cssClass).toBe('ofstedRc');
}); });
+3 -8
View File
@@ -183,15 +183,10 @@ body {
} }
/* Secondary: teal outline — supporting actions (+ Compare) */ /* Secondary: teal outline — supporting actions (+ Compare) */
/* NOTE: a duplicate `.btn` block further down this file sets `border: none`,
which wins over the base `.btn`'s `1px solid transparent`. The outline
variants below therefore declare the full `border` shorthand explicitly so
they don't depend on the base border-width — otherwise `border-color` alone
has no width and the outline never renders (buttons read as plain text). */
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
color: var(--accent-teal); color: var(--accent-teal);
border: 1px solid var(--accent-teal); border-color: var(--accent-teal);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--accent-teal-bg); background: var(--accent-teal-bg);
@@ -201,7 +196,7 @@ body {
.btn-tertiary { .btn-tertiary {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-secondary); color: var(--text-secondary);
border: 1px solid var(--border-color); border-color: var(--border-color);
} }
.btn-tertiary:hover:not(:disabled) { .btn-tertiary:hover:not(:disabled) {
background: var(--border-color); background: var(--border-color);
@@ -212,7 +207,7 @@ body {
.btn-active { .btn-active {
background: var(--accent-teal-bg); background: var(--accent-teal-bg);
color: var(--accent-teal); color: var(--accent-teal);
border: 1px solid var(--accent-teal); border-color: var(--accent-teal);
} }
.btn-active:hover:not(:disabled) { .btn-active:hover:not(:disabled) {
background: transparent; background: transparent;
+7 -18
View File
@@ -10,7 +10,6 @@ import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js'; import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import { formatAcademicYear } from '@/lib/utils'; import { formatAcademicYear } from '@/lib/utils';
import { fillAcademicYears } from '@/lib/compareChartData';
import type { SchoolAdmissions } from '@/lib/types'; import type { SchoolAdmissions } from '@/lib/types';
import styles from './AdmissionsTrendChart.module.css'; import styles from './AdmissionsTrendChart.module.css';
@@ -18,22 +17,13 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
const pts = history.filter((h) => h.first_preference_offer_pct != null); const pts = history.filter((h) => h.first_preference_offer_pct != null);
if (pts.length < 2) return null; if (pts.length < 2) return null;
// Gap-honest axis: every academic year between the first and last data point const labels = pts.map((p) => formatAcademicYear(p.year));
// appears, so a missing admissions year renders as a real gap (spanGaps:false) const values = pts.map((p) => p.first_preference_offer_pct as number);
// rather than compressing time between distant years. const lastIdx = pts.length - 1;
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 0100. // Auto-scale with headroom so variation is visible, clamped to 0100.
const numeric = values.filter((v): v is number => v != null); const lo = Math.min(...values);
const lo = Math.min(...numeric); const hi = Math.max(...values);
const hi = Math.max(...numeric);
const padded = Math.max(5, Math.round((hi - lo) * 0.25)); const padded = Math.max(5, Math.round((hi - lo) * 0.25));
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5); const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5); const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
@@ -79,16 +69,15 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
label: 'First-choice offer rate', label: 'First-choice offer rate',
data: values, data: values,
clip: false as const, clip: false as const,
spanGaps: false,
borderColor: '#e07256', borderColor: '#e07256',
backgroundColor: 'rgba(224,114,86,0.10)', backgroundColor: 'rgba(224,114,86,0.10)',
borderWidth: 2.5, borderWidth: 2.5,
tension: 0.3, tension: 0.3,
fill: true, fill: true,
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)), pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)),
pointBackgroundColor: '#e07256', pointBackgroundColor: '#e07256',
pointBorderColor: '#fff', pointBorderColor: '#fff',
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)), pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)),
pointHoverRadius: 6, pointHoverRadius: 6,
}, },
], ],
+3 -7
View File
@@ -43,13 +43,6 @@ interface PopupBadge {
} }
function buildPopupBadge(school: School): PopupBadge { function buildPopupBadge(school: School): PopupBadge {
// A report card wins over any carried-forward legacy grade — its presence is
// signalled by ofsted_rc_date (the list has no full report_card object, and
// ofsted_framework is the raw event grouping, never "ReportCard").
if (school.ofsted_rc_date) {
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
}
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null; const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
const yearStr = year ? ` · ${year}` : ''; const yearStr = year ? ` · ${year}` : '';
if (school.ofsted_grade) { if (school.ofsted_grade) {
@@ -62,6 +55,9 @@ function buildPopupBadge(school: School): PopupBadge {
}; };
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] }; return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
} }
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' };
}
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' }; return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
} }
+1 -7
View File
@@ -123,13 +123,7 @@
.modal { .modal {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
/* Bottom sheet sized against the overlay (which tracks the visual max-height: 95vh;
viewport), NOT vh: when the keyboard is open the overlay is short, so
max-height:100% keeps the whole sheet — input and results — above the
keyboard. min-height gives a comfortable default without a tiny stub,
but is capped at 100% so it never exceeds the visible area. */
min-height: min(55vh, 100%);
max-height: 100%;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
animation: slideUp 0.3s ease; animation: slideUp 0.3s ease;
+2 -30
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useEffect, useCallback, useRef } from 'react'; import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import styles from './Modal.module.css'; import styles from './Modal.module.css';
@@ -18,8 +18,6 @@ interface ModalProps {
} }
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) { export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const handleEscape = useCallback((e: KeyboardEvent) => { const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onClose(); onClose();
@@ -41,32 +39,6 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
}; };
}, [isOpen, handleEscape]); }, [isOpen, handleEscape]);
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
// the on-screen keyboard shrinks the visual viewport but not the layout one,
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
useEffect(() => {
if (!isOpen) return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
const el = overlayRef.current;
if (!vv || !el) return;
const sync = () => {
el.style.top = `${vv.offsetTop}px`;
el.style.height = `${vv.height}px`;
el.style.bottom = 'auto';
};
sync();
vv.addEventListener('resize', sync);
vv.addEventListener('scroll', sync);
return () => {
vv.removeEventListener('resize', sync);
vv.removeEventListener('scroll', sync);
};
}, [isOpen]);
if (!isOpen || typeof window === 'undefined') return null; if (!isOpen || typeof window === 'undefined') return null;
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
@@ -76,7 +48,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
}; };
return createPortal( return createPortal(
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}> <div className={styles.overlay} onClick={handleOverlayClick}>
<div className={`${styles.modal} ${styles[size]}`}> <div className={`${styles.modal} ${styles[size]}`}>
<div className={styles.header}> <div className={styles.header}>
{title && <h2 className={styles.title}>{title}</h2>} {title && <h2 className={styles.title}>{title}</h2>}
+23 -44
View File
@@ -16,7 +16,6 @@ import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import type { SchoolResult } from '@/lib/types'; import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils'; import { formatAcademicYear } from '@/lib/utils';
import { fillAcademicYears } from '@/lib/compareChartData';
import { useIsMobile } from '@/hooks/useIsMobile'; import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css'; import styles from './PerformanceChart.module.css';
@@ -36,6 +35,8 @@ interface PerformanceChartProps {
nationalByYear?: NationalByYear[]; nationalByYear?: NationalByYear[];
} }
const COVID_YEARS = new Set([201920, 202021]);
// Mobile chip definitions: which datasets render when each chip is active. // Mobile chip definitions: which datasets render when each chip is active.
// `series` keys reference the dataset labels so we can filter cleanly. // `series` keys reference the dataset labels so we can filter cleanly.
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8'; type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
@@ -66,33 +67,21 @@ export function PerformanceChart({
nationalByYear, nationalByYear,
}: PerformanceChartProps) { }: PerformanceChartProps) {
const sortedData = [...data].sort((a, b) => a.year - b.year); 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(); const isMobile = useIsMobile();
// ── Build per-year national averages (aligned to the filled axis) ──── // ── Build per-year national averages ─────────────────────────────────
const natRefRwm: (number | null)[] = axisYears.map(y => { const natRefRwm: (number | null)[] = sortedData.map(d => {
if (nationalByYear) { if (nationalByYear) {
const match = nationalByYear.find(n => n.year === y); const match = nationalByYear.find(n => n.year === d.year);
return match?.primary?.rwm_expected_pct ?? null; return match?.primary?.rwm_expected_pct ?? null;
} }
return nationalRwmAvg ?? null; return nationalRwmAvg ?? null;
}); });
const natRefAtt8: (number | null)[] = axisYears.map(y => { const natRefAtt8: (number | null)[] = sortedData.map(d => {
if (nationalByYear) { if (nationalByYear) {
const match = nationalByYear.find(n => n.year === y); const match = nationalByYear.find(n => n.year === d.year);
return match?.secondary?.attainment_8_score ?? null; return match?.secondary?.attainment_8_score ?? null;
} }
return nationalAtt8Avg ?? null; return nationalAtt8Avg ?? null;
@@ -118,11 +107,10 @@ export function PerformanceChart({
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`; return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
})(); })();
// A gap year is any filled axis year the school has no results row for — const hasCovidGap = isSecondary
// exactly the cancelled/unpublished years the fill introduced. Drives the ? false
// honest note below (KS2 and KS4 have different gap stories). : COVID_YEARS.size > 0 &&
const gapYears = axisYears.filter(y => !byYear.has(y)); [...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
const hasGap = gapYears.length > 0;
// ── Datasets (full set; mobile filters them via the active chip) ───── // ── Datasets (full set; mobile filters them via the active chip) ─────
const refLineStyle = { const refLineStyle = {
@@ -132,14 +120,13 @@ export function PerformanceChart({
borderDash: [6, 4] as number[], borderDash: [6, 4] as number[],
pointRadius: 0, pointRadius: 0,
tension: 0, tension: 0,
spanGaps: false as const,
order: 10, order: 10,
}; };
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [ const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
{ {
label: 'Attainment 8', label: 'Attainment 8',
data: col('attainment_8_score'), data: sortedData.map(d => d.attainment_8_score),
borderColor: '#2d7d7d', borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)', backgroundColor: 'rgba(45,125,125,0.08)',
borderWidth: 2.5, borderWidth: 2.5,
@@ -150,7 +137,7 @@ export function PerformanceChart({
}, },
{ {
label: 'English & Maths Grade 4+', label: 'English & Maths Grade 4+',
data: col('english_maths_standard_pass_pct'), data: sortedData.map(d => d.english_maths_standard_pass_pct),
borderColor: '#c9a227', borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,0.08)', backgroundColor: 'rgba(201,162,39,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -160,7 +147,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Progress 8', label: 'Progress 8',
data: col('progress_8_score'), data: sortedData.map(d => d.progress_8_score),
borderColor: 'rgb(139,92,246)', borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,0.08)', backgroundColor: 'rgba(139,92,246,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -178,7 +165,7 @@ export function PerformanceChart({
] : [ ] : [
{ {
label: 'Reading, Writing & Maths expected %', label: 'Reading, Writing & Maths expected %',
data: col('rwm_expected_pct'), data: sortedData.map(d => d.rwm_expected_pct),
borderColor: '#2d7d7d', borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)', backgroundColor: 'rgba(45,125,125,0.08)',
borderWidth: 2.5, borderWidth: 2.5,
@@ -189,7 +176,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Exceeding expected level', label: 'Exceeding expected level',
data: col('rwm_high_pct'), data: sortedData.map(d => d.rwm_high_pct),
borderColor: '#c9a227', borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,0.08)', backgroundColor: 'rgba(201,162,39,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -205,7 +192,7 @@ export function PerformanceChart({
} as ChartDataset<'line'>] : []), } as ChartDataset<'line'>] : []),
{ {
label: 'Reading progress', label: 'Reading progress',
data: col('reading_progress'), data: sortedData.map(d => d.reading_progress),
borderColor: 'rgb(59,130,246)', borderColor: 'rgb(59,130,246)',
backgroundColor: 'rgba(59,130,246,0.08)', backgroundColor: 'rgba(59,130,246,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -216,7 +203,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Writing progress', label: 'Writing progress',
data: col('writing_progress'), data: sortedData.map(d => d.writing_progress),
borderColor: 'rgb(139,92,246)', borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,0.08)', backgroundColor: 'rgba(139,92,246,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -227,7 +214,7 @@ export function PerformanceChart({
}, },
{ {
label: 'Maths progress', label: 'Maths progress',
data: col('maths_progress'), data: sortedData.map(d => d.maths_progress),
borderColor: 'rgb(236,72,153)', borderColor: 'rgb(236,72,153)',
backgroundColor: 'rgba(236,72,153,0.08)', backgroundColor: 'rgba(236,72,153,0.08)',
borderWidth: 1.5, borderWidth: 1.5,
@@ -288,8 +275,6 @@ export function PerformanceChart({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
// Never bridge missing years — cancelled/unpublished years are real gaps.
datasets: { line: { spanGaps: false } },
plugins: { plugins: {
legend: { legend: {
position: 'top', position: 'top',
@@ -341,7 +326,6 @@ export function PerformanceChart({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
datasets: { line: { spanGaps: false } },
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: false }, title: { display: false },
@@ -369,10 +353,7 @@ export function PerformanceChart({
}, },
x: { x: {
grid: { display: false }, grid: { display: false },
// With the gap-honest axis (more year labels) autoSkip keeps the ticks: { font: { size: 10 }, autoSkip: false },
// 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 },
}, },
}, },
}; };
@@ -435,11 +416,9 @@ export function PerformanceChart({
</div> </div>
)} )}
{hasGap && ( {hasCovidGap && (
<p className={styles.covidNote}> <p className={styles.covidNote}>
{isSecondary * No data for 2019/20 or 2020/21 national assessments were cancelled due to COVID-19.
? "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> </p>
)} )}
@@ -1040,41 +1040,6 @@
color: var(--accent-coral-dark, #b04a2e); 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 { .ofstedDisclaimer {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
+60 -141
View File
@@ -18,10 +18,9 @@ import type {
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { import {
formatPercentage, formatProgress, formatAcademicYear, isProposedToClose, ofstedLegacyAreas, formatPercentage, formatProgress, formatAcademicYear, isProposedToClose,
} from '@/lib/utils'; } from '@/lib/utils';
import { DeltaChip } from './DeltaChip'; import { DeltaChip } from './DeltaChip';
import { summariseAdmissions } from '@/lib/compareLogic';
const PerformanceChart = dynamic( const PerformanceChart = dynamic(
() => import('./PerformanceChart').then((m) => m.PerformanceChart), () => import('./PerformanceChart').then((m) => m.PerformanceChart),
@@ -86,10 +85,6 @@ export function SchoolDetailView({
// Trend toggle only appears with ≥2 years carrying an offer rate. // Trend toggle only appears with ≥2 years carrying an offer rate.
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length; const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
const showAdmissionsTrend = admissionsOfferYears >= 2; 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. // Only the section links scroll horizontally; Back and "All" stay pinned.
const sectionLinksRef = useRef<HTMLDivElement | null>(null); const sectionLinksRef = useRef<HTMLDivElement | null>(null);
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false); const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
@@ -158,16 +153,10 @@ export function SchoolDetailView({
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null; const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
// Phase detection. All-through schools cover BOTH key stages, so they are // Phase detection
// 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 phase = schoolInfo.phase ?? '';
const isAllThrough = phase.toLowerCase() === 'all-through'; const isSecondary = phase.toLowerCase().includes('secondary') || phase.toLowerCase() === 'all-through';
const isSecondary = phase.toLowerCase().includes('secondary') || isAllThrough;
const isPrimary = !isSecondary; 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) // National averages (fetched dynamically so they stay current)
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null); const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
@@ -239,11 +228,11 @@ export function SchoolDetailView({
// after the recognised Ofsted badge; low-demand context sections stay last. // after the recognised Ofsted badge; low-demand context sections stay last.
const navItems: { id: string; label: string }[] = []; const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' }); if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
if (hasAnyResults) navItems.push({ id: 'results', label: isAllThrough ? 'Results' : isSecondary ? 'GCSEs' : 'SATs' }); if (hasAnyResults) navItems.push({ id: 'results', label: isSecondary ? 'GCSEs' : 'SATs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' }); if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
if (hasPhonics && showPrimaryContent) navItems.push({ id: 'phonics', label: 'Phonics' }); if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -280,28 +269,18 @@ export function SchoolDetailView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]); }, [navItems.map(n => n.id).join(',')]);
// A report card is identified by the presence of report-card area
// judgements, NOT by `framework` — the API sets `framework` to the raw
// event grouping (e.g. "Schools - S5") even for report-card schools, so
// the old `framework === 'ReportCard'` test never matched and report cards
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
const isReportCard = !!(
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
);
// A report card is dated by its own inspection (rc_inspection_date); the
// legacy inspection_date belongs to an older inspection and must never
// date a report card (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ─────────── // ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : []; const oeifAllSameGrade = (() => {
const oeifAllSameGrade = if (!ofsted || ofsted.framework === 'ReportCard') return false;
!!ofsted && const subs = [
!isReportCard && ofsted.quality_of_education,
oeifAreas.length >= 3 && ofsted.behaviour_attitudes,
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness); 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);
})();
// Label shown in the mobile "section" menu button — the section in view. // Label shown in the mobile "section" menu button — the section in view.
const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? ''; const activeNavLabel = (navItems.find((n) => n.id === activeSection) ?? navItems[0])?.label ?? '';
@@ -330,9 +309,6 @@ export function SchoolDetailView({
{schoolInfo.school_type && ( {schoolInfo.school_type && (
<span className={styles.metaItem}>{schoolInfo.school_type}</span> <span className={styles.metaItem}>{schoolInfo.school_type}</span>
)} )}
{isAllThrough && (
<span className={styles.metaItem}>All-through (primary &amp; secondary)</span>
)}
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && ( {schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
<span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span> <span className={styles.metaItem}>{schoolInfo.gender}&apos;s school</span>
)} )}
@@ -531,10 +507,10 @@ export function SchoolDetailView({
{ofsted && ( {ofsted && (
<section id="ofsted" className={styles.card}> <section id="ofsted" className={styles.card}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'} {ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofstedInspectedDate && ( {ofsted.inspection_date && (
<span className={styles.ofstedDate}> <span className={styles.ofstedDate}>
Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span> </span>
)} )}
<a <a
@@ -549,13 +525,13 @@ export function SchoolDetailView({
</a> </a>
</h2> </h2>
{isReportCard ? ( {ofsted.framework === 'ReportCard' ? (
/* ── New Report Card layout ── */ /* ── New Report Card layout ── */
<> <>
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas. From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p> </p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && ( {ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div> <div className={styles.metricLabel}>Safeguarding</div>
@@ -592,17 +568,23 @@ export function SchoolDetailView({
)} )}
</div> </div>
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
{ofsted.grade_source === 'ungraded_carried_forward' From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
? '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> </p>
{oeifAllSameGrade ? ( {oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}> <p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership. Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p> </p>
) : ( ) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => ( {[
{ 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 key={label} className={styles.metricCard}> <div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div> <div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}> <div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
@@ -621,22 +603,17 @@ export function SchoolDetailView({
{hasAnyResults && latestResults && ( {hasAnyResults && latestResults && (
<section id="results" className={styles.card}> <section id="results" className={styles.card}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
{isAllThrough ? 'SATs & GCSE Results' : isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)}) {isSecondary ? 'GCSE Results' : 'SATs Results'} ({formatAcademicYear(latestResults.year)})
</h2> </h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
{isAllThrough {isSecondary
? 'KS2 SATs (end of Year 6) and GCSE results (Year 11) — this school covers both. England averages shown for comparison.' ? 'GCSE results for Year 11 pupils. National averages shown for comparison.'
: isSecondary : 'End-of-primary-school tests taken by Year 6 pupils. National averages shown for comparison.'}
? '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> </p>
{/* ── Primary / KS2 content ── */} {/* ── Primary / KS2 content ── */}
{hasKS2Results && ( {hasKS2Results && (
<> <>
{isAllThrough && (
<h3 className={styles.subSectionTitle}>Primary KS2 SATs (Year 6)</h3>
)}
<div className={styles.heroStatGrid}> <div className={styles.heroStatGrid}>
{latestResults.rwm_expected_pct !== null && ( {latestResults.rwm_expected_pct !== null && (
<div className={styles.heroStatCard}> <div className={styles.heroStatCard}>
@@ -656,7 +633,7 @@ export function SchoolDetailView({
)} )}
</div> </div>
{primaryAvg.rwm_expected_pct != null && ( {primaryAvg.rwm_expected_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -678,7 +655,7 @@ export function SchoolDetailView({
)} )}
</div> </div>
{primaryAvg.rwm_high_pct != null && ( {primaryAvg.rwm_high_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -775,9 +752,6 @@ export function SchoolDetailView({
{/* ── Secondary / KS4 content ── */} {/* ── Secondary / KS4 content ── */}
{hasKS4Results && ( {hasKS4Results && (
<> <>
{isAllThrough && (
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.5rem' }}>Secondary GCSEs (Year 11)</h3>
)}
<div className={styles.metricsGrid}> <div className={styles.metricsGrid}>
{latestResults.attainment_8_score !== null && ( {latestResults.attainment_8_score !== null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
@@ -787,7 +761,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div> <div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
{secondaryAvg.attainment_8_score != null && ( {secondaryAvg.attainment_8_score != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div> <div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)} )}
</div> </div>
)} )}
@@ -811,7 +785,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.metricValue}>{formatPercentage(latestResults.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 && ( {secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div> <div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -823,7 +797,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.metricValue}>{formatPercentage(latestResults.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 && ( {secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.metricHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div> <div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -928,9 +902,6 @@ export function SchoolDetailView({
</div> </div>
)} )}
</dl> </dl>
{admissionsSummary.chip && (
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
)}
</div> </div>
{/* Multi-year trend */} {/* Multi-year trend */}
@@ -966,7 +937,7 @@ export function SchoolDetailView({
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" /> <DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
)} )}
</div> </div>
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · England avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div> <div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
</div> </div>
)} )}
{latestResults?.eal_pct != null && ( {latestResults?.eal_pct != null && (
@@ -982,7 +953,7 @@ export function SchoolDetailView({
)} )}
</div> </div>
{primaryAvg.eal_pct != null && ( {primaryAvg.eal_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.eal_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -999,7 +970,7 @@ export function SchoolDetailView({
)} )}
</div> </div>
{primaryAvg.sen_support_pct != null && ( {primaryAvg.sen_support_pct != null && (
<div className={styles.heroStatHint}>England avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -1063,52 +1034,16 @@ export function SchoolDetailView({
{yearlyData.length > 0 && ( {yearlyData.length > 0 && (
<section id="history" className={styles.card}> <section id="history" className={styles.card}>
<h2 className={styles.sectionTitle}>Results Over Time</h2> <h2 className={styles.sectionTitle}>Results Over Time</h2>
{isAllThrough ? ( <div className={styles.chartContainer}>
// All-through: KS2 and KS4 trends are on different scales and have <PerformanceChart
// different gap stories, so render them as two stacked charts data={yearlyData}
// rather than crowding 8+ series onto one axis. schoolName={schoolInfo.school_name}
<> isSecondary={isSecondary}
{hasKS2Results && ( nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
<> nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
<h3 className={styles.subSectionTitle}>Primary KS2 SATs</h3> nationalByYear={nationalAvg?.by_year}
<div className={styles.chartContainer}> />
<PerformanceChart </div>
data={yearlyData}
schoolName={schoolInfo.school_name}
isSecondary={false}
nationalRwmAvg={primaryAvg.rwm_expected_pct ?? null}
nationalByYear={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={secondaryAvg.attainment_8_score ?? null}
nationalByYear={nationalAvg?.by_year}
/>
</div>
</>
)}
</>
) : (
<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>
)}
{yearlyData.length > 1 && ( {yearlyData.length > 1 && (
<details className={styles.historyDisclosure}> <details className={styles.historyDisclosure}>
<summary className={styles.historyToggle}>View raw year-by-year data</summary> <summary className={styles.historyToggle}>View raw year-by-year data</summary>
@@ -1117,15 +1052,7 @@ export function SchoolDetailView({
<thead> <thead>
<tr> <tr>
<th>Year</th> <th>Year</th>
{isAllThrough ? ( {isSecondary ? (
<>
<th>RWM (expected %)</th>
<th>Exceeding (%)</th>
<th>Attainment 8</th>
<th>Progress 8</th>
<th>English &amp; Maths Grade 4+</th>
</>
) : isSecondary ? (
<> <>
<th>Attainment 8</th> <th>Attainment 8</th>
<th>Progress 8</th> <th>Progress 8</th>
@@ -1147,15 +1074,7 @@ export function SchoolDetailView({
{yearlyData.map((result) => ( {yearlyData.map((result) => (
<tr key={result.year}> <tr key={result.year}>
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td> <td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
{isAllThrough ? ( {isSecondary ? (
<>
<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.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.progress_8_score !== null ? formatProgress(result.progress_8_score) : '-'}</td>
@@ -1180,8 +1099,8 @@ export function SchoolDetailView({
)} )}
</section> </section>
)} )}
{/* Year 1 Phonics — primary-stage metric (pure primary + all-through) */} {/* Year 1 Phonics — primary only */}
{hasPhonics && showPrimaryContent && phonics && ( {hasPhonics && isPrimary && phonics && (
<section id="phonics" className={styles.card}> <section id="phonics" className={styles.card}>
<h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2> <h2 className={styles.sectionTitle}>Year 1 Phonics ({formatAcademicYear(phonics.year)})</h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
@@ -1223,7 +1142,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div> <div className={styles.metricValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
{primaryAvg.overall_absence_pct != null && ( {primaryAvg.overall_absence_pct != null && (
<div className={styles.metricHint}>England avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div> <div className={styles.metricHint}>National avg: ~{primaryAvg.overall_absence_pct.toFixed(1)}%</div>
)} )}
</div> </div>
)} )}
@@ -1235,7 +1154,7 @@ export function SchoolDetailView({
</div> </div>
<div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div> <div className={styles.metricValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
{primaryAvg.persistent_absence_pct != null && ( {primaryAvg.persistent_absence_pct != null && (
<div className={styles.metricHint}>England avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div> <div className={styles.metricHint}>National avg: ~{primaryAvg.persistent_absence_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -2,6 +2,14 @@
padding: 1.5rem; padding: 1.5rem;
} }
.title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 1.5rem;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.warning { .warning {
background: var(--accent-gold-bg); background: var(--accent-gold-bg);
border: 1px solid var(--accent-gold, #c9a227); border: 1px solid var(--accent-gold, #c9a227);
@@ -111,16 +119,12 @@
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
margin-bottom: 0.25rem; margin-bottom: 0.5rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.resultButton {
flex: 0 0 auto;
}
.schoolMeta { .schoolMeta {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -155,30 +159,21 @@
padding: 1rem; padding: 1rem;
} }
/* One scroll container on mobile: the modal content itself scrolls, so the .title {
results list must not add its own inner scroll (double scrollbars, and font-size: 1.25rem;
the input would be trapped above a short 400px window when the keyboard
shrinks the sheet). */
.results {
max-height: none;
overflow: visible;
} }
/* Compact stacked card: name + meta, then a full-width action so the tap
target is obvious and the card doesn't waste vertical space. */
.resultItem { .resultItem {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 0.625rem;
padding: 0.875rem;
} }
.resultButton { .addButton {
width: 100%; width: 100%;
} }
.schoolMeta { .schoolMeta {
flex-wrap: wrap; flex-direction: column;
gap: 0.25rem 1rem; gap: 0.25rem;
} }
} }
+5 -3
View File
@@ -81,8 +81,10 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
}; };
return ( return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison"> <Modal isOpen={isOpen} onClose={handleClose}>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<h2 className={styles.title}>Add School to Comparison</h2>
{!canAddMore && ( {!canAddMore && (
<div className={styles.warning}> <div className={styles.warning}>
Maximum 5 schools can be compared. Remove a school to add another. Maximum 5 schools can be compared. Remove a school to add another.
@@ -127,9 +129,9 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
<button <button
onClick={() => handleAddSchool(school)} onClick={() => handleAddSchool(school)}
disabled={alreadySelected || !canAddMore} disabled={alreadySelected || !canAddMore}
className={`${styles.resultButton} ${ className={
alreadySelected ? "btn btn-active" : "btn btn-secondary" alreadySelected ? "btn btn-active" : "btn btn-secondary"
}`} }
> >
{alreadySelected ? "✓ Comparing" : "+ Compare"} {alreadySelected ? "✓ Comparing" : "+ Compare"}
</button> </button>
@@ -623,41 +623,6 @@
color: var(--accent-coral-dark, #b04a2e); 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 { .ofstedDisclaimer {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted, #8a847a); color: var(--text-muted, #8a847a);
@@ -1084,12 +1049,8 @@
font-size: 1.85rem; 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 { .chartContainer {
height: auto; height: 220px;
} }
.dataTable { .dataTable {
@@ -23,7 +23,7 @@ import type {
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose, ofstedLegacyAreas } from '@/lib/utils'; import { formatPercentage, formatProgress, formatAcademicYear, formatAgeRange, isProposedToClose } from '@/lib/utils';
import { DeltaChip } from './DeltaChip'; import { DeltaChip } from './DeltaChip';
import { track, getNavigationSource } from '@/lib/analytics'; import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css'; import styles from './SecondarySchoolDetailView.module.css';
@@ -186,27 +186,18 @@ export function SecondarySchoolDetailView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navItems.map(n => n.id).join(',')]); }, [navItems.map(n => n.id).join(',')]);
// A report card is identified by the presence of report-card area
// judgements, NOT by `framework` — the API sets `framework` to the raw
// event grouping (e.g. "Schools - S5") even for report-card schools, so
// the old `framework === 'ReportCard'` test never matched and report cards
// were rendered as legacy ratings dated to a pre-Nov-2025 inspection.
const isReportCard = !!(
ofsted?.report_card && Object.keys(ofsted.report_card).length > 0
);
// Report cards are dated by their own inspection (rc_inspection_date), never
// the legacy inspection_date (report cards exist only from Nov 2025).
const ofstedInspectedDate = isReportCard
? ofsted?.rc_inspection_date ?? null
: ofsted?.inspection_date ?? null;
// ── Ofsted: detect if all OEIF sub-grades match the overall ─────────── // ── Ofsted: detect if all OEIF sub-grades match the overall ───────────
const oeifAreas = ofsted ? ofstedLegacyAreas(ofsted) : []; const oeifAllSameGrade = (() => {
const oeifAllSameGrade = if (!ofsted || ofsted.framework === 'ReportCard') return false;
!!ofsted && const subs = [
!isReportCard && ofsted.quality_of_education,
oeifAreas.length >= 3 && ofsted.behaviour_attitudes,
oeifAreas.every((a) => a.value === ofsted.overall_effectiveness); 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);
})();
// National Attainment 8 baseline for the "Results Over Time" chart. // National Attainment 8 baseline for the "Results Over Time" chart.
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null; const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
@@ -341,10 +332,10 @@ export function SecondarySchoolDetailView({
{ofsted && ( {ofsted && (
<section id="ofsted" className={styles.card}> <section id="ofsted" className={styles.card}>
<h2 className={styles.sectionTitle}> <h2 className={styles.sectionTitle}>
{isReportCard ? 'Ofsted Report Card' : 'Ofsted Rating'} {ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
{ofstedInspectedDate && ( {ofsted.inspection_date && (
<span className={styles.ofstedDate}> <span className={styles.ofstedDate}>
{' '}Inspected {new Date(ofstedInspectedDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })} {' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</span> </span>
)} )}
<a <a
@@ -358,12 +349,12 @@ export function SecondarySchoolDetailView({
Ofsted reports Ofsted reports
</a> </a>
</h2> </h2>
{isReportCard ? ( {ofsted.framework === 'ReportCard' ? (
<> <>
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas. From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
</p> </p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{ofsted.rc_safeguarding_met != null && ( {ofsted.rc_safeguarding_met != null && (
<div className={styles.metricCard}> <div className={styles.metricCard}>
<div className={styles.metricLabel}>Safeguarding</div> <div className={styles.metricLabel}>Safeguarding</div>
@@ -399,17 +390,23 @@ export function SecondarySchoolDetailView({
)} )}
</div> </div>
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
{ofsted.grade_source === 'ungraded_carried_forward' From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
? '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> </p>
{oeifAllSameGrade ? ( {oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}> <p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership. Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
</p> </p>
) : ( ) : (
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{oeifAreas.map(({ label, value }) => ( {[
{ 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 key={label} className={styles.metricCard}> <div key={label} className={styles.metricCard}>
<div className={styles.metricLabel}>{label}</div> <div className={styles.metricLabel}>{label}</div>
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}> <div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
@@ -425,7 +422,7 @@ export function SecondarySchoolDetailView({
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
From September 2024, Ofsted no longer gives a single overall grade. From September 2024, Ofsted no longer gives a single overall grade.
</p> </p>
<div className={`${styles.metricsGrid} ${styles.gradeGrid}`}> <div className={styles.metricsGrid}>
{[ {[
{ label: 'Quality of Education', value: ofsted.quality_of_education }, { label: 'Quality of Education', value: ofsted.quality_of_education },
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes }, { label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
@@ -452,13 +449,12 @@ export function SecondarySchoolDetailView({
GCSE Results ({formatAcademicYear(latestResults.year)}) GCSE Results ({formatAcademicYear(latestResults.year)})
</h2> </h2>
<p className={styles.sectionSubtitle}> <p className={styles.sectionSubtitle}>
GCSE results for Year 11 pupils. England averages shown for comparison. GCSE results for Year 11 pupils. National averages shown for comparison.
</p> </p>
{p8Suspended && ( {p8Suspended && (
<div className={styles.p8Banner}> <div className={styles.p8Banner}>
Progress 8 isn&apos;t published for 2024/25: this GCSE year group sat no KS2 tests Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
(COVID), so DfE has no starting point to measure their progress from.
</div> </div>
)} )}
@@ -482,7 +478,7 @@ export function SecondarySchoolDetailView({
)} )}
</div> </div>
{secondaryAvg.attainment_8_score != null && ( {secondaryAvg.attainment_8_score != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div> <div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
)} )}
</div> </div>
)} )}
@@ -522,7 +518,7 @@ export function SecondarySchoolDetailView({
)} )}
</div> </div>
{secondaryAvg.english_maths_strong_pass_pct != null && ( {secondaryAvg.english_maths_strong_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -544,7 +540,7 @@ export function SecondarySchoolDetailView({
)} )}
</div> </div>
{secondaryAvg.english_maths_standard_pass_pct != null && ( {secondaryAvg.english_maths_standard_pass_pct != null && (
<div className={styles.heroStatHint}>England avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div> <div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
)} )}
</div> </div>
)} )}
@@ -162,21 +162,6 @@ export function CompareAcademics({
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct'); const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct'); const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score; const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
// Every headline number gets its England anchor + verdict chip, so the
// "anchored against the England average" promise holds for the grade-5
// and EBacc rows too, not just Attainment 8.
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
if (value == null || anchor == null) return null;
const v = verdict(value, anchor, tol);
return (
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
</Chip>
);
};
return ( return (
<Section <Section
@@ -189,8 +174,7 @@ export function CompareAcademics({
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{att8[i] != null ? ( {att8[i] != null ? (
<> <>
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '} <span className={s.big}>{(att8[i] as number).toFixed(1)}</span>
{anchorChip(att8[i], att8Anchor, 2)}
{att8Anchor != null && ( {att8Anchor != null && (
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span> <span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
)} )}
@@ -232,38 +216,14 @@ export function CompareAcademics({
</RowLabel> </RowLabel>
{schools.map((school, i) => ( {schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{grade5[i] != null ? ( {grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>}
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(grade5[i] as number)}%
</span>{' '}
{anchorChip(grade5[i], grade5Anchor, 3)}
{grade5Anchor != null && (
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell> </Cell>
))} ))}
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel> <RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
{schools.map((school, i) => ( {schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{ebacc[i] != null ? ( {ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>}
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(ebacc[i] as number)}%
</span>{' '}
{anchorChip(ebacc[i], ebaccAnchor, 3)}
{ebaccAnchor != null && (
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell> </Cell>
))} ))}
</SectionGrid> </SectionGrid>
@@ -239,17 +239,8 @@ export function CompareOfsted({
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`; `https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
return ( return (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{/* Short visible label to save space on mobile (the coloured <a className={s.link} href={url} target="_blank" rel="noopener noreferrer">
school name already leads the row); the full name stays in {school.school_name}&apos;s Ofsted page
aria-label so screen readers can tell the links apart. */}
<a
className={s.link}
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${school.school_name}'s Ofsted page`}
>
Ofsted page
</a> </a>
</Cell> </Cell>
); );
@@ -72,18 +72,15 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
flex-basis: 100%; flex-basis: 100%;
/* Slightly larger than the values below it so the school each row belongs font-size: 0.8rem;
to is easy to read on mobile (hidden on desktop, where the column header
names the school). */
font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--sc, var(--text-secondary)); color: var(--sc, var(--text-secondary));
margin-bottom: 0.2rem; margin-bottom: 0.15rem;
} }
.cellDot { .cellDot {
width: 10px; width: 9px;
height: 10px; height: 9px;
border-radius: 50%; border-radius: 50%;
background: var(--dot, var(--text-muted)); background: var(--dot, var(--text-muted));
flex: none; flex: none;
-3
View File
@@ -68,9 +68,6 @@ export interface School {
// Ofsted (for list view — summary only) // Ofsted (for list view — summary only)
ofsted_grade?: 1 | 2 | 3 | 4 | null; ofsted_grade?: 1 | 2 | 3 | 4 | null;
/** Report-card inspection date (Nov 2025+); non-null identifies a report
* card in the list/map, where the full report_card object isn't available. */
ofsted_rc_date?: string | null;
ofsted_date?: string | null; ofsted_date?: string | null;
ofsted_framework?: string | null; ofsted_framework?: string | null;
} }
+6 -50
View File
@@ -680,42 +680,6 @@ export function buildSchoolSummary(
return parts.join(', ') + '.'; 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 (14) 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 ────────────────────────────────────────────────── // ─── List-level Ofsted badge ──────────────────────────────────────────────────
export interface OfstedListBadge { export interface OfstedListBadge {
@@ -727,12 +691,9 @@ export interface OfstedListBadge {
/** /**
* Build the Ofsted badge for a school card in the list/map view. * Build the Ofsted badge for a school card in the list/map view.
* States, in priority order: * Three states:
* - Report Card school (ofsted_rc_date set): "Report Card · YYYY" in purple.
* Checked FIRST so it wins over any carried-forward legacy grade — the
* list has no full report_card object, and ofsted_framework is the raw
* event grouping ("Schools - S5"), never "ReportCard".
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed * - OEIF school (ofsted_grade set): grade word + year, colour-keyed
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no * - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the * longer issues an overall judgement): "Inspected · YYYY" — mirrors the
* detail page's hero chip so a school never reads as both inspected and * detail page's hero chip so a school never reads as both inspected and
@@ -743,16 +704,7 @@ export function buildOfstedListBadge(school: {
ofsted_grade?: 1 | 2 | 3 | 4 | null; ofsted_grade?: 1 | 2 | 3 | 4 | null;
ofsted_date?: string | null; ofsted_date?: string | null;
ofsted_framework?: string | null; ofsted_framework?: string | null;
ofsted_rc_date?: string | null;
}): OfstedListBadge { }): OfstedListBadge {
// A report card wins over any carried-forward legacy grade — signalled by
// ofsted_rc_date. ofsted_framework is the raw event grouping ("Schools -
// S5"), never "ReportCard", so it can't detect report cards.
if (school.ofsted_rc_date) {
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' };
}
const year = school.ofsted_date const year = school.ofsted_date
? new Date(school.ofsted_date).getFullYear() ? new Date(school.ofsted_date).getFullYear()
: null; : null;
@@ -771,6 +723,10 @@ export function buildOfstedListBadge(school: {
}; };
} }
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
}
// An inspection is on record (date or framework present) but carries no // An inspection is on record (date or framework present) but carries no
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school // overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
// that has genuinely never been inspected. // that has genuinely never been inspected.